Compare commits

...
24 Commits
Author SHA1 Message Date
Gmer4Lfe 760fb4a3e2 varaverk: monitor tab overhaul — grid layout, dynamic thresholds, new cards
Layout:
- Switch from three flex rows to single CSS grid (repeat(8,1fr)) — eliminates
  gap-count discrepancy between rows; span 1/2/4/8 cards are now pixel-identical
  across all rows regardless of how many cards share the row

New cards:
- Scripts card: last-run status (ok/warn/running/unknown), clickable filter pills,
  7-row scroll, flex:1 matching Power/Parity
- Streams card: live per-second progress counters, color-coded play buttons
  (blue=live, orange=transcoding, green=direct, yellow=paused), server badges
  with counts, left-fill column layout (3 per col)
- Transcode card: NVMe indicator, active session count + scrollable list,
  session rows with server icon + title + type + method

Dynamic thresholds:
- vv_disk_thresholds() reads /boot/config/plugins/dynamix/dynamix.cfg
- Disk utilization bars: green/orange/red based on warning/critical settings
- Temperature colors: hot/max for HDD, hotssd/maxssd for SSD/NVMe
- Thresholds update every poll cycle; changing Unraid settings takes effect automatically

Other:
- SSD path detection uses findmnt FSTYPE to exclude tmpfs/ramfs (fixes RAM
  being reported as SSD transcode target)
- varaverk.css h3: add white-space:normal + overflow:hidden to neutralise
  any Unraid global h3 styles that could force card width
2026-05-24 16:56:50 -04:00
Gmer4Lfe f85493cf35 watchdogs: fix Tailscale error propagation and docker watchdog output order
- network_watchdog: remove ISSUES++ for Tailscale — it's notify-only by
  design, no restart possible; cascading to system watchdog exit 1 was noise
- docker_watchdog: move banner to top of cycle so body errors appear under
  the correct section header, not visually inside Resource Manager output
2026-05-24 16:56:32 -04:00
Gmer4Lfe 359b051e64 varaverk: System card (identity/time/uptime/buttons); merge Fallback+Partner into one card 2026-05-24 12:00:09 -04:00
Gmer4Lfe 725c371f21 varaverk: remove GPU history chart 2026-05-24 11:53:54 -04:00
Gmer4Lfe 6be9873f49 varaverk: fallback and partner cards — 250px min-width, matched flex:1 2026-05-24 11:50:51 -04:00
Gmer4Lfe 36831f0693 Revert "varaverk: top row cards equal width (flex:1 across all five)"
This reverts commit f35a0f2dd4.
2026-05-24 11:49:30 -04:00
Gmer4Lfe f35a0f2dd4 varaverk: top row cards equal width (flex:1 across all five) 2026-05-24 11:48:50 -04:00
Gmer4Lfe cead4eb46f varaverk: redesign Fallback card — host online/offline list, state line, active fallback containers with image 2026-05-24 11:47:08 -04:00
Gmer4Lfe 6bb44cae8b varaverk: Docker memory — use docker stats sum instead of raw cgroup (matches Unraid dashboard) 2026-05-24 11:41:59 -04:00
Gmer4Lfe 9e236e3f52 varaverk: fix Docker memory — use /sys/fs/cgroup/docker/memory.current (Unraid cgv2 path) 2026-05-24 11:39:41 -04:00
Gmer4Lfe c17be8d017 varaverk: add Partner card — known hosts, owner tags, Tailscale online status 2026-05-24 11:36:23 -04:00
Gmer4Lfe cbac3949e0 varaverk: network canvas 100→110px 2026-05-24 11:30:15 -04:00
Gmer4Lfe 3c26f8d767 varaverk: memory top procs — top 3, inline right of total on same line 2026-05-24 11:28:51 -04:00
Gmer4Lfe 397ec405a0 varaverk: memory card — top 5 procs by RSS as horizontal strip under total 2026-05-24 11:25:27 -04:00
Gmer4Lfe 475cb3e75c varaverk: memory card — top 3 usages by size in top-right corner 2026-05-24 11:21:43 -04:00
Gmer4Lfe 2cd0a8e66e varaverk: inline RX/TX legend with speed numbers; drop separate legend row 2026-05-24 11:20:11 -04:00
Gmer4Lfe 50614b9cbe varaverk: network canvas 52→100px 2026-05-24 11:16:49 -04:00
Gmer4Lfe 423a8e3e0a varaverk: CPU canvas 36→60px; add GPU history chart at 60px 2026-05-24 11:15:31 -04:00
Gmer4Lfe 496d70c137 Revert "varaverk: stretch CPU and GPU graphs to card bottom; add GPU history chart"
This reverts commit 660c4762f2.
2026-05-24 11:12:55 -04:00
Gmer4Lfe 660c4762f2 varaverk: stretch CPU and GPU graphs to card bottom; add GPU history chart
- CPU/GPU cards: flex-column so body div takes remaining height
- CPU canvas: flex:1 + min-height:36px instead of fixed 36px
- GPU: add vvGpuHistory + canvas (purple, same style as CPU); canvas flex:1
  stretches to fill whatever space the row's tallest card leaves over
2026-05-24 11:11:04 -04:00
Gmer4Lfe 4a22f3af66 varaverk: fix ext IP and Tailscale IP in network card
- External IP: switch from DDNS domain lookup to curl ifconfig.me with 5-min
  cache; DDNS approach broke because host detection uses HOST1_NAME but conf
  has HOST1, so domain was never resolved
- Tailscale IP: switch from hardcoded tailscale0 iface to `tailscale ip -4`;
  interface name varies (tailscale0, tailscale1) across kernels/installs
2026-05-24 11:07:15 -04:00
Gmer4Lfe b2e937526f varaverk: add media, confform, script API/include files (from previous session)
media.php: Emby/Jellyfin/Plex session poller for Streams card
confform.php: conf file form helpers for Config tab
script.php: per-script run/log API endpoint for Scheduler tab
2026-05-24 10:59:13 -04:00
Gmer4Lfe 279ac156c2 varaverk: fix monitor API — include config.php so vv_conf_vars/detect_host resolve
vv_network_stats() calls vv_conf_vars() and vv_detect_host() which are defined in
config.php. monitor.php had no require for it, causing a PHP fatal error on every
poll → empty JSON → all monitor cards stuck on "Loading...".
2026-05-24 10:55:13 -04:00
Gmer4Lfe ab3e28531e 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
2026-05-24 10:52:42 -04:00
16 changed files with 2614 additions and 92 deletions
@@ -0,0 +1,43 @@
<?php
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/scheduler.php';
require_once dirname(__DIR__) . '/include/confform.php';
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
$id = trim($_GET['id'] ?? '');
if (!$id || str_contains($id, '..')) {
echo json_encode(['ok' => false, 'error' => 'Invalid id']);
exit;
}
$groups = vv_conf_fields_for_script($id);
echo json_encode(['ok' => true, 'groups' => $groups]);
exit;
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$id = trim($_POST['id'] ?? '');
$rawJson = $_POST['changes'] ?? '[]';
if (!$id) { echo json_encode(['ok' => false, 'error' => 'Missing id']); exit; }
$changes = json_decode($rawJson, true);
if (!is_array($changes)) { echo json_encode(['ok' => false, 'error' => 'Invalid changes']); exit; }
$allowed = vv_get_conf_files();
foreach ($changes as $c) {
if (empty($c['file']) || !in_array($c['file'], $allowed, true)) {
echo json_encode(['ok' => false, 'error' => 'Unauthorized file: ' . ($c['file'] ?? '')]);
exit;
}
if (empty($c['key']) || !preg_match('/^[A-Z_][A-Z0-9_]*$/', $c['key'])) {
echo json_encode(['ok' => false, 'error' => 'Invalid key: ' . ($c['key'] ?? '')]);
exit;
}
}
$results = vv_conf_write_changes($changes);
echo json_encode(['ok' => !in_array(false, $results, true), 'files' => $results]);
exit;
}
echo json_encode(['ok' => false, 'error' => 'Method not allowed']);
@@ -19,7 +19,7 @@ $logFile = vv_job_log_path($id);
$logDir = dirname($logFile); $logDir = dirname($logFile);
if (!is_dir($logDir)) mkdir($logDir, 0755, true); 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); $flags = vv_job_flags($id);
exec('nohup env DRY_RUN=1 bash ' . escapeshellarg($script) . ($flags ? " $flags" : '') . ' >> ' . escapeshellarg($logFile) . ' 2>&1 </dev/null &'); exec('nohup env DRY_RUN=1 bash ' . escapeshellarg($script) . ($flags ? " $flags" : '') . ' >> ' . escapeshellarg($logFile) . ' 2>&1 </dev/null &');
@@ -0,0 +1,5 @@
<?php
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/media.php';
echo json_encode(vv_media_sessions());
@@ -3,12 +3,24 @@ header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/monitor.php'; require_once dirname(__DIR__) . '/include/monitor.php';
echo json_encode([ echo json_encode([
'fallback' => vv_fallback_state(), 'system' => vv_system_info(),
'fallback' => vv_fallback_state(),
'fallback_active' => vv_fallback_active(),
'partner' => vv_partner_state(),
'resources' => vv_system_resources(), 'resources' => vv_system_resources(),
'cpu' => vv_cpu_per_core(),
'mem' => vv_memory_breakdown(),
'net' => vv_network_stats(),
'gpu' => vv_gpu_stats(), 'gpu' => vv_gpu_stats(),
'gpu_procs' => vv_gpu_processes(), 'gpu_procs' => vv_gpu_processes(),
'containers' => vv_docker_containers(), 'containers' => vv_docker_containers(),
'stopped' => vv_docker_stopped(), 'stopped' => vv_docker_stopped(),
'transcode' => vv_transcode_sessions(), 'transcode' => vv_transcode_sessions(),
'ups' => vv_ups_stats(),
'parity' => vv_parity_status(),
'storage' => vv_storage_pools(),
'array_disks' => vv_array_disks(),
'scripts' => vv_scripts_status(),
'thresholds' => vv_disk_thresholds(),
'ts' => time(), 'ts' => time(),
]); ]);
@@ -19,8 +19,7 @@ $logFile = vv_job_log_path($id);
$logDir = dirname($logFile); $logDir = dirname($logFile);
if (!is_dir($logDir)) mkdir($logDir, 0755, true); if (!is_dir($logDir)) mkdir($logDir, 0755, true);
// Stamp the log so the panel shows when the run started file_put_contents($logFile, "\n── " . date('Y-m-d H:i:s') . " ──────────────────────\n", FILE_APPEND);
file_put_contents($logFile, date('[Y-m-d H:i:s]') . " Manual run started\n", FILE_APPEND);
$flags = vv_job_flags($id); $flags = vv_job_flags($id);
exec('nohup bash ' . escapeshellarg($script) . ($flags ? " $flags" : '') . ' >> ' . escapeshellarg($logFile) . ' 2>&1 </dev/null &'); exec('nohup bash ' . escapeshellarg($script) . ($flags ? " $flags" : '') . ' >> ' . escapeshellarg($logFile) . ' 2>&1 </dev/null &');
@@ -0,0 +1,62 @@
<?php
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/scheduler.php';
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
$id = trim($_GET['id'] ?? '');
if (!$id || !preg_match('/^Custom\/[a-zA-Z0-9_\-]+\.sh$/', $id) || str_contains($id, '..')) {
echo json_encode(['ok' => false, 'error' => 'Invalid id']);
exit;
}
$path = SCRIPTS_DIR . '/' . $id;
echo json_encode(['ok' => true, 'content' => file_exists($path) ? file_get_contents($path) : '']);
exit;
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$action = trim($_POST['action'] ?? 'save');
$name = trim($_POST['name'] ?? '');
$content = $_POST['content'] ?? '';
if (!$name || !preg_match('/^[a-zA-Z0-9_\-]+$/', $name)) {
echo json_encode(['ok' => false, 'error' => 'Name must be letters, numbers, _ or - only']);
exit;
}
$id = 'Custom/' . $name . '.sh';
$path = SCRIPTS_DIR . '/Custom/' . $name . '.sh';
if ($action === 'delete') {
if (!file_exists($path)) {
echo json_encode(['ok' => false, 'error' => 'Script not found']);
exit;
}
unlink($path);
$schedule = vv_schedule_load();
unset($schedule[$id]);
vv_schedule_save($schedule);
vv_cron_rebuild($schedule);
echo json_encode(['ok' => true]);
exit;
}
$dir = SCRIPTS_DIR . '/Custom';
if (!is_dir($dir)) mkdir($dir, 0755, true);
if (file_put_contents($path, $content) === false) {
echo json_encode(['ok' => false, 'error' => 'Failed to write script']);
exit;
}
chmod($path, 0755);
// Ensure schedule.json has an entry so the script appears in the job list
$schedule = vv_schedule_load();
if (!isset($schedule[$id])) {
$schedule[$id] = ['id' => $id, 'enabled' => false, 'cron' => '', 'log_enabled' => false, 'updated' => date('c')];
vv_schedule_save($schedule);
}
echo json_encode(['ok' => true, 'id' => $id]);
exit;
}
echo json_encode(['ok' => false, 'error' => 'Method not allowed']);
@@ -0,0 +1,20 @@
<?php
header('Content-Type: application/json');
$body = json_decode(file_get_contents('php://input'), true) ?? [];
$action = $body['action'] ?? '';
$allowed = ['stop', 'shutdown', 'restart'];
if (!in_array($action, $allowed, true)) {
echo json_encode(['ok' => false, 'error' => 'invalid action']);
exit;
}
$cmd = match($action) {
'stop' => '/usr/local/sbin/mdcmd stop',
'shutdown' => '/sbin/shutdown -h now',
'restart' => '/sbin/shutdown -r now',
};
exec($cmd . ' > /dev/null 2>&1 &');
echo json_encode(['ok' => true]);
@@ -14,10 +14,20 @@
border-radius: 6px; padding: 12px; } border-radius: 6px; padding: 12px; }
.vv-wide { flex: 100%; } .vv-wide { flex: 100%; }
.vv-card h3 { margin: 0 0 10px; font-size: 13px; text-transform: uppercase; .vv-card h3 { margin: 0 0 10px; font-size: 13px; text-transform: uppercase;
color: #888; letter-spacing: 0.05em; } color: #888; letter-spacing: 0.05em; white-space: normal;
overflow: hidden; min-width: 0; }
/* System card — no h3, no top padding waste */
#vv-system { padding-top: 14px; }
/* System action buttons */
.vv-sys-btn { background: #2a2a2a; border: 1px solid #e65100; color: #ff9800; border-radius: 3px;
padding: 3px 0; font-size: 6px; cursor: pointer; line-height: 1;
width: 50px; min-width: 0; text-align: center; }
.vv-sys-btn:hover { background: #3a2000; color: #ffb74d; border-color: #ff9800; }
/* Fallback state badge */ /* 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-normal { color: #4caf50; }
.vv-state-failover { color: #f44336; } .vv-state-failover { color: #f44336; }
.vv-state-no_internet { color: #ff9800; } .vv-state-no_internet { color: #ff9800; }
@@ -108,6 +118,50 @@
.vv-dry-btn { border-color: #ff9800 !important; color: #ff9800 !important; } .vv-dry-btn { border-color: #ff9800 !important; color: #ff9800 !important; }
.vv-dry-btn:hover { background: #e65100 !important; color: #fff !important; .vv-dry-btn:hover { background: #e65100 !important; color: #fff !important;
border-color: #ff9800 !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 */ /* Suggestions panel — accordion */
#vv-suggestions { overflow-y: auto; } #vv-suggestions { overflow-y: auto; }
@@ -144,7 +198,8 @@
.vv-log-pre { background: #0d0d0d; border: 1px solid #333; border-radius: 4px; .vv-log-pre { background: #0d0d0d; border: 1px solid #333; border-radius: 4px;
padding: 10px 12px; margin: 0; font-family: monospace; font-size: 12px; padding: 10px 12px; margin: 0; font-family: monospace; font-size: 12px;
color: #ccc; white-space: pre-wrap; word-break: break-all; 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 */ /* Toggle switch */
.vv-toggle { position: relative; display: inline-block; width: 36px; height: 20px; flex-shrink: 0; } .vv-toggle { position: relative; display: inline-block; width: 36px; height: 20px; flex-shrink: 0; }
@@ -188,6 +243,28 @@
.vv-doc-body pre { background: #111; padding: 12px; border-radius: 4px; overflow-x: auto; } .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; } .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 */ /* Live var substitution colours */
code.vv-live-var { color: #4caf50; background: #0d1f0d; } code.vv-live-var { color: #4caf50; background: #0d1f0d; }
code.vv-unknown-var { color: #ff9800; background: #1f130d; } code.vv-unknown-var { color: #ff9800; background: #1f130d; }
@@ -0,0 +1,209 @@
<?php
// confform.php — script→conf-section mapping, field parsing, and write-back.
// Map: script relative id → subsection names (must match # ━━━ Name ━━━ or # ── Name ── headers).
const VV_SCRIPT_CONF_SECTIONS = [
// Orchestrators
'Orchestrators/array_started.sh' => ['Array Start'],
'Orchestrators/array_stopping.sh' => ['Array Stop'],
'Orchestrators/watchdog_orchestrator.sh' => ['Watchdog Orchestrator', 'System Watchdog'],
'Orchestrators/critical_sync_maintenance.sh' => ['Critical Sync Maintenance', 'Critical Sync Shares'],
'Orchestrators/intermediate_sync_maintenance.sh' => ['Intermediate Sync Maintenance', 'Intermediate Sync Shares'],
'Orchestrators/daily_sync_maintenance.sh' => ['Daily Sync Maintenance', 'Daily Sync Shares'],
'Orchestrators/weekly_sync_maintenance.sh' => ['Weekly Sync Maintenance', 'Weekly Sync Shares'],
'Orchestrators/monthly_maintenance.sh' => ['Monthly Maintenance'],
'Orchestrators/transcode_management.sh' => ['Transcode Manager', 'Transcode Server Array', 'Transcodes'],
// Docker Essentials
'Docker_Essentials/docker_daily_restart.sh' => ['Docker Daily Restart'],
'Docker_Essentials/docker_weekly_restart.sh' => ['Docker Weekly Restart'],
'Docker_Essentials/docker_network_connect.sh' => ['Docker Network Connect'],
'Docker_Essentials/downloaders_reset.sh' => ['Downloaders Reset', 'Downloaders'],
// Watchdogs
'Watchdogs/docker_watchdog.sh' => ['Docker Watchdog'],
'Watchdogs/resource_watchdog.sh' => ['Pressure Levels'],
'Watchdogs/System/network_watchdog.sh' => ['Network Watchdog'],
'Watchdogs/System/webgui_watchdog.sh' => ['WebGUI Watchdog'],
// Media
'Media/media_cleaner.sh' => ['Media Cleaner'],
'Media/media_shares_permissions.sh' => ['Media Permissions'],
'Media/arrs_failed_stalled_recovery.sh' => ['Arr Failed/Stalled Recovery'],
'Media/radarr_cleanup.sh' => ['Arr Cleanup'],
'Media/lidarr_cleanup.sh' => ['Arr Cleanup'],
'Media/sonarr_cleanup.sh' => ['Arr Cleanup'],
// Monitors
'Monitors/cert_monitor.sh' => ['Certificate Monitor'],
'Monitors/backup_verify.sh' => ['Backup Verify'],
'Monitors/smart_health.sh' => ['SMART Health'],
'Monitors/bandwidth_monitor.sh' => ['Bandwidth Monitor'],
'Monitors/emby_session_report.sh' => ['Emby Session Report'],
'Monitors/zfs_memory_snapshot.sh' => ['ZFS Report'],
];
function vv_conf_has_sections(string $id): bool {
return !empty(VV_SCRIPT_CONF_SECTIONS[$id] ?? []);
}
// Parse fields from a named subsection in raw conf content.
// Headers accepted: # ━━━ Name ━━━ OR # ── Name ── (any mix of ━ ─ chars).
// Returns array of field defs, or null if subsection not found.
function vv_conf_parse_subsection(string $raw, string $subName, string $filename): ?array {
$lines = explode("\n", $raw);
$n = count($lines);
$needle = mb_strtolower(trim(preg_replace('/\s+/', ' ', $subName)));
$start = -1;
for ($i = 0; $i < $n; $i++) {
if (!preg_match('/^#\s*[━─]{2,}\s+([A-Za-z].+?)\s+[━─]{2,}/', $lines[$i], $m)) continue;
$t = mb_strtolower(trim(preg_replace('/\s+/', ' ', $m[1])));
if ($t === $needle) { $start = $i + 1; break; }
}
if ($start === -1) return null;
// End at next section/subsection line (3+ consecutive divider chars after #)
$end = $n;
for ($i = $start; $i < $n; $i++) {
if (preg_match('/^#\s*[━─═=]{3,}/', $lines[$i])) { $end = $i; break; }
}
$fields = [];
$pendingDesc = [];
for ($i = $start; $i < $end; $i++) {
$line = rtrim($lines[$i]);
if ($line === '' || $line === '#') { $pendingDesc = []; continue; }
// Pure comment line
if (preg_match('/^#\s*(.*)$/', $line, $cm)) {
$inner = trim($cm[1]);
if ($inner !== '' && !preg_match('/^[━─═=\-\s]+$/', $inner)) {
$pendingDesc[] = $inner;
}
continue;
}
$desc = implode(' ', $pendingDesc);
$pendingDesc = [];
// declare -A KEY=(
if (preg_match('/^(\s*)declare\s+-A\s+([A-Z_][A-Z0-9_]*)\s*=\s*\(/', $line, $m)) {
$indent = $m[1]; $key = $m[2];
$blockLines = [];
$j = $i + 1;
while ($j < $end && !preg_match('/^\s*\)\s*$/', $lines[$j])) {
$blockLines[] = rtrim($lines[$j]);
$j++;
}
$fields[] = ['key' => $key, 'value' => implode("\n", $blockLines),
'type' => 'assoc_array', 'desc' => $desc, 'file' => $filename, 'indent' => $indent];
$i = $j;
continue;
}
// KEY=( (array)
if (preg_match('/^(\s*)([A-Z_][A-Z0-9_]*)\s*=\s*\(/', $line, $m)) {
$indent = $m[1]; $key = $m[2];
// Single-line: KEY=( ... )
if (preg_match('/^[^(]*\(([^)]*)\)/', $line, $sm)) {
$fields[] = ['key' => $key, 'value' => $sm[1],
'type' => 'array_single', 'desc' => $desc, 'file' => $filename, 'indent' => $indent];
continue;
}
// Multi-line
$blockLines = [];
$j = $i + 1;
while ($j < $end && !preg_match('/^\s*\)\s*$/', $lines[$j])) {
$blockLines[] = rtrim($lines[$j]);
$j++;
}
$fields[] = ['key' => $key, 'value' => implode("\n", $blockLines),
'type' => 'array', 'desc' => $desc, 'file' => $filename, 'indent' => $indent];
$i = $j;
continue;
}
// Scalar: KEY="value" or KEY=value
if (preg_match('/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*"([^"]*)"(?:\s+#\s*(.+))?$/', $line, $m)) {
$fields[] = ['key' => $m[1], 'value' => $m[2],
'type' => 'scalar', 'desc' => ($m[3] ?? '') ?: $desc, 'file' => $filename];
continue;
}
if (preg_match('/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*([^(\n#]*?)(?:\s+#\s*(.+))?$/', $line, $m)) {
$val = trim($m[2]);
if ($val === '') continue;
$fields[] = ['key' => $m[1], 'value' => $val,
'type' => 'scalar', 'desc' => ($m[3] ?? '') ?: $desc, 'file' => $filename];
}
}
return $fields ?: null;
}
// Return all conf groups (subsection + fields) for a script on the current host.
function vv_conf_fields_for_script(string $id): array {
$sectionNames = VV_SCRIPT_CONF_SECTIONS[$id] ?? [];
if (!$sectionNames) return [];
$groups = [];
foreach ($sectionNames as $name) {
foreach (vv_get_conf_files() as $filename) {
$fields = vv_conf_parse_subsection(vv_read_conf_raw($filename), $name, $filename);
if ($fields !== null) {
$groups[] = ['subsection' => $name, 'file' => $filename, 'fields' => $fields];
}
}
}
return $groups;
}
// Write a batch of field changes back to their respective conf files.
// Each change: {file, key, value, type}
function vv_conf_write_changes(array $changes): array {
$byFile = [];
foreach ($changes as $c) {
if (!empty($c['file']) && !empty($c['key'])) $byFile[$c['file']][] = $c;
}
$results = [];
foreach ($byFile as $file => $fileChanges) {
$raw = vv_read_conf_raw($file);
if ($raw === '') { $results[$file] = false; continue; }
foreach ($fileChanges as $c) {
$qKey = preg_quote($c['key'], '/');
$value = $c['value'];
$type = $c['type'] ?? 'scalar';
if ($type === 'scalar') {
$raw = preg_replace_callback(
'/^(\s*' . $qKey . '\s*=\s*)("(?:[^"\\\\]|\\\\.)*"|\'(?:[^\'\\\\]|\\\\.)*\'|[^#\n]*?)(\s*(?:#[^\n]*)?)$/m',
fn($m) => $m[1] . '"' . str_replace(['"', '\\'], ['\\"', '\\\\'], $value) . '"' . $m[3],
$raw
) ?? $raw;
} elseif ($type === 'array_single') {
$raw = preg_replace_callback(
'/^(\s*' . $qKey . '\s*=\s*\()([^)]*)(\)(?:\s*(?:#[^\n]*)?)?)$/m',
fn($m) => $m[1] . $value . $m[3],
$raw
) ?? $raw;
} elseif ($type === 'array') {
$raw = preg_replace_callback(
'/^(\s*)(' . $qKey . '\s*=\s*\()[^)]*\)/ms',
fn($m) => $m[1] . $m[2] . "\n" . $value . "\n" . $m[1] . ")",
$raw
) ?? $raw;
} elseif ($type === 'assoc_array') {
$raw = preg_replace_callback(
'/^(\s*)(declare\s+-A\s+' . $qKey . '\s*=\s*\()[^)]*\)/ms',
fn($m) => $m[1] . $m[2] . "\n" . $value . "\n" . $m[1] . ")",
$raw
) ?? $raw;
}
}
$results[$file] = vv_write_conf_raw($file, $raw);
}
return $results;
}
@@ -0,0 +1,197 @@
<?php
// Media server session helpers — reads HOST*_EMBY_* / JELLYFIN_* / PLEX_* from host conf.
require_once __DIR__ . '/config.php';
// ── Conf reader ───────────────────────────────────────────────────────────────
function vv_media_conf_scalar(string $raw, string $key): string {
return preg_match('/^\s*' . preg_quote($key, '/') . '\s*=\s*"?([^"\n]*)"?/m', $raw, $m)
? trim($m[1]) : '';
}
// ── Server list from host conf ────────────────────────────────────────────────
function vv_discover_media_servers(): array {
$host = vv_detect_host(); // 'host1', 'host2', 'unknown'
// For unknown (dev), try both host confs; otherwise read only the current host's file.
$hostSlots = $host !== 'unknown' ? [$host] : ['host1', 'host2'];
$servers = [];
foreach ($hostSlots as $h) {
$raw = vv_read_conf_raw($h . '.conf');
$prefix = strtoupper($h) . '_'; // HOST1_ or HOST2_
$get = fn(string $k) => vv_media_conf_scalar($raw, $prefix . $k);
// ── Emby ──────────────────────────────────────────────────────────────
$embyUrl = $get('EMBY_URL');
$embyKey = $get('EMBY_API_KEY');
if ($embyUrl && $embyKey && !str_contains($embyKey, 'your-')) {
$servers[] = [
'type' => 'emby',
'name' => $get('EMBY_CONTAINER') ?: 'Emby',
'url' => $embyUrl,
'key' => $embyKey,
];
}
// ── Jellyfin ──────────────────────────────────────────────────────────
$jfUrl = $get('JELLYFIN_URL');
$jfKey = $get('JELLYFIN_API_KEY');
if ($jfUrl && $jfKey && !str_contains($jfKey, 'your-')) {
$servers[] = [
'type' => 'jellyfin',
'name' => $get('JELLYFIN_CONTAINER') ?: 'Jellyfin',
'url' => $jfUrl,
'key' => $jfKey,
];
}
// ── Plex (optional) ───────────────────────────────────────────────────
$plexToken = $get('PLEX_TOKEN');
$plexUrl = $get('PLEX_URL') ?: 'http://localhost:32400';
if ($plexToken && !str_contains($plexToken, 'your-')) {
$servers[] = [
'type' => 'plex',
'name' => 'Plex',
'url' => $plexUrl,
'token' => $plexToken,
];
}
}
// Deduplicate — same server can appear from multiple conf sources (unknown host, shared keys)
$seen = [];
$unique = [];
foreach ($servers as $s) {
$k = $s['type'] . '|' . ($s['url'] ?? $s['token'] ?? '');
if (!isset($seen[$k])) { $seen[$k] = true; $unique[] = $s; }
}
return $unique;
}
// ── Session fetchers ──────────────────────────────────────────────────────────
function vv_fetch_jf_sessions(array $srv): array {
$url = rtrim($srv['url'], '/') . '/Sessions?api_key=' . urlencode($srv['key']) . '&activeWithinSeconds=60';
$ctx = stream_context_create(['http' => ['timeout' => 3]]);
$raw = @file_get_contents($url, false, $ctx);
if (!$raw) return [];
$sessions = json_decode($raw, true);
if (!is_array($sessions)) return [];
$result = [];
foreach ($sessions as $s) {
if (empty($s['NowPlayingItem'])) continue;
$item = $s['NowPlayingItem'];
$ps = $s['PlayState'] ?? [];
$tc = $s['TranscodingInfo'] ?? null;
$type = $item['Type'] ?? '';
$title = $item['Name'] ?? 'Unknown';
if ($type === 'Episode' && !empty($item['SeriesName'])) {
$ep = sprintf('S%02dE%02d', $item['ParentIndexNumber'] ?? 0, $item['IndexNumber'] ?? 0);
$title = $item['SeriesName'] . ' ' . $ep;
}
$pos = (int)($ps['PositionTicks'] ?? 0);
$dur = (int)($item['RunTimeTicks'] ?? 0);
$pct = $dur > 0 ? min(100, (int)round($pos / $dur * 100)) : 0;
if ($tc) {
$vc = strtoupper($tc['VideoCodec'] ?? '');
$hw = !empty($tc['IsHardwareAcceleratedVideoDecoding']) ? ' HW' : '';
$method = 'Transcode' . ($vc ? " ($vc$hw)" : '');
} else {
$pm = $ps['PlayMethod'] ?? '';
$method = $pm === 'DirectStream' ? 'Direct Stream' : 'Direct Play';
}
$result[] = [
'server' => $srv['name'],
'server_type' => $srv['type'],
'user' => $s['UserName'] ?? '?',
'title' => $title,
'type' => $type,
'client' => trim(($s['Client'] ?? '') . ' / ' . ($s['DeviceName'] ?? ''), ' /'),
'method' => $method,
'paused' => !empty($ps['IsPaused']),
'pct' => $pct,
'pos_sec' => (int)($pos / 10000000),
'dur_sec' => (int)($dur / 10000000),
'is_tc' => $tc !== null,
];
}
return $result;
}
function vv_fetch_plex_sessions(array $srv): array {
$url = rtrim($srv['url'], '/') . '/status/sessions?X-Plex-Token=' . urlencode($srv['token']);
$ctx = stream_context_create(['http' => ['timeout' => 3, 'header' => "Accept: application/json\r\n"]]);
$raw = @file_get_contents($url, false, $ctx);
if (!$raw) return [];
$data = json_decode($raw, true);
$items = $data['MediaContainer']['Metadata'] ?? [];
if (!is_array($items)) return [];
$result = [];
foreach ($items as $m) {
$type = strtolower($m['type'] ?? '');
$title = $m['title'] ?? 'Unknown';
if ($type === 'episode') {
$title = ($m['grandparentTitle'] ?? '') . ' S' . str_pad($m['parentIndex'] ?? 0, 2, '0', STR_PAD_LEFT)
. 'E' . str_pad($m['index'] ?? 0, 2, '0', STR_PAD_LEFT);
}
$dur = (int)($m['duration'] ?? 0);
$offset = (int)($m['viewOffset'] ?? 0);
$pct = $dur > 0 ? min(100, (int)round($offset / $dur * 100)) : 0;
$tcInfo = $m['TranscodeSession'] ?? null;
$isTc = $tcInfo !== null;
if ($isTc) {
$vc = strtoupper($tcInfo['videoCodec'] ?? '');
$hw = !empty($tcInfo['transcodeHwEncoding']) ? ' HW' : '';
$method = 'Transcode' . ($vc ? " ($vc$hw)" : '');
} else {
$method = 'Direct Play';
}
$player = $m['Player'] ?? [];
$result[] = [
'server' => $srv['name'],
'server_type' => $srv['type'],
'user' => ($m['User']['title'] ?? '?'),
'title' => $title,
'type' => ucfirst($type),
'client' => trim(($player['product'] ?? '') . ' / ' . ($player['title'] ?? ''), ' /'),
'method' => $method,
'paused' => ($player['state'] ?? '') === 'paused',
'pct' => $pct,
'pos_sec' => (int)($offset / 1000),
'dur_sec' => (int)($dur / 1000),
'is_tc' => $isTc,
];
}
return $result;
}
// ── Public entry point ────────────────────────────────────────────────────────
function vv_media_sessions(): array {
$servers = vv_discover_media_servers();
$sessions = [];
foreach ($servers as $srv) {
$found = $srv['type'] === 'plex'
? vv_fetch_plex_sessions($srv)
: vv_fetch_jf_sessions($srv);
foreach ($found as $s) $sessions[] = $s;
}
return [
'sessions' => $sessions,
'server_names' => array_column($servers, 'name'),
'server_count' => count($servers),
];
}
@@ -1,6 +1,49 @@
<?php <?php
require_once __DIR__ . '/config.php';
// Monitor helpers — docker, GPU, resources, transcode sessions, fallback state. // Monitor helpers — docker, GPU, resources, transcode sessions, fallback state.
function vv_system_info(): array {
// Identity from ident.cfg
$ident = @parse_ini_file('/boot/config/ident.cfg') ?: [];
// Registration from var.ini
$var = [];
foreach (@file('/var/local/emhttp/var.ini') ?: [] as $line) {
if (preg_match('/^(\w+)="([^"]*)"/', $line, $m)) $var[$m[1]] = $m[2];
}
// CPU model
$cpu = '';
foreach (@file('/proc/cpuinfo') ?: [] as $line) {
if (preg_match('/^model name\s*:\s*(.+)/', $line, $m)) { $cpu = trim($m[1]); break; }
}
// Uptime
$uptimeSec = (int)explode(' ', @file_get_contents('/proc/uptime') ?: '0')[0];
$days = intdiv($uptimeSec, 86400);
$hours = intdiv($uptimeSec % 86400, 3600);
$mins = intdiv($uptimeSec % 3600, 60);
$uptime = ($days > 0 ? "{$days}d " : '')
. ($hours > 0 ? "{$hours}h " : '')
. "{$mins}m";
// Array state
$arrayState = $var['mdState'] ?? 'UNKNOWN';
return [
'name' => $ident['NAME'] ?? gethostname(),
'comment' => $ident['COMMENT'] ?? '',
'timezone' => $ident['timeZone'] ?? 'UTC',
'cpu_model' => $ident['SYS_MODEL'] ?? $cpu,
'reg_type' => 'Unraid OS ' . ($var['regTy'] ?? ''),
'reg_to' => $var['regTo'] ?? '',
'uptime' => $uptime,
'array_state' => $arrayState,
'version' => trim(@file_get_contents('/etc/unraid-version') ?: ''),
];
}
function vv_docker_containers(): array { function vv_docker_containers(): array {
$out = shell_exec('docker ps --format \'{"name":"{{.Names}}","status":"{{.Status}}","image":"{{.Image}}"}\' 2>/dev/null'); $out = shell_exec('docker ps --format \'{"name":"{{.Names}}","status":"{{.Status}}","image":"{{.Image}}"}\' 2>/dev/null');
$containers = []; $containers = [];
@@ -24,10 +67,11 @@ function vv_docker_stopped(): array {
} }
function vv_gpu_stats(): array { function vv_gpu_stats(): array {
$out = shell_exec('nvidia-smi --query-gpu=name,memory.used,memory.total,utilization.gpu,temperature.gpu --format=csv,noheader,nounits 2>/dev/null'); $out = shell_exec('nvidia-smi --query-gpu=name,memory.used,memory.total,utilization.gpu,temperature.gpu,power.draw,utilization.encoder,utilization.decoder --format=csv,noheader,nounits 2>/dev/null');
if (!$out) return ['available' => false]; if (!$out) return ['available' => false];
$parts = array_map('trim', explode(',', $out)); $parts = array_map('trim', explode(',', $out));
$power = is_numeric($parts[5] ?? '') ? round((float)$parts[5], 1) : null;
return [ return [
'available' => true, 'available' => true,
'name' => $parts[0] ?? '', 'name' => $parts[0] ?? '',
@@ -35,6 +79,9 @@ function vv_gpu_stats(): array {
'memory_total' => (int)($parts[2] ?? 0), 'memory_total' => (int)($parts[2] ?? 0),
'utilization' => (int)($parts[3] ?? 0), 'utilization' => (int)($parts[3] ?? 0),
'temperature' => (int)($parts[4] ?? 0), 'temperature' => (int)($parts[4] ?? 0),
'power_w' => $power,
'enc_pct' => (int)($parts[6] ?? 0),
'dec_pct' => (int)($parts[7] ?? 0),
]; ];
} }
@@ -54,26 +101,114 @@ function vv_gpu_processes(): array {
} }
function vv_system_resources(): array { function vv_system_resources(): array {
// RAM
$mem = []; $mem = [];
foreach (file('/proc/meminfo') ?: [] as $line) { foreach (file('/proc/meminfo') ?: [] as $line) {
if (preg_match('/^(MemTotal|MemAvailable):\s+(\d+)/', $line, $m)) if (preg_match('/^(MemTotal|MemAvailable):\s+(\d+)/', $line, $m))
$mem[$m[1]] = (int)$m[2]; $mem[$m[1]] = (int)$m[2];
} }
return [
'ram_total_mb' => (int)(($mem['MemTotal'] ?? 0) / 1024),
'ram_free_mb' => (int)(($mem['MemAvailable'] ?? 0) / 1024),
'cache' => vv_df('/mnt/cache'),
];
}
// CPU (1-second sample) function vv_cpu_per_core(): array {
$cpu = (int)trim(shell_exec("top -bn1 | grep 'Cpu(s)' | awk '{print $2}' | cut -d. -f1") ?: '0'); // Parse /proc/stat — [user, nice, system, idle, iowait, irq, softirq]
$raw = [];
foreach (file('/proc/stat') ?: [] as $line) {
if (!preg_match('/^(cpu\d*)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)/', $line, $m)) continue;
$raw[$m[1]] = [(int)$m[2],(int)$m[3],(int)$m[4],(int)$m[5],(int)$m[6],(int)$m[7],(int)$m[8]];
}
// Disk — ramdisk + cache $stateFile = '/tmp/vv_cpu_stat.json';
$ramdisk = vv_df('/mnt/ramdisk_transcodes'); $prev = file_exists($stateFile) ? (json_decode(file_get_contents($stateFile), true) ?: []) : [];
$cache = vv_df('/mnt/cache'); file_put_contents($stateFile, json_encode($raw));
$usage = function(array $c, ?array $p): int {
if (!$p) return 0;
$dt = array_sum($c) - array_sum($p);
$di = ($c[3] + $c[4]) - ($p[3] + $p[4]);
return $dt > 0 ? max(0, min(100, (int)round((1 - $di / $dt) * 100))) : 0;
};
$overall = $usage($raw['cpu'] ?? [], $prev['cpu'] ?? null);
$cores = [];
foreach ($raw as $cpu => $c) {
if ($cpu === 'cpu') continue;
$num = (int)substr($cpu, 3);
$freqKhz = (int)@file_get_contents("/sys/devices/system/cpu/$cpu/cpufreq/scaling_cur_freq");
$maxKhz = (int)@file_get_contents("/sys/devices/system/cpu/$cpu/cpufreq/cpuinfo_max_freq");
$minKhz = (int)@file_get_contents("/sys/devices/system/cpu/$cpu/cpufreq/cpuinfo_min_freq");
$cores[] = [
'core' => $num,
'usage_pct' => $usage($c, $prev[$cpu] ?? null),
'freq_mhz' => $freqKhz > 0 ? (int)round($freqKhz / 1000) : 0,
'max_mhz' => $maxKhz > 0 ? (int)round($maxKhz / 1000) : 0,
'min_mhz' => $minKhz > 0 ? (int)round($minKhz / 1000) : 0,
];
}
usort($cores, fn($a, $b) => $a['core'] - $b['core']);
return ['overall' => $overall, 'cores' => $cores];
}
function vv_memory_breakdown(): array {
$mem = [];
foreach (file('/proc/meminfo') ?: [] as $line) {
if (preg_match('/^(\w+):\s+(\d+)/', $line, $m)) $mem[$m[1]] = (int)$m[2];
}
$totalKb = $mem['MemTotal'] ?? 0;
// ZFS ARC
$arcKb = 0;
foreach (@file('/proc/spl/kstat/zfs/arcstats') ?: [] as $line) {
if (preg_match('/^size\s+\d+\s+(\d+)/', $line, $m)) { $arcKb = (int)($m[1] / 1024); break; }
}
// Docker — sum docker stats used memory per container (matches Unraid dashboard)
$dockerKb = 0;
$dsOut = shell_exec("docker stats --no-stream --format '{{.MemUsage}}' 2>/dev/null") ?: '';
foreach (explode("\n", trim($dsOut)) as $line) {
if (!preg_match('/^([0-9.]+)(GiB|MiB|KiB|B)\s*\//', trim($line), $m)) continue;
$val = (float)$m[1];
$dockerKb += match($m[2]) {
'GiB' => (int)($val * 1048576),
'MiB' => (int)($val * 1024),
'KiB' => (int)$val,
default => (int)($val / 1024),
};
}
// VM (QEMU/KVM RSS)
$vmKb = 0;
foreach (preg_split('/\s+/', trim(shell_exec('ps -C qemu-system-x86_64 -o rss= 2>/dev/null') ?: '')) as $rss) {
if (is_numeric($rss) && $rss > 0) $vmKb += (int)$rss;
}
$freeKb = max(0, $mem['MemAvailable'] ?? 0);
$systemKb = max(0, $totalKb - $freeKb - $arcKb - $dockerKb - $vmKb);
// Top processes by RSS — group same-named procs, take top 5
$grouped = [];
$psOut = shell_exec("ps -eo comm,rss --sort=-rss 2>/dev/null | tail -n +2 | head -40") ?: '';
foreach (explode("\n", trim($psOut)) as $line) {
$parts = preg_split('/\s+/', trim($line), 2);
if (count($parts) === 2 && is_numeric($parts[1]) && (int)$parts[1] > 0)
$grouped[$parts[0]] = ($grouped[$parts[0]] ?? 0) + (int)$parts[1];
}
arsort($grouped);
$topProcs = [];
foreach (array_slice($grouped, 0, 3, true) as $name => $kb)
$topProcs[] = ['name' => $name, 'kb' => $kb];
return [ return [
'ram_total_mb' => (int)(($mem['MemTotal'] ?? 0) / 1024), 'total_kb' => $totalKb,
'ram_free_mb' => (int)(($mem['MemAvailable'] ?? 0) / 1024), 'system_kb' => $systemKb,
'cpu_percent' => $cpu, 'vm_kb' => $vmKb,
'ramdisk' => $ramdisk, 'zfs_kb' => $arcKb,
'cache' => $cache, 'docker_kb' => $dockerKb,
'free_kb' => $freeKb,
'top_procs' => $topProcs,
]; ];
} }
@@ -90,6 +225,112 @@ function vv_df(string $path): array {
]; ];
} }
function vv_network_stats(): array {
$iface = trim(shell_exec("ip route show default 2>/dev/null | awk 'NR==1{print \$5}'") ?: '');
if (!$iface) {
$best = ''; $bestBytes = 0;
foreach (file('/proc/net/dev') ?: [] as $line) {
if (!preg_match('/^\s*(\w+):\s+(\d+)/', $line, $m) || $m[1] === 'lo') continue;
if ((int)$m[2] > $bestBytes) { $bestBytes = (int)$m[2]; $best = $m[1]; }
}
$iface = $best;
}
if (!$iface) return ['available' => false];
$rxBytes = $txBytes = 0;
foreach (file('/proc/net/dev') ?: [] as $line) {
if (!preg_match('/^\s*' . preg_quote($iface, '/') . ':\s+(.+)$/', $line, $m)) continue;
$parts = preg_split('/\s+/', trim($m[1]));
$rxBytes = (int)($parts[0] ?? 0);
$txBytes = (int)($parts[8] ?? 0);
break;
}
$stateFile = '/tmp/vv_net_stat.json';
$now = ['rx' => $rxBytes, 'tx' => $txBytes, 'ts' => microtime(true)];
$prev = file_exists($stateFile) ? (json_decode(file_get_contents($stateFile), true) ?: []) : [];
file_put_contents($stateFile, json_encode($now));
$rxRate = $txRate = 0;
if (!empty($prev['ts']) && ($dt = $now['ts'] - $prev['ts']) > 0.1) {
$rxRate = max(0, (int)(($rxBytes - ($prev['rx'] ?? $rxBytes)) / $dt));
$txRate = max(0, (int)(($txBytes - ($prev['tx'] ?? $txBytes)) / $dt));
}
$speedMbps = (int)@file_get_contents("/sys/class/net/$iface/speed");
// Local IP — use primary iface
$localIp = trim(shell_exec(
"ip -4 addr show " . escapeshellarg($iface) . " 2>/dev/null | awk '/inet /{print \$2}' | cut -d/ -f1 | head -1"
) ?: '');
// External IP — curl ifconfig.me, cached 5 min so we don't hammer it
$extIpCache = '/tmp/vv_ext_ip.cache';
$extIp = '';
if (file_exists($extIpCache) && (time() - filemtime($extIpCache)) < 300) {
$extIp = trim(file_get_contents($extIpCache) ?: '');
} else {
$fetched = trim(shell_exec('curl -sf --max-time 4 https://ifconfig.me 2>/dev/null') ?: '');
if (preg_match('/^\d+\.\d+\.\d+\.\d+$/', $fetched)) {
$extIp = $fetched;
file_put_contents($extIpCache, $extIp);
}
}
// Tailscale IP — use `tailscale ip` CLI (interface name varies: tailscale0, tailscale1, etc.)
$tsIp = trim(shell_exec('tailscale ip -4 2>/dev/null | head -1') ?: '');
return [
'available' => true,
'iface' => $iface,
'speed_mbps' => $speedMbps > 0 ? $speedMbps : null,
'rx_bps' => $rxRate,
'tx_bps' => $txRate,
'local_ip' => $localIp,
'ext_ip' => $extIp,
'ts_ip' => $tsIp,
];
}
function vv_partner_state(): array {
$vars = vv_conf_vars();
$myName = trim(shell_exec('hostname -s') ?: '');
// Parse Tailscale peer status once
$tsData = json_decode(shell_exec('tailscale status --json 2>/dev/null') ?: '{}', true) ?? [];
$tsPeers = [];
foreach ($tsData['Peer'] ?? [] as $peer) {
// DNSName is "hostname.tailnet.ts.net." — take the first label (full, not truncated)
$dns = $peer['DNSName'] ?? '';
$h = $dns ? strtolower(explode('.', $dns)[0]) : strtolower($peer['HostName'] ?? '');
if ($h) $tsPeers[$h] = (bool)($peer['Online'] ?? false);
}
$hosts = [];
foreach (['HOST1', 'HOST2', 'HOST3', 'HOST4'] as $id) {
$hostname = $vars[$id] ?? '';
if (!$hostname) continue;
$isMe = strcasecmp($hostname, $myName) === 0;
$isOwner = strcasecmp($id, $vars['PARTNERSHIP_OWNER_HOST'] ?? '') === 0;
$online = $isMe ? true : ($tsPeers[strtolower($hostname)] ?? null);
$hosts[] = [
'id' => $id,
'hostname' => $hostname,
'owner' => $vars[$id . '_OWNER'] ?? '',
'is_me' => $isMe,
'is_owner' => $isOwner,
'online' => $online,
];
}
return [
'enabled' => ($vars['PARTNERSHIP_ENABLED'] ?? 'false') === 'true',
'owner_host' => $vars['PARTNERSHIP_OWNER_HOST'] ?? '',
'sync_min' => (int)($vars['PARTNERSHIP_SYNC_INTERVAL'] ?? 15),
'hosts' => $hosts,
];
}
function vv_fallback_state(): array { function vv_fallback_state(): array {
// State file written by fallback.sh // State file written by fallback.sh
$stateFile = '/tmp/fallback_state.db'; $stateFile = '/tmp/fallback_state.db';
@@ -108,14 +349,345 @@ function vv_fallback_state(): array {
]; ];
} }
function vv_parse_bash_array(string $raw, string $varName): array {
if (!preg_match('/^\s*' . preg_quote($varName, '/') . '\s*=\s*\(([^)]*)\)/ms', $raw, $m)) return [];
$items = [];
foreach (explode("\n", $m[1]) as $line) {
$line = trim(preg_replace('/#.*$/', '', $line), " \t\"'");
if ($line !== '') $items[] = $line;
}
return $items;
}
function vv_fallback_active(): array {
$vars = vv_conf_vars();
$myName = trim(shell_exec('hostname -s') ?: '');
// Identify which HOST id we are
$myId = null;
foreach (['HOST1', 'HOST2', 'HOST3', 'HOST4'] as $id) {
if (strcasecmp($vars[$id] ?? '', $myName) === 0) { $myId = $id; break; }
}
if (!$myId) return [];
// Running containers: name → image
$running = [];
$psOut = shell_exec("docker ps --format '{\"n\":\"{{.Names}}\",\"i\":\"{{.Image}}\"}' 2>/dev/null") ?: '';
foreach (explode("\n", trim($psOut)) as $line) {
$c = json_decode($line, true);
if ($c) $running[strtolower($c['n'])] = $c['i'];
}
// Parse FALLBACK arrays from this host's conf
$confFile = strtolower($myId) . '.conf';
$rawConf = vv_read_conf_raw($confFile);
$result = [];
foreach (['HOST1', 'HOST2', 'HOST3', 'HOST4'] as $covered) {
if ($covered === $myId) continue;
$coveredHostname = $vars[$covered] ?? '';
if (!$coveredHostname) continue;
$names = [];
for ($tier = 1; $tier <= 4; $tier++)
$names = array_merge($names, vv_parse_bash_array($rawConf, "FALLBACK_{$myId}_COVERS_{$covered}_TIER{$tier}"));
$active = [];
foreach ($names as $name) {
if (isset($running[strtolower($name)]))
$active[] = ['name' => $name, 'image' => $running[strtolower($name)]];
}
if ($active) $result[] = ['host_id' => $covered, 'hostname' => $coveredHostname, 'containers' => $active];
}
return $result;
}
function vv_transcode_sessions(): array { function vv_transcode_sessions(): array {
// Read from transcode state file written by transcode_manager.sh
$stateFile = '/tmp/transcode_state.db'; $stateFile = '/tmp/transcode_state.db';
if (!file_exists($stateFile)) return []; if (!file_exists($stateFile)) return ['available' => false];
$raw = []; $raw = [];
foreach (file($stateFile) ?: [] as $line) { foreach (file($stateFile) ?: [] as $line) {
[$k, $v] = array_pad(explode('=', trim($line), 2), 2, ''); [$k, $v] = array_pad(explode('=', trim($line), 2), 2, '');
$raw[trim($k)] = trim($v); $raw[trim($k)] = trim($v);
} }
return $raw;
$target = $raw['current_target'] ?? '';
$lastFlip = (int)($raw['last_flip_time'] ?? 0);
$flipCount = (int)($raw['flip_count_hour'] ?? 0);
$isRamdisk = str_contains($target, 'ramdisk');
// Count active session dirs in both known locations
$ramdiskPath = '/mnt/ramdisk_transcodes/transcoding-temp';
$ramSessions = count(glob("$ramdiskPath/*/", GLOB_ONLYDIR) ?: []);
// SSD path: first transcoding-temp mount that is not a RAM filesystem (tmpfs/ramfs)
$ssdPath = '';
$ssdSessions = 0;
foreach (glob('/mnt/*/transcoding-temp/', GLOB_ONLYDIR) ?: [] as $p) {
$parts = explode('/', rtrim($p, '/'));
array_pop($parts);
$mount = implode('/', $parts) ?: '/';
$fsType = trim(shell_exec('findmnt -n -o FSTYPE ' . escapeshellarg($mount) . ' 2>/dev/null') ?: '');
if ($fsType === 'tmpfs' || $fsType === 'ramfs') continue;
$ssdPath = $p;
break;
}
$ssd = ['available' => false];
if ($ssdPath) {
$ssdSessions = count(glob($ssdPath . '/*/', GLOB_ONLYDIR) ?: []);
$parts = explode('/', rtrim($ssdPath, '/'));
array_pop($parts);
$ssdMount = implode('/', $parts) ?: '/';
$ssd = vv_df($ssdMount);
}
// Ramdisk disk usage
$rd = vv_df('/mnt/ramdisk_transcodes');
return [
'available' => true,
'current_target' => $target,
'is_ramdisk' => $isRamdisk,
'flip_count_hour' => $flipCount,
'last_flip_time' => $lastFlip,
'last_flip_ago' => $lastFlip > 0 ? time() - $lastFlip : null,
'ram_sessions' => $ramSessions,
'ssd_sessions' => $ssdSessions,
'ramdisk' => $rd,
'ssd' => $ssd,
];
}
function vv_disk_entry(array $d, string $key, string $role = 'data'): ?array {
$name = $d['name'] ?? $key;
$isParity = $role === 'parity';
$mounted = ($d['fsStatus'] ?? '') === 'Mounted';
// Parity has no filesystem — use raw size only
$size_kb = (int)($isParity ? ($d['size'] ?? 0) : ($mounted ? ($d['fsSize'] ?? 0) : ($d['size'] ?? 0)));
$used_kb = (int)($isParity ? 0 : ($mounted ? ($d['fsUsed'] ?? 0) : 0));
if ($size_kb <= 0) return null;
$tempRaw = trim($d['temp'] ?? '');
return [
'name' => $name,
'role' => $role,
'size_gb' => round($size_kb / 1048576, 1),
'used_gb' => round($used_kb / 1048576, 1),
'pct' => (!$isParity && $size_kb > 0) ? round($used_kb / $size_kb * 100, 1) : null,
'temp' => is_numeric($tempRaw) ? (int)$tempRaw : null,
'transport' => $d['transport'] ?? 'ata',
'mounted' => $mounted,
'status' => $d['status'] ?? '',
];
}
function vv_ups_stats(): array {
$raw = shell_exec('apcaccess 2>/dev/null') ?: '';
if (!$raw) return ['available' => false];
$fields = [];
foreach (explode("\n", $raw) as $line) {
if (preg_match('/^(\w+)\s*:\s*(.+)$/', trim($line), $m)) {
$fields[trim($m[1])] = trim($m[2]);
}
}
if (empty($fields)) return ['available' => false];
$parse_num = fn(string $k) => isset($fields[$k]) ? (float)$fields[$k] : null;
$loadPct = $parse_num('LOADPCT');
$nomPower = $parse_num('NOMPOWER');
$watts = ($loadPct !== null && $nomPower !== null) ? round($loadPct / 100 * $nomPower) : null;
return [
'available' => true,
'model' => $fields['MODEL'] ?? '',
'status' => trim(explode(' ', $fields['STATUS'] ?? 'UNKNOWN')[0]),
'line_v' => $parse_num('LINEV'),
'output_v' => $parse_num('OUTPUTV'),
'load_pct' => $loadPct,
'nom_power' => $nomPower,
'watts' => $watts,
'bcharge' => $parse_num('BCHARGE'),
'timeleft' => $parse_num('TIMELEFT'),
'num_xfers' => (int)($fields['NUMXFERS'] ?? 0),
'on_batt_s' => $parse_num('CUMONBATT'),
'selftest' => $fields['SELFTEST'] ?? '',
];
}
function vv_parity_status(): array {
$var = [];
foreach (@file('/var/local/emhttp/var.ini') ?: [] as $line) {
if (preg_match('/^(\w+)="([^"]*)"/', $line, $m)) $var[$m[1]] = $m[2];
}
$isValid = ($var['mdNumInvalid'] ?? '0') === '0';
$exitCode = (int)($var['sbSyncExit'] ?? 0);
$errors = (int)($var['sbSyncErrs'] ?? 0);
$inProgress = ($var['mdResync'] ?? '0') !== '0';
$resyncPos = (int)($var['mdResyncPos'] ?? 0);
$resyncSize = (int)($var['mdResyncSize'] ?? 1);
$resyncPct = $resyncSize > 0 ? round($resyncPos / $resyncSize * 100, 1) : 0;
// Last check from log
$lastDate = null; $lastDuration = 0; $lastSpeed = 0; $lastErrors = 0; $lastExit = 0;
$logFile = '/boot/config/parity-checks.log';
if (file_exists($logFile)) {
$lines = file($logFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
if ($lines) {
$p = explode('|', trim(end($lines)));
$lastDate = trim($p[0] ?? '');
$lastDuration = (int)($p[1] ?? 0);
$lastSpeed = (int)($p[2] ?? 0);
$lastExit = (int)($p[3] ?? 0);
$lastErrors = (int)($p[4] ?? 0);
}
}
// Parse last date string to timestamp
$lastTs = $lastDate ? strtotime($lastDate) : null;
// Next scheduled check from cron
$nextTs = null;
$cronFile = '/boot/config/plugins/dynamix/parity-check.cron';
foreach (@file($cronFile) ?: [] as $line) {
$line = trim($line);
if ($line === '' || $line[0] === '#') continue;
if (!str_contains($line, 'mdcmd')) continue;
$p = preg_split('/\s+/', $line);
// cron: min hour dom month dow command...
if (count($p) >= 5 && is_numeric($p[0]) && is_numeric($p[1]) && is_numeric($p[2])) {
$next = new DateTime('now');
$next->setTime((int)$p[1], (int)$p[0], 0);
$next->setDate((int)$next->format('Y'), (int)$next->format('n'), (int)$p[2]);
if ($next->getTimestamp() <= time()) $next->modify('+1 month');
$nextTs = $next->getTimestamp();
}
break;
}
$exitMap = ['0' => 'Completed', '-4' => 'Aborted', '-5' => 'Cancelled'];
return [
'valid' => $isValid,
'in_progress' => $inProgress,
'resync_pct' => $resyncPct,
'exit_code' => $exitCode,
'exit_label' => $exitMap[(string)$lastExit] ?? 'Unknown',
'errors' => $lastErrors,
'last_date' => $lastDate,
'last_ts' => $lastTs,
'last_duration' => $lastDuration,
'last_speed_mb' => $lastSpeed > 0 ? round($lastSpeed / 1048576, 1) : null,
'next_ts' => $nextTs,
];
}
function vv_storage_pools(): array {
$ini = @parse_ini_file('/var/local/emhttp/disks.ini', true) ?: [];
$out = [];
foreach ($ini as $key => $d) {
if (($d['type'] ?? '') !== 'Cache') continue;
if (($d['fsStatus'] ?? '') !== 'Mounted') continue;
$entry = vv_disk_entry($d, $key);
if ($entry) $out[] = $entry;
}
usort($out, fn($a, $b) => strcmp($a['name'], $b['name']));
return $out;
}
function vv_array_disks(): array {
$ini = @parse_ini_file('/var/local/emhttp/disks.ini', true) ?: [];
$parity = [];
$data = [];
foreach ($ini as $key => $d) {
$type = $d['type'] ?? '';
if ($type === 'Parity') {
$entry = vv_disk_entry($d, $key, 'parity');
if ($entry) $parity[] = $entry;
} elseif ($type === 'Data') {
$entry = vv_disk_entry($d, $key, 'data');
if ($entry) $data[] = $entry;
}
}
usort($parity, fn($a, $b) => strnatcmp($a['name'], $b['name']));
usort($data, fn($a, $b) => strnatcmp($a['name'], $b['name']));
return array_merge($parity, $data);
}
function vv_disk_thresholds(): array {
$cfg = @file_get_contents('/boot/config/plugins/dynamix/dynamix.cfg') ?: '';
$get = function(string $key) use ($cfg): ?int {
return preg_match('/^\s*' . preg_quote($key, '/') . '\s*=\s*"?(\d+)"?/m', $cfg, $m)
? (int)$m[1] : null;
};
return [
'util_warn' => $get('warning') ?? 70,
'util_crit' => $get('critical') ?? 90,
'hdd_warn' => $get('hot') ?? 45,
'hdd_crit' => $get('max') ?? 55,
'ssd_warn' => $get('hotssd') ?? 60,
'ssd_crit' => $get('maxssd') ?? 70,
];
}
function vv_log_tail(string $path, int $lines): string {
$fp = @fopen($path, 'r');
if (!$fp) return '';
fseek($fp, 0, SEEK_END);
$size = ftell($fp);
if ($size <= 0) { fclose($fp); return ''; }
$chunk = min($size, 4096);
fseek($fp, -$chunk, SEEK_END);
$data = fread($fp, $chunk);
fclose($fp);
$all = explode("\n", $data ?: '');
return implode("\n", array_slice($all, -$lines));
}
function vv_scripts_status(): array {
$tmpBase = '/tmp/user.scripts/tmpScripts';
$runDir = '/tmp/user.scripts/running';
$running = [];
foreach (glob($runDir . '/*') ?: [] as $f) {
$running[basename($f)] = true;
}
$scripts = [];
foreach (glob($tmpBase . '/*/') ?: [] as $dir) {
$name = basename(rtrim($dir, '/'));
$logPath = $dir . 'log.txt';
$ts = @filemtime($logPath);
if (!$ts) continue;
$isRunning = isset($running[$name]);
if ($isRunning) {
$status = 'running';
} else {
$tail = vv_log_tail($logPath, 15);
$finished = stripos($tail, 'Script Finished') !== false;
if ($finished) {
$hasWarn = (bool)preg_match('/permission denied|command not found|no such file|: error[\s:]/i', $tail);
$status = $hasWarn ? 'warn' : 'ok';
} else {
$status = 'unknown';
}
}
$scripts[] = ['name' => $name, 'last_ts' => $ts, 'status' => $status, 'running' => $isRunning];
}
usort($scripts, fn($a, $b) => ($b['last_ts'] ?? 0) <=> ($a['last_ts'] ?? 0));
$scripts = array_slice($scripts, 0, 12);
return [
'scripts' => $scripts,
'running_count' => count(array_filter($scripts, fn($s) => $s['status'] === 'running')),
'ok_count' => count(array_filter($scripts, fn($s) => $s['status'] === 'ok')),
'warn_count' => count(array_filter($scripts, fn($s) => $s['status'] === 'warn')),
];
} }
@@ -3,6 +3,7 @@
// schedule.json is per-host, never synced. // schedule.json is per-host, never synced.
require_once __DIR__ . '/config.php'; require_once __DIR__ . '/config.php';
require_once __DIR__ . '/confform.php';
define('SCHEDULE_FILE', '/boot/config/plugins/varaverk/schedule.json'); define('SCHEDULE_FILE', '/boot/config/plugins/varaverk/schedule.json');
define('CRON_FILE', '/etc/cron.d/varaverk'); define('CRON_FILE', '/etc/cron.d/varaverk');
@@ -59,7 +60,7 @@ function vv_cron_rebuild(array $schedule): bool {
$logDir = dirname($logFile); $logDir = dirname($logFile);
if (!is_dir($logDir)) mkdir($logDir, 0755, true); if (!is_dir($logDir)) mkdir($logDir, 0755, true);
$flags = !empty($entry['log_enabled']) ? ' --log' : ''; $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[] = ""; $lines[] = "";
@@ -171,6 +172,25 @@ function vv_script_description(string $path): string {
return $first; 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: // Walk the scripts repo and return the job tree:
// orchestrators as top-level, individual scripts as children // orchestrators as top-level, individual scripts as children
function vv_job_tree(): array { function vv_job_tree(): array {
File diff suppressed because it is too large Load Diff
@@ -1,7 +1,8 @@
<?php <?php
require_once dirname(__DIR__) . '/include/scheduler.php'; require_once dirname(__DIR__) . '/include/scheduler.php';
$tree = vv_job_tree(); $tree = vv_job_tree();
$blocks = vv_parse_user_script_template(); $customs = vv_custom_scripts();
$blocks = vv_parse_user_script_template();
// Build a flat lookup: rel_path → [enabled, cron, in_tree] // Build a flat lookup: rel_path → [enabled, cron, in_tree]
$treeMap = []; $treeMap = [];
@@ -45,7 +46,11 @@ foreach ($tree as $orch) {
<div class="vv-job-actions"> <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-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-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"> <label class="vv-log-label" title="Enable verbose --log output">
<input type="checkbox" class="vv-log-enabled" <input type="checkbox" class="vv-log-enabled"
<?= $orch['log_enabled'] ? 'checked' : '' ?> <?= $orch['log_enabled'] ? 'checked' : '' ?>
@@ -80,7 +85,11 @@ foreach ($tree as $orch) {
<div class="vv-job-actions"> <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-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-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"> <label class="vv-log-label" title="Enable verbose --log output">
<input type="checkbox" class="vv-log-enabled" <input type="checkbox" class="vv-log-enabled"
<?= $child['log_enabled'] ? 'checked' : '' ?> <?= $child['log_enabled'] ? 'checked' : '' ?>
@@ -97,11 +106,61 @@ foreach ($tree as $orch) {
</div> </div>
<?php endforeach; ?> <?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 --> </div><!-- /#vv-sched-cards -->
<!-- Single global save --> <!-- Single global save -->
<div class="vv-sched-footer"> <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> <span id="vv-save-all-status" class="vv-save-status"></span>
</div> </div>
@@ -112,17 +171,23 @@ foreach ($tree as $orch) {
<div class="vv-card vv-log-card"> <div class="vv-card vv-log-card">
<div class="vv-log-toolbar" style="margin-bottom:8px;"> <div class="vv-log-toolbar" style="margin-bottom:8px;">
<div style="display:flex;align-items:center;gap: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-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> <span id="vv-log-dot" style="display:none;width:8px;height:8px;border-radius:50%;background:#4caf50;flex-shrink:0;"></span>
</div> </div>
<div style="display:flex;align-items:center;gap:12px;"> <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"> <label id="vv-auto-scroll-label" class="vv-log-label" title="Auto-scroll to newest line" style="display:none">
<input type="checkbox" id="vv-scroll-lock"> <span>Scroll Lock</span> <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>
<label id="vv-invert-log-label" class="vv-log-label" title="Show newest lines at top" style="display:none"> <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> <input type="checkbox" id="vv-invert-log" onchange="vvFetchRight()"> <span>Invert</span>
</label> </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> <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> <button id="vv-clear-btn" class="vv-btn-sm" onclick="vvClearRightLog()" style="display:none">Clear</button>
</div> </div>
@@ -195,6 +260,18 @@ foreach ($tree as $orch) {
<!-- Log view (shown when a script is selected) --> <!-- 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> <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> </div>
<?php <?php
$totalJobs = 0; $scheduledJobs = 0; $totalJobs = 0; $scheduledJobs = 0;
@@ -230,6 +307,8 @@ let vvRunningId = null;
let vvLastTs = 0; let vvLastTs = 0;
let vvStaleCount = 0; let vvStaleCount = 0;
let vvPollTimer = null; let vvPollTimer = null;
let vvEditorId = null;
let vvConfId = null;
function vvPost(url, data) { function vvPost(url, data) {
const params = new URLSearchParams({csrf_token, ...data}); const params = new URLSearchParams({csrf_token, ...data});
@@ -258,26 +337,51 @@ function vvFitRight() {
right.style.height = Math.round(leftRect.bottom - rightRect.top) + 'px'; right.style.height = Math.round(leftRect.bottom - rightRect.top) + 'px';
const contentH = Math.max(80, lf.getBoundingClientRect().top - 32 - toolbar.getBoundingClientRect().bottom); const contentH = Math.max(80, lf.getBoundingClientRect().top - 32 - toolbar.getBoundingClientRect().bottom);
// Apply to whichever content pane is visible
if (pre.style.display !== 'none') { if (pre.style.display !== 'none') {
pre.style.maxHeight = 'none'; pre.style.maxHeight = 'none';
pre.style.height = contentH + 'px'; pre.style.height = contentH + 'px';
} }
if (sug.style.display !== 'none') { 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); window.addEventListener('resize', vvFitRight);
function vvShowLogMode(id) { function vvShowLogMode(id) {
document.getElementById('vv-suggestions').style.display = 'none'; document.getElementById('vv-suggestions').style.display = 'none';
document.getElementById('vv-log-pre').style.display = ''; document.getElementById('vv-editor').style.display = 'none';
document.getElementById('vv-back-btn').style.display = ''; document.getElementById('vv-log-pre').style.display = '';
document.getElementById('vv-clear-btn').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-scroll-lock-label').style.display = '';
document.getElementById('vv-invert-log-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(); const name = id.replace(/\.sh$/, '').split('/').pop();
document.getElementById('vv-log-title').textContent = name; 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() { function vvBackToSuggestions() {
@@ -289,12 +393,30 @@ function vvBackToSuggestions() {
if (vvPollTimer) { clearInterval(vvPollTimer); vvPollTimer = null; } if (vvPollTimer) { clearInterval(vvPollTimer); vvPollTimer = null; }
vvSetRunning(null); vvSetRunning(null);
document.getElementById('vv-log-pre').style.display = 'none'; document.getElementById('vv-log-pre').style.display = 'none';
document.getElementById('vv-suggestions').style.display = ''; document.getElementById('vv-editor').style.display = 'none';
document.getElementById('vv-back-btn').style.display = 'none'; document.getElementById('vv-suggestions').style.display = '';
document.getElementById('vv-clear-btn').style.display = 'none'; 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-scroll-lock-label').style.display = 'none';
document.getElementById('vv-invert-log-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-title').textContent = 'Suggested Schedules';
document.getElementById('vv-log-ts').textContent = ''; document.getElementById('vv-log-ts').textContent = '';
document.getElementById('vv-log-dot').style.display = 'none'; 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'); if (old) old.querySelector('.vv-job-row').classList.remove('vv-row-selected');
} }
vvActiveId = id; vvActiveId = id;
localStorage.setItem('vv-last-job', id);
const job = document.querySelector('[data-id="' + CSS.escape(id) + '"]'); const job = document.querySelector('[data-id="' + CSS.escape(id) + '"]');
if (job) job.querySelector('.vv-job-row').classList.add('vv-row-selected'); if (job) job.querySelector('.vv-job-row').classList.add('vv-row-selected');
@@ -324,12 +447,21 @@ function vvFetchRight() {
.then(d => { .then(d => {
const pre = document.getElementById('vv-log-pre'); const pre = document.getElementById('vv-log-pre');
const ts = document.getElementById('vv-log-ts'); const ts = document.getElementById('vv-log-ts');
const scrollLock = document.getElementById('vv-scroll-lock')?.checked; const autoScroll = document.getElementById('vv-auto-scroll').checked;
const invert = document.getElementById('vv-invert-log')?.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; } if (!d.ok) { pre.textContent = '✗ ' + (d.error ?? 'Error'); return; }
const content = d.content || '(no log yet)'; const content = d.content || '';
pre.textContent = invert ? content.split('\n').reverse().join('\n') : content; vvSetLogBtnState(vvActiveId, content.trim().length > 0);
if (!scrollLock) pre.scrollTop = invert ? 0 : pre.scrollHeight; 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() : ''; ts.textContent = d.ts ? 'Last run: ' + new Date(d.ts * 1000).toLocaleString() : '';
// Running indicator: clear when log stops changing // Running indicator: clear when log stops changing
@@ -450,13 +582,211 @@ function vvToggleAdvanced(btn) {
btn.textContent = visible ? 'Advanced ▸' : 'Advanced ▾'; 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() { function vvClearRightLog() {
if (!vvActiveId) return; if (!vvActiveId) return;
vvPost('/plugins/varaverk/api/log.php', {id: vvActiveId, clear: '1'}) vvPost('/plugins/varaverk/api/log.php', {id: vvActiveId, clear: '1'})
.then(() => vvFetchRight()) .then(() => { vvSetLogBtnState(vvActiveId, false); vvFetchRight(); })
.catch(() => {}); .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) { function vvToggleSugBlock(bi) {
const body = document.getElementById('vv-sug-body-' + bi); const body = document.getElementById('vv-sug-body-' + bi);
const chevron = body.closest('.vv-sug-block').querySelector('.vv-sug-chevron'); const chevron = body.closest('.vv-sug-block').querySelector('.vv-sug-chevron');
@@ -466,4 +796,18 @@ function vvToggleSugBlock(bi) {
} }
</script> </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>
-1
View File
@@ -258,7 +258,6 @@ if [[ "${NETWORK_WATCHDOG_CHECK_TAILSCALE:-true}" == "true" ]]; then
log "$ICON_SUCCESS Tailscale running" log "$ICON_SUCCESS Tailscale running"
else else
warn "$ICON_ERROR Tailscale not in Running state" warn "$ICON_ERROR Tailscale not in Running state"
(( ISSUES++ ))
notify "Tailscale not running on $(hostname) ($MY_ID) — manual check needed" \ notify "Tailscale not running on $(hostname) ($MY_ID) — manual check needed" \
"Network Watchdog" "warning" "Network Watchdog" "warning"
fi fi
+1 -3
View File
@@ -589,7 +589,7 @@ check_docker_daemon() {
# ============================================================================================== # ==============================================================================================
# ━━━ Single-Pass Monitoring Run ━━━ # ━━━ Single-Pass Monitoring Run ━━━
# ============================================================================================== # ==============================================================================================
log "$ICON_WATCHDOG Docker watchdog — $MY_ID$(date '+%H:%M:%S')" echo "━━━ $ICON_WATCHDOG Docker Watchdog — $(date '+%Y-%m-%d %H:%M:%S') ━━━"
CYCLE_START=$(date +%s) CYCLE_START=$(date +%s)
@@ -932,8 +932,6 @@ CYCLE_START=$(date +%s)
CYCLE_END=$(date +%s) CYCLE_END=$(date +%s)
if [[ "$TOTAL_RESTARTS" -gt 0 || "$TOTAL_WARNINGS" -gt 0 ]]; then if [[ "$TOTAL_RESTARTS" -gt 0 || "$TOTAL_WARNINGS" -gt 0 ]]; then
echo ""
echo "━━━ $ICON_WATCHDOG Docker Watchdog — $(date '+%Y-%m-%d %H:%M:%S') ━━━"
echo "$ICON_WATCHDOG T1: $T1_RESTARTS restarts / $T1_WARNINGS warnings" echo "$ICON_WATCHDOG T1: $T1_RESTARTS restarts / $T1_WARNINGS warnings"
echo "$ICON_WATCHDOG T2: $T2_RESTARTS restarts / $T2_WARNINGS warnings" echo "$ICON_WATCHDOG T2: $T2_RESTARTS restarts / $T2_WARNINGS warnings"
echo "$ICON_TIME Duration: $(format_duration $(( CYCLE_END - CYCLE_START )))" echo "$ICON_TIME Duration: $(format_duration $(( CYCLE_END - CYCLE_START )))"