Varaverk: FallBack + Watchdog tabs; plugin path restructure to Plugin/unraid/

- FallBack tab: per-node tier inventory + active fallback card with duration, tier, handback strikes, running container status
- Watchdog tab: live system health (RAM bar + thresholds, load, uptime, daemon), docker watchdog strikes + skip list + restart history, stability strikes + reboot log, resource pressure alert card, config inventory (mem limits, required, pause/stop lists)
- Swapped partnership/arrs tab order; FallBack between partnership and watchdog
- Plugin source tree moved from Plugin/usr/local/emhttp/plugins/varaverk/ to Plugin/unraid/
- Deployment/ conf templates added
This commit is contained in:
Gmer4Lfe
2026-05-28 22:24:50 -04:00
parent 64d95aa991
commit fb051b60c1
73 changed files with 5896 additions and 462 deletions
+41
View File
@@ -0,0 +1,41 @@
Menu="Tasks:95"
Title="Varaverk"
Icon="varaverk.png"
---
<?php
$plugin = 'varaverk';
$docroot = $docroot ?? $_SERVER['DOCUMENT_ROOT'] ?: '/usr/local/emhttp';
$pluginDir = "$docroot/plugins/$plugin";
// Determine active tab
$tab = $_GET['tab'] ?? 'monitor';
$validTabs = ['monitor', 'scheduler', 'partnership', 'fallback', 'watchdog', 'arrs'];
if (!in_array($tab, $validTabs)) $tab = 'monitor';
$tabLabels = ['monitor' => 'Monitor', 'scheduler' => 'Scheduler', 'partnership' => 'Partnership', 'fallback' => 'FallBack', 'watchdog' => 'Watchdog', 'arrs' => 'Arrs'];
?>
<link rel="stylesheet" href="/plugins/<?=$plugin?>/css/varaverk.css">
<div id="varaverk-wrap">
<!-- Tab bar -->
<div id="vv-tabs">
<?php foreach ($validTabs as $t): ?>
<a href="?tab=<?=$t?>" class="vv-tab<?= $t === $tab ? ' active' : '' ?>">
<?= $tabLabels[$t] ?? ucfirst($t) ?>
</a>
<?php endforeach; ?>
</div>
<!-- Tab content -->
<div id="vv-content">
<?php
$page = "$pluginDir/pages/$tab.php";
if (file_exists($page)) include $page;
else echo "<p>Page not found: $tab</p>";
?>
</div>
</div>
<script src="/plugins/<?=$plugin?>/js/varaverk.js"></script>
+4
View File
@@ -0,0 +1,4 @@
<?php
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/arrs.php';
echo json_encode(vv_arrs_all());
+121
View File
@@ -0,0 +1,121 @@
<?php
// Live board data: locks, recent errors, partner reachability.
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/config.php';
$out = ['ok' => true];
// ── Active locks ──────────────────────────────────────────────────────────
$lockDir = '/tmp/unraid_locks';
$locks = [];
if (is_dir($lockDir)) {
foreach (glob($lockDir . '/*.lock') ?: [] as $lf) {
$name = basename($lf, '.lock');
$age = time() - (int)filemtime($lf);
$content = trim(file_get_contents($lf) ?: '');
// content is "PID:scriptname" — extract PID
$pid = preg_match('/^(\d+)/', $content, $pm) ? $pm[1] : '';
// Skip if PID is still alive (it's legitimately running)
if ($pid && file_exists("/proc/$pid")) continue;
$locks[] = ['name' => $name, 'file' => basename($lf), 'age' => $age];
}
}
$out['locks'] = $locks;
// ── Recent errors ──────────────────────────────────────────────────────────
// Scan recursively so subdirectory logs (Orchestrators/, Media/, etc.) are included.
// e.script = relative path without extension = job ID base, e.g. "Orchestrators/transcode_management"
$errors = [];
if (is_dir(LOG_DIR)) {
$cutoff = time() - 7 * 86400;
$logBase = rtrim(LOG_DIR, '/') . '/';
try {
$ri = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator(LOG_DIR, RecursiveDirectoryIterator::SKIP_DOTS)
);
foreach ($ri as $lf) {
if ($lf->getExtension() !== 'log') continue;
if ($lf->getMTime() < $cutoff) continue;
$rel = ltrim(str_replace($logBase, '', $lf->getPathname()), '/');
$script = preg_replace('/\.log$/', '', $rel);
// Only report scripts whose last recorded run exited as warn or error.
// This eliminates dry-run false positives (exit 0 = ok) and success-run
// summaries that happen to contain words like "Failed: 0".
$statFile = $logBase . $script . '.json';
if (file_exists($statFile)) {
$stat = json_decode(@file_get_contents($statFile), true);
$status = $stat['status'] ?? '';
if ($status !== 'warn' && $status !== 'error') continue;
} else {
continue; // no stat file — never ran through run_job.sh, skip
}
$lines = array_slice(@file($lf->getPathname()) ?: [], -200);
$lastErr = null;
foreach (array_reverse($lines) as $raw) {
$clean = preg_replace('/\033\[[0-9;]*[mK]/', '', rtrim($raw));
if (!$clean) continue;
if (preg_match('/\[(?:ERROR|WARN|CRITICAL|FAILED)\]/i', $clean) ||
preg_match('/\b(?:ERROR|CRITICAL):\s/i', $clean) ||
str_contains($clean, '✗') ||
(str_contains($clean, '⚠') && !str_contains($clean, '♥'))) {
$lastErr = mb_substr($clean, 0, 220);
break;
}
}
// Fall back to last non-blank line if no error pattern found in log
if ($lastErr === null) {
foreach (array_reverse($lines) as $raw) {
$clean = preg_replace('/\033\[[0-9;]*[mK]/', '', rtrim($raw));
if ($clean && !str_starts_with($clean, '──')) { $lastErr = mb_substr($clean, 0, 220); break; }
}
}
if ($lastErr !== null)
$errors[] = ['script' => $script, 'line' => $lastErr, 'ts' => (int)$lf->getMTime()];
}
} catch (Exception $e) {}
usort($errors, fn($a, $b) => $b['ts'] - $a['ts']);
}
$out['errors'] = array_slice($errors, 0, 20);
// ── Partner reachability ───────────────────────────────────────────────────
$cacheFile = '/tmp/vv_partner_cache.json';
$cacheTtl = 30;
$partnerData = null;
if (file_exists($cacheFile) && (time() - (int)filemtime($cacheFile)) < $cacheTtl) {
$partnerData = json_decode(file_get_contents($cacheFile), true);
} else {
// Discover partner hostname dynamically from master.conf (works for any number of hosts)
$vars = vv_conf_vars();
$mine = vv_get_hostname();
$partnerHost = null;
foreach ($vars as $k => $v) {
if (preg_match('/^HOST\d+$/', $k) && $v !== '' && strcasecmp($v, $mine) !== 0) {
$partnerHost = $v; // first non-self host is the partner
break;
}
}
if ($partnerHost) {
// Resolve via Tailscale — mirrors common.sh resolve_tailscale_ip().
// Uses `tailscale ip -4` so it survives IP changes; never relies on local DNS.
$partnerIp = vv_resolve_tailscale_ip($partnerHost);
$target = $partnerIp ?: $partnerHost;
$start = microtime(true);
$result = shell_exec('ping -c1 -W2 ' . escapeshellarg($target) . ' 2>&1');
$elapsed = (int)round((microtime(true) - $start) * 1000);
$reached = str_contains((string)$result, '1 received')
|| str_contains((string)$result, '1 packets received');
$partnerData = [
'host' => $partnerHost,
'reachable' => $reached,
'latency' => $reached ? $elapsed : null,
];
@file_put_contents($cacheFile, json_encode($partnerData));
}
}
$out['partner'] = $partnerData;
echo json_encode($out);
+14
View File
@@ -0,0 +1,14 @@
<?php
header('Content-Type: application/json');
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
echo json_encode(['ok' => false, 'error' => 'POST only']);
exit;
}
$file = basename($_POST['file'] ?? '');
if (!$file || !preg_match('/^[a-zA-Z0-9_\-]+\.lock$/', $file)) {
echo json_encode(['ok' => false, 'error' => 'Invalid filename']);
exit;
}
$path = '/tmp/unraid_locks/' . $file;
if (file_exists($path)) @unlink($path);
echo json_encode(['ok' => true]);
+14
View File
@@ -0,0 +1,14 @@
<?php
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/scheduler.php';
$id = trim($_POST['id'] ?? '');
$enabled = ($_POST['enabled'] ?? '0') === '1';
if (!$id || !preg_match('/^[a-zA-Z0-9_.\/\-]+\.sh$/', $id) || str_contains($id, '..')) {
echo json_encode(['ok' => false, 'error' => 'Invalid id']);
exit;
}
$ok = vv_conf_toggle_script($id, $enabled);
echo json_encode(['ok' => $ok, 'error' => $ok ? null : 'Failed to write master.conf']);
+43
View File
@@ -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']);
+16
View File
@@ -0,0 +1,16 @@
<?php
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/config.php';
$file = trim($_POST['file'] ?? '');
$content = $_POST['content'] ?? '';
// Must be an allowed file for this host
$allowed = vv_get_conf_files();
if (!$file || !in_array($file, $allowed)) {
echo json_encode(['ok' => false, 'error' => 'File not permitted']);
exit;
}
$ok = vv_write_conf_raw($file, $content);
echo json_encode(['ok' => $ok, 'error' => $ok ? null : 'Failed to write file']);
+21
View File
@@ -0,0 +1,21 @@
<?php
header('Content-Type: application/json');
$action = trim($_POST['action'] ?? '');
$name = trim($_POST['name'] ?? '');
if (!$name || !in_array($action, ['start', 'stop'], true)) {
echo json_encode(['ok' => false, 'error' => 'Invalid request']);
exit;
}
// Confirm container exists
$check = trim(shell_exec('docker ps -a --filter ' . escapeshellarg('name=^' . $name . '$') . " --format '{{.Names}}' 2>/dev/null") ?? '');
if ($check !== $name) {
echo json_encode(['ok' => false, 'error' => 'Container not found']);
exit;
}
exec(($action === 'start' ? 'docker start' : 'docker stop') . ' ' . escapeshellarg($name) . ' 2>&1', $out, $rc);
echo json_encode(['ok' => $rc === 0, 'output' => implode("\n", $out)]);
+33
View File
@@ -0,0 +1,33 @@
<?php
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/scheduler.php';
$id = trim($_POST['id'] ?? '');
if (!$id || !preg_match('/^[a-zA-Z0-9_.\/\-]+\.sh$/', $id) || str_contains($id, '..')) {
echo json_encode(['ok' => false, 'error' => 'Invalid id']);
exit;
}
$script = SCRIPTS_DIR . '/' . $id;
if (!file_exists($script)) {
echo json_encode(['ok' => false, 'error' => 'Script not found: ' . $id]);
exit;
}
$logFile = vv_job_log_path($id);
$logDir = dirname($logFile);
if (!is_dir($logDir)) mkdir($logDir, 0755, true);
$location = trim($_POST['location'] ?? '');
if ($location && (!str_starts_with($location, '/') || str_contains($location, '..') || preg_match('/[\x00\n\r]/', $location))) {
echo json_encode(['ok' => false, 'error' => 'Invalid location']);
exit;
}
$runner = dirname(__DIR__) . '/run_job.sh';
$flags = vv_job_flags($id);
$locArg = $location ? ' ' . escapeshellarg('--location=' . $location) : '';
exec('nohup bash ' . escapeshellarg($runner) . ' ' . escapeshellarg($id) . ' ' . escapeshellarg($script) . ' --dry-run' . ($flags ? " $flags" : '') . ' --manual' . $locArg . ' >> ' . escapeshellarg($logFile) . ' 2>&1 </dev/null &');
echo json_encode(['ok' => true]);
+4
View File
@@ -0,0 +1,4 @@
<?php
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/fallback.php';
echo json_encode(vv_fb_all());
+14
View File
@@ -0,0 +1,14 @@
<?php
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/scheduler.php';
$name = trim($_POST['name'] ?? '');
$enabled = ($_POST['enabled'] ?? '0') === '1';
if (!$name || !preg_match('/^[A-Z_]+_RSYNC_ENABLED$/', $name)) {
echo json_encode(['ok' => false, 'error' => 'Invalid flag name']);
exit;
}
$ok = vv_conf_flag_set($name, $enabled);
echo json_encode(['ok' => $ok, 'error' => $ok ? null : 'Failed to write master.conf']);
+30
View File
@@ -0,0 +1,30 @@
<?php
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/scheduler.php';
$id = trim($_SERVER['REQUEST_METHOD'] === 'POST' ? ($_POST['id'] ?? '') : ($_GET['id'] ?? ''));
if (!$id || !preg_match('/^[a-zA-Z0-9_.\/\-]+\.sh$/', $id) || str_contains($id, '..')) {
echo json_encode(['ok' => false, 'error' => 'Invalid job id']);
exit;
}
$logFile = vv_job_log_path($id);
if ($_SERVER['REQUEST_METHOD'] === 'POST' && !empty($_POST['clear'])) {
if (file_exists($logFile)) file_put_contents($logFile, '');
echo json_encode(['ok' => true]);
exit;
}
if (!file_exists($logFile)) {
echo json_encode(['ok' => true, 'content' => '', 'ts' => 0]);
exit;
}
$lines = array_slice(file($logFile) ?: [], -200);
echo json_encode([
'ok' => true,
'content' => implode('', $lines),
'ts' => filemtime($logFile),
]);
+5
View File
@@ -0,0 +1,5 @@
<?php
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/media.php';
echo json_encode(vv_media_sessions());
+30
View File
@@ -0,0 +1,30 @@
<?php
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/monitor.php';
require_once dirname(__DIR__) . '/include/vms.php';
require_once dirname(__DIR__) . '/include/docker_folders.php';
echo json_encode([
'system' => vv_system_info(),
'fallback' => vv_fallback_state(),
'fallback_active' => vv_fallback_active(),
'partner' => vv_partner_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(),
'stopped' => vv_docker_stopped(),
'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(),
'vms' => vv_get_vms(),
'docker_folders' => vv_get_docker_folders(),
'ts' => time(),
]);
+78
View File
@@ -0,0 +1,78 @@
<?php
// Move a script between *_SCRIPTS arrays in master.conf.
// POST: script (rel path), to_array (var name, or '' to remove from all arrays).
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/config.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
echo json_encode(['ok' => false, 'error' => 'POST only']);
exit;
}
$script = trim($_POST['script'] ?? '');
$toArray = trim($_POST['to_array'] ?? '');
if (!$script || str_contains($script, '..') || !preg_match('/^[A-Za-z0-9_.\-\/]+\.sh$/', $script)) {
echo json_encode(['ok' => false, 'error' => 'Invalid script']);
exit;
}
if ($toArray && !preg_match('/^[A-Z_]+_SCRIPTS$/', $toArray)) {
echo json_encode(['ok' => false, 'error' => 'Invalid array name']);
exit;
}
$confPath = CONF_DIR . '/master.conf';
if (!file_exists($confPath)) {
echo json_encode(['ok' => false, 'error' => 'master.conf not found']);
exit;
}
$lines = file($confPath, FILE_KEEP_BLANK_LINES);
if (!$lines) {
echo json_encode(['ok' => false, 'error' => 'Could not read master.conf']);
exit;
}
$scriptEsc = preg_quote($script, '/');
$removedLine = null;
$inArray = false;
// Step 1: find and remove the script line from whatever array it is currently in.
$newLines = [];
foreach ($lines as $line) {
if (preg_match('/^\s*[A-Z_]+_SCRIPTS\s*=\s*\(/', $line)) $inArray = true;
if ($inArray && preg_match('/^\s*\)\s*(?:#.*)?$/', $line) && !str_contains($line, '(')) $inArray = false;
if ($inArray && preg_match('/^\s*(?:#\s*)?"' . $scriptEsc . '(?:\s[^"]*)?"/', $line)) {
$removedLine = ' "' . $script . '"' . "\n"; // normalise indentation when re-inserting
continue; // drop from current location
}
$newLines[] = $line;
}
// Step 2: insert into target array (if specified).
if ($toArray) {
$resultLines = [];
$inTarget = false;
$inserted = false;
foreach ($newLines as $line) {
if (preg_match('/^\s*' . preg_quote($toArray, '/') . '\s*=\s*\(/', $line)) $inTarget = true;
if ($inTarget && !$inserted && preg_match('/^\s*\)\s*(?:#.*)?$/', $line) && !str_contains($line, '(')) {
$resultLines[] = $removedLine ?? (' "' . $script . '"' . "\n");
$inTarget = false;
$inserted = true;
}
$resultLines[] = $line;
}
if (!$inserted) {
echo json_encode(['ok' => false, 'error' => 'Target array "' . $toArray . '" not found in master.conf']);
exit;
}
$newLines = $resultLines;
}
if (file_put_contents($confPath, implode('', $newLines)) === false) {
echo json_encode(['ok' => false, 'error' => 'Write failed']);
exit;
}
echo json_encode(['ok' => true]);
+4
View File
@@ -0,0 +1,4 @@
<?php
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/partnership.php';
echo json_encode(vv_partnership_all());
+29
View File
@@ -0,0 +1,29 @@
<?php
// Raw conf read/write — respects per-host file visibility from vv_get_conf_files().
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/config.php';
$allowed = vv_get_conf_files();
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
$file = trim($_GET['file'] ?? 'master.conf');
if (!in_array($file, $allowed, true) || str_contains($file, '..')) {
echo json_encode(['ok' => false, 'error' => 'Not allowed']);
exit;
}
echo json_encode(['ok' => true, 'content' => vv_read_conf_raw($file), 'file' => $file, 'allowed' => $allowed]);
exit;
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$file = trim($_POST['file'] ?? '');
$content = $_POST['content'] ?? '';
if (!in_array($file, $allowed, true) || str_contains($file, '..')) {
echo json_encode(['ok' => false, 'error' => 'Not allowed']);
exit;
}
echo json_encode(['ok' => vv_write_conf_raw($file, $content)]);
exit;
}
echo json_encode(['ok' => false, 'error' => 'Method not allowed']);
+20
View File
@@ -0,0 +1,20 @@
<?php
// Read-only endpoint: return full content of any script in SCRIPTS_DIR.
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/config.php';
$id = trim($_GET['id'] ?? '');
// Must be relative path within SCRIPTS_DIR, no traversal, must end in .sh or .md
if (!$id || str_contains($id, '..') || !preg_match('/^[A-Za-z0-9_.\-\/]+\.(sh|md)$/', $id)) {
echo json_encode(['ok' => false, 'error' => 'Invalid id']);
exit;
}
$path = SCRIPTS_DIR . '/' . $id;
if (!file_exists($path)) {
echo json_encode(['ok' => false, 'error' => 'Not found']);
exit;
}
echo json_encode(['ok' => true, 'content' => file_get_contents($path)]);
+28
View File
@@ -0,0 +1,28 @@
<?php
header('Content-Type: application/json');
$logDir = '/var/log/varaverk';
$runs = [];
try {
$ri = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($logDir, RecursiveDirectoryIterator::SKIP_DOTS)
);
foreach ($ri as $file) {
if ($file->getExtension() !== 'json') continue;
$d = @json_decode(@file_get_contents($file->getPathname()), true);
if (!is_array($d) || empty($d['start']) || empty($d['status'])) continue;
if ($d['status'] === 'running') continue;
$id = (string)($d['id'] ?? '');
$runs[] = [
'id' => $id,
'label' => basename(str_replace('.sh', '', $id)),
'status' => $d['status'],
'start' => (int)$d['start'],
'dur' => isset($d['end']) ? max(0, (int)$d['end'] - (int)$d['start']) : 0,
];
}
} catch (Exception $e) {}
usort($runs, fn($a, $b) => $b['start'] - $a['start']);
echo json_encode(['ok' => true, 'runs' => array_slice($runs, 0, 24)]);
+109
View File
@@ -0,0 +1,109 @@
<?php
// Rewrite a *_SCRIPTS array in master.conf with a new script order.
// POST: array_name (e.g. "DAILY_SCRIPTS"), scripts (JSON: [{"id":"rel/path.sh","enabled":true}, ...])
// Preserves original entry lines (including inline flags/args) where possible.
// Scripts absent from the new list are dropped; new scripts are added as fresh entries.
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/config.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
echo json_encode(['ok' => false, 'error' => 'POST only']);
exit;
}
$arrayName = trim($_POST['array_name'] ?? '');
$raw = $_POST['scripts'] ?? '';
$decoded = json_decode($raw, true);
if (!$arrayName || !preg_match('/^[A-Z_]+_SCRIPTS$/', $arrayName)) {
echo json_encode(['ok' => false, 'error' => 'Invalid array_name']);
exit;
}
if (!is_array($decoded)) {
echo json_encode(['ok' => false, 'error' => 'Invalid scripts JSON']);
exit;
}
// Validate each entry
$order = [];
foreach ($decoded as $item) {
$id = trim((string)($item['id'] ?? ''));
$enabled = (bool)($item['enabled'] ?? true);
if (!$id || str_contains($id, '..') || !preg_match('/^[A-Za-z0-9_.\-\/]+\.sh$/', $id)) continue;
$order[] = ['id' => $id, 'enabled' => $enabled];
}
$confPath = CONF_DIR . '/master.conf';
if (!file_exists($confPath)) {
echo json_encode(['ok' => false, 'error' => 'master.conf not found']);
exit;
}
$lines = file($confPath, FILE_KEEP_BLANK_LINES);
if (!$lines) {
echo json_encode(['ok' => false, 'error' => 'Could not read master.conf']);
exit;
}
// Find the array block and extract original entry lines keyed by script path.
$arrayEsc = preg_quote($arrayName, '/');
$blockStart = null;
$blockEnd = null;
$depth = 0;
$origEntries = []; // path → original trimmed content line (e.g. '"Daily/script.sh --flag"')
foreach ($lines as $i => $line) {
if ($blockStart === null) {
if (preg_match('/^\s*' . $arrayEsc . '\s*=\s*\(/', $line)) {
$blockStart = $i;
$depth = 1;
}
continue;
}
$depth += substr_count($line, '(');
$depth -= substr_count($line, ')');
if ($depth <= 0) {
$blockEnd = $i;
break;
}
// Collect entries (enabled and commented)
if (preg_match('/^\s*(?:#\s*)?"([^"]+)"/', $line, $m)) {
$parts = preg_split('/\s+/', trim($m[1]));
$path = $parts[0] ?? '';
if (substr($path, -3) === '.sh' && !isset($origEntries[$path])) {
// Store the full quoted expression (may include flags after the path)
$origEntries[$path] = '"' . $m[1] . '"';
}
}
}
if ($blockStart === null || $blockEnd === null) {
echo json_encode(['ok' => false, 'error' => "Array $arrayName not found in master.conf"]);
exit;
}
// Build replacement block lines
$newBlockLines = [];
// Preserve the opening line exactly (e.g. "DAILY_SCRIPTS=(")
$newBlockLines[] = $lines[$blockStart];
foreach ($order as $item) {
$id = $item['id'];
$enabled = $item['enabled'];
$entry = $origEntries[$id] ?? '"' . $id . '"';
$prefix = $enabled ? ' ' : ' # ';
$newBlockLines[] = $prefix . $entry . "\n";
}
// Preserve the closing line exactly
$newBlockLines[] = $lines[$blockEnd];
// Replace the original block in $lines
array_splice($lines, $blockStart, $blockEnd - $blockStart + 1, $newBlockLines);
if (file_put_contents($confPath, implode('', $lines)) === false) {
echo json_encode(['ok' => false, 'error' => 'Write failed']);
exit;
}
echo json_encode(['ok' => true]);
+47
View File
@@ -0,0 +1,47 @@
<?php
// Save rsync standalone config (location + cron) for a specific rsync tier.
// POST: flag_name (e.g. "DAILY_RSYNC_ENABLED"), orch_id, location, cron
// Stored in schedule.json under "__rsync_{FLAG_NAME}".
// Triggers a cron rebuild so the standalone entry takes effect immediately.
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/config.php';
require_once dirname(__DIR__) . '/include/scheduler.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
echo json_encode(['ok' => false, 'error' => 'POST only']);
exit;
}
$flagName = trim($_POST['flag_name'] ?? '');
$orchId = trim($_POST['orch_id'] ?? '');
$location = trim($_POST['location'] ?? '');
$cron = trim($_POST['cron'] ?? '');
if (!$flagName || !preg_match('/^[A-Z_]+_RSYNC_ENABLED$/', $flagName)) {
echo json_encode(['ok' => false, 'error' => 'Invalid flag_name']);
exit;
}
if ($orchId && (str_contains($orchId, '..') || !preg_match('/^[A-Za-z0-9_.\-\/]+\.sh$/', $orchId))) {
echo json_encode(['ok' => false, 'error' => 'Invalid orch_id']);
exit;
}
if ($location && (!str_starts_with($location, '/') || str_contains($location, '..') || preg_match('/[\x00\n\r]/', $location))) {
echo json_encode(['ok' => false, 'error' => 'Invalid location']);
exit;
}
$key = '__rsync_' . $flagName;
$schedule = vv_schedule_load();
$schedule[$key] = [
'flag_name' => $flagName,
'orch_id' => $orchId,
'location' => $location,
'cron' => $cron,
];
if (!vv_schedule_save($schedule)) {
echo json_encode(['ok' => false, 'error' => 'Write failed']);
exit;
}
vv_cron_rebuild($schedule);
echo json_encode(['ok' => true]);
+33
View File
@@ -0,0 +1,33 @@
<?php
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/scheduler.php';
$id = trim($_POST['id'] ?? '');
if (!$id || !preg_match('/^[a-zA-Z0-9_.\/\-]+\.sh$/', $id) || str_contains($id, '..')) {
echo json_encode(['ok' => false, 'error' => 'Invalid id']);
exit;
}
$script = SCRIPTS_DIR . '/' . $id;
if (!file_exists($script)) {
echo json_encode(['ok' => false, 'error' => 'Script not found: ' . $id]);
exit;
}
$logFile = vv_job_log_path($id);
$logDir = dirname($logFile);
if (!is_dir($logDir)) mkdir($logDir, 0755, true);
$location = trim($_POST['location'] ?? '');
if ($location && (!str_starts_with($location, '/') || str_contains($location, '..') || preg_match('/[\x00\n\r]/', $location))) {
echo json_encode(['ok' => false, 'error' => 'Invalid location']);
exit;
}
$runner = dirname(__DIR__) . '/run_job.sh';
$flags = vv_job_flags($id);
$locArg = $location ? ' ' . escapeshellarg('--location=' . $location) : '';
exec('nohup bash ' . escapeshellarg($runner) . ' ' . escapeshellarg($id) . ' ' . escapeshellarg($script) . ($flags ? " $flags" : '') . ' --manual' . $locArg . ' >> ' . escapeshellarg($logFile) . ' 2>&1 </dev/null &');
echo json_encode(['ok' => true]);
+41
View File
@@ -0,0 +1,41 @@
<?php
// Save custom-script folder assignments to schedule.json (__folders key).
// POST: folders (JSON-encoded object: {"FolderName": ["Custom/script.sh", ...]})
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/config.php';
require_once dirname(__DIR__) . '/include/scheduler.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
echo json_encode(['ok' => false, 'error' => 'POST only']);
exit;
}
$raw = $_POST['folders'] ?? '';
$decoded = json_decode($raw, true);
if (!is_array($decoded)) {
echo json_encode(['ok' => false, 'error' => 'Invalid JSON']);
exit;
}
$clean = [];
foreach ($decoded as $name => $scripts) {
$name = trim((string)$name);
if (!$name || strlen($name) > 80) continue;
if (!is_array($scripts)) continue;
$cleanScripts = [];
foreach ($scripts as $s) {
$s = trim((string)$s);
if (!$s || str_contains($s, '..') || !preg_match('/^[A-Za-z0-9_.\-\/]+\.sh$/', $s)) continue;
$cleanScripts[] = $s;
}
$clean[$name] = $cleanScripts;
}
$schedule = vv_schedule_load();
$schedule['__folders'] = $clean;
if (!vv_schedule_save($schedule)) {
echo json_encode(['ok' => false, 'error' => 'Write failed']);
exit;
}
echo json_encode(['ok' => true]);
+45
View File
@@ -0,0 +1,45 @@
<?php
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/scheduler.php';
// Batch save — all entries in one load/write/rebuild cycle
if (!empty($_POST['batch'])) {
$entries = json_decode($_POST['batch'], true) ?: [];
$clean = [];
foreach ($entries as $e) {
$id = trim($e['id'] ?? '');
$cron = trim($e['cron'] ?? '');
if (!$id) continue;
if ($cron && !in_array($cron, ['array_start', 'array_stop'], true)
&& !preg_match('/^(\S+\s+){4}\S+$/', $cron)) $cron = '';
$clean[] = [
'id' => $id,
'enabled' => ($e['enabled'] ?? '0') === '1',
'cron' => $cron,
'log_enabled' => ($e['log_enabled'] ?? '0') === '1',
];
}
$ok = vv_schedule_update_batch($clean);
echo json_encode(['ok' => $ok, 'error' => $ok ? null : 'Failed to write schedule']);
exit;
}
$id = trim($_POST['id'] ?? '');
$enabled = (bool)($_POST['enabled'] ?? false);
$cron = trim($_POST['cron'] ?? '');
$log_enabled = ($_POST['log_enabled'] ?? '0') === '1';
if (!$id) {
echo json_encode(['ok' => false, 'error' => 'Missing id']);
exit;
}
// Basic cron validation — 5 fields, or known @event trigger, or empty
if ($cron && !in_array($cron, ['array_start', 'array_stop'], true)
&& !preg_match('/^(\S+\s+){4}\S+$/', $cron)) {
echo json_encode(['ok' => false, 'error' => 'Invalid cron expression']);
exit;
}
$ok = vv_schedule_update($id, $enabled, $cron, $log_enabled);
echo json_encode(['ok' => $ok, 'error' => $ok ? null : 'Failed to write schedule']);
+62
View File
@@ -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']);
+54
View File
@@ -0,0 +1,54 @@
<?php
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/scheduler.php';
$id = trim($_GET['id'] ?? '');
if (!$id || str_contains($id, '..') || !preg_match('/^[a-zA-Z0-9_.\/\-]+$/', $id)) {
echo json_encode(['ok' => false, 'error' => 'Invalid id']);
exit;
}
$isScript = str_ends_with($id, '.sh');
$name = basename($id, $isScript ? '.sh' : '');
$dirName = basename(dirname($id)); // e.g. "Media", "Rsync", "Orchestrators"
$slug = strtolower(str_replace(['_', '-'], ' ', $name));
$parts = explode(' ', $slug);
$first = $parts[0] ?? ''; // e.g. "radarr" from "radarr cleanup"
// Script header (bash scripts only)
$path = SCRIPTS_DIR . '/' . $id;
$header = ($isScript && file_exists($path)) ? vv_script_header_clean($path) : '';
// Section matcher: heading contains the full slug OR first meaningful word (>3 chars)
$matcher = function(string $heading, bool $isIntro) use ($slug, $first): bool {
if ($isIntro) return false;
$h = strtolower(str_replace(['_', '-'], ' ', $heading));
return str_contains($h, $slug)
|| (strlen($first) > 3 && str_contains($h, $first));
};
// Search main README/Manual + module-level files for this script's directory
$scriptsDir = SCRIPTS_DIR;
$searchFiles = [];
foreach (['README', 'Manual'] as $docType) {
$main = "$scriptsDir/$docType.md";
if (file_exists($main)) $searchFiles[] = [$docType, $main];
$mod = "$scriptsDir/$docType-$dirName.md";
if ($dirName && $dirName !== '.' && file_exists($mod) && $mod !== $main) {
$searchFiles[] = ["$docType$dirName", $mod];
}
}
$sections = [];
foreach ($searchFiles as [$label, $file]) {
$body = vv_readme_section($file, $matcher);
if ($body) $sections[] = ['source' => $label, 'body' => $body];
}
echo json_encode([
'ok' => true,
'name' => $name,
'header' => $header,
'sections' => $sections,
]);
+23
View File
@@ -0,0 +1,23 @@
<?php
header('Content-Type: application/json');
$scriptsDir = trim($_POST['scripts_dir'] ?? '');
if (!$scriptsDir) {
echo json_encode(['ok' => false, 'error' => 'scripts_dir is required']);
exit;
}
if (!is_dir($scriptsDir)) {
echo json_encode(['ok' => false, 'error' => 'Directory does not exist']);
exit;
}
$cfgFile = '/boot/config/plugins/varaverk/varaverk.cfg';
$cfgDir = dirname($cfgFile);
if (!is_dir($cfgDir)) mkdir($cfgDir, 0755, true);
$content = 'SCRIPTS_DIR="' . addslashes($scriptsDir) . '"' . "\n";
$ok = file_put_contents($cfgFile, $content) !== false;
echo json_encode(['ok' => $ok, 'error' => $ok ? null : 'Failed to write cfg file']);
+72
View File
@@ -0,0 +1,72 @@
<?php
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/monitor.php';
require_once dirname(__DIR__) . '/include/media.php';
// CPU% — delta from own state file so it doesn't conflict with monitor.php
$cpuPct = 0;
$cpuLine = '';
foreach (file('/proc/stat') ?: [] as $line) {
if (strncmp($line, 'cpu ', 4) === 0) { $cpuLine = $line; break; }
}
if (preg_match('/^cpu\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)/', $cpuLine, $m)) {
$c = [(int)$m[1],(int)$m[2],(int)$m[3],(int)$m[4],(int)$m[5],(int)$m[6],(int)$m[7]];
$sf = '/tmp/vv_snap_cpu.json';
$p = file_exists($sf) ? (json_decode(file_get_contents($sf), true) ?: null) : null;
file_put_contents($sf, json_encode($c));
if ($p && is_array($p)) {
$dt = array_sum($c) - array_sum($p);
$di = ($c[3] + $c[4]) - ($p[3] + $p[4]);
$cpuPct = $dt > 0 ? max(0, min(100, (int)round((1 - $di / $dt) * 100))) : 0;
}
}
// RAM%
$mem = [];
foreach (file('/proc/meminfo') ?: [] as $line) {
if (preg_match('/^(MemTotal|MemAvailable):\s+(\d+)/', $line, $m)) $mem[$m[1]] = (int)$m[2];
}
$ramTotalMb = (int)(($mem['MemTotal'] ?? 0) / 1024);
$ramUsedMb = (int)((($mem['MemTotal'] ?? 0) - ($mem['MemAvailable'] ?? 0)) / 1024);
$ramPct = $ramTotalMb > 0 ? (int)round($ramUsedMb / $ramTotalMb * 100) : 0;
// Fallback state (fast file read, no exec)
$fallbackState = 'UNKNOWN';
foreach (@file('/tmp/fallback_state.db') ?: [] as $line) {
if (preg_match('/^state=(.+)/', trim($line), $m)) { $fallbackState = trim($m[1]); break; }
}
// Partner
$partner = vv_partner_state();
$peers = array_values(array_filter($partner['hosts'], fn($h) => !$h['is_me']));
// Media sessions — cached 30s so the HTTP calls don't hold up every snapshot poll
$streamCount = 0;
$transcodeCount = 0;
$mediaCacheFile = '/tmp/vv_snap_media.json';
$cacheMaxAge = 30;
$cacheValid = file_exists($mediaCacheFile) && (time() - filemtime($mediaCacheFile)) < $cacheMaxAge;
if ($cacheValid) {
$cached = json_decode(file_get_contents($mediaCacheFile), true) ?: [];
} else {
$media = vv_media_sessions();
$cached = [
'stream_count' => count($media['sessions']),
'transcode_count' => count(array_filter($media['sessions'], fn($s) => !empty($s['is_tc']))),
];
file_put_contents($mediaCacheFile, json_encode($cached));
}
$streamCount = (int)($cached['stream_count'] ?? 0);
$transcodeCount = (int)($cached['transcode_count'] ?? 0);
echo json_encode([
'cpu_pct' => $cpuPct,
'ram_pct' => $ramPct,
'ram_used_mb' => $ramUsedMb,
'ram_total_mb' => $ramTotalMb,
'fallback' => $fallbackState,
'partner_enabled' => $partner['enabled'],
'peers' => $peers,
'stream_count' => $streamCount,
'transcode_count' => $transcodeCount,
]);
+19
View File
@@ -0,0 +1,19 @@
<?php
// Returns current run status for all scheduled jobs.
// Used by the scheduler page to light up running indicators without user interaction.
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/scheduler.php';
$schedule = vv_schedule_load();
$result = [];
foreach ($schedule as $id => $entry) {
$statFile = vv_job_stat_path($id);
if (!file_exists($statFile)) continue;
$stat = json_decode(@file_get_contents($statFile) ?: '{}', true) ?: [];
$status = $stat['status'] ?? 'unknown';
if ($status === 'running' && !empty($stat['pid']) && !file_exists("/proc/{$stat['pid']}")) {
$status = 'error';
}
$result[$id] = $status;
}
echo json_encode($result);
+91
View File
@@ -0,0 +1,91 @@
<?php
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/scheduler.php';
$id = trim($_POST['id'] ?? '');
if (!$id || !preg_match('/^[a-zA-Z0-9_.\/\-]+\.sh$/', $id) || str_contains($id, '..')) {
echo json_encode(['ok' => false, 'error' => 'Invalid id']);
exit;
}
$statFile = vv_job_stat_path($id);
if (!file_exists($statFile)) {
echo json_encode(['ok' => false, 'error' => 'No stat file — script may not be running']);
exit;
}
$stat = json_decode(file_get_contents($statFile) ?: '{}', true) ?: [];
if (($stat['status'] ?? '') !== 'running') {
echo json_encode(['ok' => true, 'msg' => 'Not running']);
exit;
}
$pid = (int)($stat['pid'] ?? 0);
if ($pid < 2) {
echo json_encode(['ok' => false, 'error' => 'No valid PID in stat file']);
exit;
}
// Kill the whole process group so the script and all its children die together.
// pgid is usually the same as the session leader PID from run_job.sh.
$pgid = (int)trim(shell_exec("ps -o pgid= -p $pid 2>/dev/null") ?: '0');
if ($pgid > 1) {
shell_exec("kill -TERM -$pgid 2>/dev/null");
} else {
// Fallback: kill the direct PID and its children
shell_exec("pkill -TERM -P $pid 2>/dev/null");
shell_exec("kill -TERM $pid 2>/dev/null");
}
// Give it up to 3s to exit gracefully
$dead = false;
for ($i = 0; $i < 6; $i++) {
usleep(500000);
if (!file_exists("/proc/$pid")) { $dead = true; break; }
}
// Force-kill if still alive
if (!$dead) {
if ($pgid > 1) shell_exec("kill -KILL -$pgid 2>/dev/null");
shell_exec("pkill -KILL -P $pid 2>/dev/null");
shell_exec("kill -KILL $pid 2>/dev/null");
usleep(300000);
$dead = !file_exists("/proc/$pid");
}
// Clear any lock files in /tmp/unraid_locks whose content matches this PID
$lockDir = '/tmp/unraid_locks';
$cleared = [];
foreach (glob("$lockDir/*.lock") ?: [] as $lf) {
$content = trim(file_get_contents($lf) ?: '');
$lockPid = (int)explode(':', $content)[0];
if ($lockPid === $pid || !file_exists("/proc/$lockPid")) {
@unlink($lf);
$cleared[] = basename($lf);
}
}
// Also clear by script name in case PID rotated
$scriptBase = basename($id, '.sh');
$namedLock = "$lockDir/{$scriptBase}.lock";
if (file_exists($namedLock)) {
@unlink($namedLock);
if (!in_array(basename($namedLock), $cleared)) $cleared[] = basename($namedLock);
}
// Update stat file
$now = time();
$stat['status'] = 'stopped';
$stat['end'] = $now;
$stat['exit'] = -1;
unset($stat['pid']);
file_put_contents($statFile, json_encode($stat));
echo json_encode([
'ok' => true,
'killed' => $dead,
'locks' => $cleared,
]);
+20
View File
@@ -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]);
+4
View File
@@ -0,0 +1,4 @@
<?php
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/watchdog.php';
echo json_encode(vv_wd_all());
+692
View File
@@ -0,0 +1,692 @@
/* Varaverk plugin styles — inherits unRAID theme, adds plugin-specific layout */
#varaverk-wrap { padding: 10px; font-family: inherit; }
/* Tab bar */
#vv-tabs { display: flex; gap: 4px; margin-bottom: 16px; border-bottom: 2px solid #444; }
.vv-tab { padding: 6px 16px; text-decoration: none; color: #aaa; border-radius: 4px 4px 0 0; }
.vv-tab:hover { color: #fff; background: #333; }
.vv-tab.active { color: #fff; background: #555; border-bottom: 2px solid #fff; }
/* Cards / layout */
.vv-row { display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 12px; }
.vv-card { flex: 1; min-width: 200px; background: #1e1e1e; border: 1px solid #444;
border-radius: 6px; padding: 12px; }
.vv-wide { flex: 100%; }
.vv-card h3 { margin: 0 0 10px; font-size: 13px; text-transform: uppercase;
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 */
.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; }
.vv-state-dark { color: #9e9e9e; }
.vv-state-unknown { color: #666; }
/* Docker table */
#vv-docker-table { width: 100%; border-collapse: collapse; font-size: 13px; }
#vv-docker-table th { text-align: left; padding: 4px 8px; color: #888;
border-bottom: 1px solid #444; }
#vv-docker-table td { padding: 4px 8px; border-bottom: 1px solid #2a2a2a; }
.vv-status-up { color: #4caf50; }
.vv-status-down { color: #f44336; }
/* Scheduler */
.vv-hint { color: #888; font-size: 13px; margin-bottom: 16px; }
.vv-sched-card { margin-bottom: 10px; }
.vv-script { background: #161616; border: 1px solid #333; border-radius: 4px;
padding: 6px 10px; margin: 4px 0; margin-left: 20px; }
.vv-job-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.vv-job-label { flex: 1; font-size: 16px; font-weight: bold;
color: #6fcf97; min-width: 80px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.vv-job-actions { display: flex; align-items: center; gap: 8px; padding-left: 44px; margin-top: 6px; }
.vv-job-desc { font-size: 14px; color: #777; margin: 3px 0 2px 0;
padding-left: 44px; box-sizing: border-box; width: 100%;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
cursor: default; }
.vv-cron { flex: 0 0 80px; width: 80px; background: #111; border: 1px solid #444; color: #ddd;
padding: 4px 6px; border-radius: 4px; font-family: monospace; font-size: 11px; }
.vv-event-badge { flex: 0 0 auto; padding: 3px 8px; border-radius: 4px; font-size: 12px;
background: #1a3a1a; border: 1px solid #2e6b2e; color: #6fcf6f;
white-space: nowrap; font-weight: 500; }
.vv-flag-badge { flex: 0 0 auto; padding: 2px 6px; border-radius: 3px; font-size: 11px;
background: #2e2200; border: 1px solid #6b4e00; color: #d4a017;
white-space: nowrap; font-family: monospace; }
.vv-log-label { display: flex; align-items: center; gap: 4px; font-size: 12px; color: #888;
cursor: pointer; white-space: nowrap; flex-shrink: 0; }
.vv-log-label input { cursor: pointer; accent-color: #4caf50; }
.vv-log-label:has(input:checked) { color: #4caf50; }
.vv-children { padding-top: 8px; border-top: 1px solid #333; margin-top: 8px; }
.vv-advanced-toggle { background: none; border: 1px solid #555; color: #aaa;
padding: 2px 8px; border-radius: 4px; cursor: pointer; font-size: 12px; }
.vv-advanced-toggle:hover { border-color: #888; color: #fff; }
.vv-btn-sm { padding: 3px 10px; background: #2a2a2a; border: 1px solid #555; color: #ccc;
border-radius: 4px; cursor: pointer; font-size: 12px; white-space: nowrap; }
.vv-btn-sm:hover { border-color: #888; color: #fff; }
.vv-btn-sm.active { border-color: #4caf50; color: #4caf50; }
/* Save checkmark */
.vv-save-check { color: #4caf50; font-size: 13px; width: 14px; flex-shrink: 0;
opacity: 0; text-align: center; }
@keyframes vv-check-fade { 0%,60% { opacity: 1; } 100% { opacity: 0; } }
.vv-save-check.vv-check-show { animation: vv-check-fade 2s forwards; }
/* Running dot */
.vv-job-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; flex-shrink: 0; }
.vv-dot-running { background: #4caf50; animation: vv-pulse-dot 1s ease-in-out infinite; }
#vv-log-dot { animation: vv-pulse-dot 1s ease-in-out infinite; }
@keyframes vv-pulse-dot { 0%, 100% { opacity: 1; } 50% { opacity: 0.2; } }
/* Selected job row */
.vv-row-selected { background: rgba(255,255,255,0.05); border-radius: 4px;
outline: 1px solid #555; }
/* Two-panel layout */
#vv-sched-layout { display: flex; gap: 16px; align-items: stretch; }
#vv-sched-left { flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; align-self: flex-start; }
#vv-sched-cards { flex: 1; }
#vv-sched-right { display: none; flex: 1 1 0; min-width: 0; flex-direction: column; overflow: hidden; align-self: flex-start; }
#vv-sched-right.vv-panel-visible { display: flex; }
.vv-log-card { flex: 1; display: flex; flex-direction: column; padding-bottom: 0; }
.vv-log-right-pre { max-height: none; overflow-y: auto; }
/* Scheduler stacked layout (narrow viewport) */
@media (max-width: 900px) {
#vv-sched-layout { flex-direction: column; align-items: stretch; }
#vv-sched-left { flex: none; width: 100%; }
#vv-sched-right { flex-direction: column; width: 100%; overflow: visible; }
.vv-log-right-pre { min-height: 520px; max-height: 680px; }
/* Toolbar: stack title row above buttons row, let buttons wrap */
.vv-log-toolbar { flex-direction: column; align-items: flex-start; gap: 6px; }
.vv-log-toolbar > div { flex-wrap: wrap; gap: 6px !important; }
/* Array-event rows: badge already shows the event, cron input is redundant */
.vv-job-row:has(.vv-event-badge) .vv-cron { display: none; }
}
/* Plugin settings row (Advanced mode) */
.vv-nb-settings { display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
padding: 5px 10px; background: #101010; border-bottom: 1px solid #1a1a1a; }
/* Monitor responsive — 4-column grid at medium width */
@media (max-width: 1024px) {
#vv-monitor { grid-template-columns: repeat(4, 1fr) !important; }
#vv-docker { grid-column: span 4 !important; }
/* Reset explicit placements so cards reflow in the 4-col grid */
#vv-docker-folders { grid-column: span 4 !important; }
#vv-parity-card { grid-column: auto !important; }
#vv-storage-card { grid-column: auto !important; }
#vv-array-card { grid-column: auto !important; }
}
/* Containers+VMs: single column when viewport is narrow */
@media (max-width: 900px) {
.vv-df-cols { flex-direction: column; align-items: stretch; }
}
/* Phone layout — scheduler row/actions fixes + monitor single-column */
@media (max-width: 480px) {
/* Phone portrait — hint gone, cron stays inline but compact */
.vv-cron-hint { display: none !important; }
.vv-job-row .vv-cron { flex: 0 0 68px; width: 68px; font-size: 10px; margin-left: 30px; }
}
@media (max-width: 600px) {
/* Allow action buttons to wrap rather than overflow the card */
.vv-job-actions { flex-wrap: wrap; padding-left: 0; }
/* Kill the inline margin-left:auto that pushes Advanced off-screen */
.vv-advanced-toggle { margin-left: 0 !important; width: auto !important; }
/* Keep cron from growing — label owns remaining row space */
.vv-cron { flex: 0 0 75px; width: 75px; }
/* Slightly tighter label on narrow screens */
.vv-job-label { font-size: 14px; }
/* Footer buttons wrap instead of overflowing */
.vv-sched-footer { flex-wrap: wrap; }
/* Log toolbar search — narrow on small screens */
#vv-log-search { width: 80px; }
/* Snapshot footer — smaller on mobile */
.vv-snap-footer { gap: 10px !important; }
.vv-snap-item { gap: 4px; }
.vv-snap-label { font-size: 10px; }
.vv-snap-bar { width: 44px; height: 5px; }
.vv-snap-val { font-size: 11px; min-width: 26px; }
.vv-snap-div { font-size: 11px; }
.vv-snap-state { font-size: 11px; }
#vv-snap-partner { font-size: 11px; }
.vv-snap-media { font-size: 11px; }
/* CPU core bars — shrink gap and min-width so many cores don't overflow */
.vv-cpu-cores { gap: 1px !important; }
.vv-cpu-core { min-width: 4px !important; }
/* Monitor single-column — explicit placement cards need override too */
#vv-monitor { grid-template-columns: 1fr !important; }
#vv-monitor > .vv-card { grid-column: 1 / -1 !important; }
#vv-docker-folders { grid-column: 1 / -1 !important; }
}
/* Shared footer (Save Schedule left, info right) — same min-height so log card ends level with script cards */
.vv-sched-footer { display: flex; align-items: center; gap: 10px;
margin-top: 12px; padding: 10px 0; border-top: 1px solid #333;
flex-shrink: 0; min-height: 72px; box-sizing: border-box; }
.vv-sched-info { color: #666; font-size: 14px; display: flex; flex-direction: column; gap: 9px; }
.vv-save-btn { padding: 5px 18px; background: #4caf50; border: none; color: #fff;
border-radius: 4px; cursor: pointer; font-size: 13px; }
.vv-save-btn:hover { background: #388e3c; }
.vv-save-status { font-size: 12px; color: #aaa; }
.vv-run-btn { border-color: #2196f3 !important; color: #2196f3 !important; }
.vv-run-btn:hover { background: #0d47a1 !important; color: #fff !important;
border-color: #2196f3 !important; }
.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; }
/* Editor layout: gutter + inner area */
#vv-editor-wrap { display: flex; }
#vv-ln-gutter { width: 42px; min-width: 42px; flex-shrink: 0;
background: #0a0a0a; border-right: 1px solid #1c1c1c;
font-family: monospace; font-size: 12px; line-height: 1.5; tab-size: 2;
color: #3a3a3a; text-align: right; padding: 10px 8px 10px 0;
overflow: hidden; user-select: none; white-space: pre; }
#vv-editor-inner { position: relative; flex: 1; overflow: hidden; }
/* Syntax-highlight overlay sits over the transparent textarea */
#vv-hl-overlay { display: none; position: absolute; top: 1px; left: 1px; right: 1px; bottom: 1px;
margin: 0; padding: 10px 12px; box-sizing: border-box;
font-family: monospace; font-size: 12px; line-height: 1.5; tab-size: 2;
white-space: pre-wrap; word-break: break-all; overflow: hidden;
pointer-events: none; user-select: none;
background: #0d0d0d; border: none; border-radius: 3px; }
.vv-editor-hl #vv-hl-overlay { display: block; }
.vv-editor-hl #vv-editor-body { color: transparent; caret-color: #ddd; background: transparent; }
/* Suggestions panel — accordion */
#vv-suggestions { overflow-y: auto; }
.vv-sug-block { border-bottom: 1px solid #1e1e1e; }
.vv-sug-header { display: flex; align-items: center; gap: 8px; padding: 7px 4px;
cursor: pointer; user-select: none; flex-wrap: wrap; }
.vv-sug-header:hover { background: rgba(255,255,255,0.03); }
.vv-sug-chevron { color: #555; font-size: 11px; flex-shrink: 0; width: 10px; }
.vv-sug-title { color: #7a9eb5; font-weight: bold; font-size: 13px; flex: 1; min-width: 120px; }
.vv-sug-cron { color: #ddd; background: #111; border: 1px solid #333; padding: 1px 6px;
border-radius: 3px; font-size: 11px; font-family: monospace; white-space: nowrap; flex-shrink: 0; }
.vv-sug-label { color: #666; font-size: 12px; flex: 1; min-width: 0; white-space: nowrap;
overflow: hidden; text-overflow: ellipsis; }
.vv-sug-status { font-size: 11px; flex-shrink: 0; margin-left: auto; }
.vv-sug-on { color: #4caf50; }
.vv-sug-off { color: #777; }
.vv-sug-none { color: #444; }
.vv-sug-body { padding: 0 14px 10px; }
.vv-sug-desc { font-size: 12px; color: #888; white-space: pre-wrap; word-break: break-word;
background: none; border: none; margin: 4px 0 8px; padding: 0;
font-family: inherit; line-height: 1.6; }
.vv-sug-scripts { display: flex; flex-direction: column; gap: 4px; }
.vv-sug-script-row { display: flex; align-items: center; gap: 8px; font-size: 12px; }
.vv-sug-inline-cron { color: #aaa; background: #111; padding: 1px 5px; border-radius: 3px;
font-size: 11px; white-space: nowrap; }
.vv-sug-path { color: #888; font-family: monospace; }
.vv-sug-configured { color: #4caf50; font-size: 11px; }
/* Info sections inside Scheduler Information panel */
.vv-info-block .vv-sug-title { color: #9ab; }
.vv-info-body { padding: 2px 10px 10px; }
.vv-info-cols { margin: 0; padding-left: 16px; columns: 2; column-gap: 20px; column-fill: balance; }
.vv-info-cols li { font-size: 12px; color: #aaa; margin-bottom: 5px; break-inside: avoid; line-height: 1.5; }
.vv-info-cols .vv-info-sep { column-span: all; list-style: none; margin: 10px -4px 6px;
padding: 4px 8px; background: #161a1d; border-left: 2px solid #1e6fa5;
font-size: 10px; font-weight: bold; text-transform: uppercase;
letter-spacing: .08em; color: #4a8ab5; break-inside: avoid; }
.vv-info-cols li strong { color: #ccc; }
.vv-info-cols code { background: #1a1a1a; padding: 0 4px; border-radius: 2px;
font-size: 11px; color: #9ab; border: 1px solid #333; }
.vv-info-divider { font-size: 10px; text-transform: uppercase; letter-spacing: 0.1em; color: #444;
padding: 10px 12px 4px; border-top: 1px solid #2a2a2a; margin-top: 2px; }
/* How do I use this — pinned at top of suggestions panel */
#vv-how-to-use {
position: sticky;
top: 0;
z-index: 3;
background: #1e1e1e;
border-bottom: 1px solid #333;
}
/* Run-status dot colours (tree + activity list) */
.vv-stat-ok { color: #4caf50; }
.vv-stat-warn { color: #ff9800; }
.vv-stat-error{ color: #f44336; }
.vv-stat-skip { color: #607d8b; }
.vv-stat-none { color: #444; }
/* Cron humanizer hint — left of cron input; first to collapse under space pressure */
.vv-cron-hint { font-size: 10px; color: #505050; white-space: nowrap;
flex: 0 10 auto; min-width: 0; max-width: 130px;
overflow: hidden; text-overflow: ellipsis;
pointer-events: none; user-select: none; }
/* Recent Activity list */
.vv-activity-list { display: flex; flex-direction: column; }
.vv-activity-row { display: flex; align-items: center; gap: 6px; padding: 4px 0;
border-bottom: 1px solid #1a1a1a; font-size: 11px; cursor: pointer; }
.vv-activity-row:last-child { border-bottom: none; }
.vv-activity-row:hover .vv-activity-label { color: #fff; }
.vv-activity-dot { flex-shrink: 0; font-size: 9px; }
.vv-activity-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis;
white-space: nowrap; color: #ccc; }
.vv-activity-ago { color: #555; font-size: 10px; white-space: nowrap; flex-shrink: 0; }
.vv-activity-dur { color: #444; font-size: 10px; white-space: nowrap; flex-shrink: 0;
min-width: 32px; text-align: right; }
/* Log search */
#vv-log-search { width: 120px; font-size: 11px; padding: 2px 6px;
background: #111; border: 1px solid #333; color: #ccc;
border-radius: 3px; font-family: monospace; }
#vv-log-search:focus { border-color: #555; outline: none; }
mark { background: #5d4037; color: #ffcc80; border-radius: 2px; }
.vv-log-dim { opacity: 0.2; }
/* Cron Calculator */
.vv-calc-wrap { display: flex; flex-direction: column; gap: 8px; }
.vv-calc-in { width: 100%; background: #111; border: 1px solid #333; color: #ccc;
padding: 5px 8px; border-radius: 3px; font-family: monospace; font-size: 12px;
box-sizing: border-box; }
.vv-calc-in:focus { border-color: #555; outline: none; }
.vv-calc-expr { font-family: monospace; font-size: 13px; color: #7cb8e8;
background: #0d1117; padding: 4px 8px; border-radius: 3px; }
.vv-calc-desc { font-size: 12px; color: #aaa; padding: 2px 2px 0; }
.vv-calc-hint { font-size: 11px; color: #555; font-style: italic; }
.vv-calc-runs-lbl { font-size: 10px; color: #555; text-transform: uppercase;
letter-spacing: .05em; margin-top: 4px; }
.vv-calc-runs { display: flex; flex-direction: column; gap: 2px; }
.vv-calc-run-row { display: flex; gap: 8px; font-size: 11px; }
.vv-calc-run-in { color: #4caf50; white-space: nowrap; min-width: 56px; }
.vv-calc-run-at { color: #666; }
.vv-calc-apply-btn { align-self: flex-start; margin-top: 2px; }
/* Board blocks — Next Runs, Errors, Locks, Partner, Disabled */
.vv-board-placeholder { color: #555; font-size: 11px; padding: 3px 0; }
.vv-hdr-badge { display: inline-block; padding: 1px 7px; border-radius: 10px;
font-size: 10px; font-weight: bold; flex-shrink: 0; }
.vv-hdr-badge-red { background: #7f0000; color: #ef9a9a; }
.vv-hdr-badge-orange { background: #5d2000; color: #ffcc80; }
.vv-hdr-badge-gray { background: #2a2a2a; color: #aaa; }
/* Next Runs */
.vv-nextrun-list { display: flex; flex-direction: column; }
.vv-nextrun-row { display: flex; align-items: center; gap: 8px; padding: 4px 0;
border-bottom: 1px solid #1a1a1a; font-size: 12px; }
.vv-nextrun-row:last-child { border-bottom: none; }
.vv-nr-label { color: #ccc; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.vv-nr-cron { color: #555; font-family: monospace; font-size: 10px; flex-shrink: 0; }
.vv-nr-in { color: #4caf50; font-size: 11px; white-space: nowrap; flex-shrink: 0; }
.vv-nr-at { color: #666; font-size: 11px; white-space: nowrap; flex-shrink: 0; }
/* Errors */
.vv-errors-list { display: flex; flex-direction: column; gap: 1px; }
.vv-err-row { padding: 5px 0; border-bottom: 1px solid #1a1a1a; }
.vv-err-row:last-child { border-bottom: none; }
.vv-err-top { display: flex; align-items: baseline; gap: 8px; margin-bottom: 2px; }
.vv-err-script { color: #e07070; font-size: 11px; font-weight: bold; }
.vv-err-age { color: #555; font-size: 10px; flex: 1; padding-left: 6px; white-space: nowrap; }
.vv-ack-btn { margin-left: auto; font-size: 10px; padding: 1px 6px;
color: #555; border-color: #333; flex-shrink: 0; }
.vv-ack-btn:hover { color: #aaa; border-color: #555; }
.vv-err-line { color: #888; font-size: 11px; word-break: break-all; line-height: 1.4; }
/* Locks */
.vv-locks-list { display: flex; flex-direction: column; }
.vv-lock-row { display: flex; align-items: center; gap: 8px; padding: 4px 0;
border-bottom: 1px solid #1a1a1a; font-size: 12px; }
.vv-lock-row:last-child { border-bottom: none; }
.vv-lk-name { color: #e8a87c; flex: 1; }
.vv-lk-age { color: #777; font-size: 11px; white-space: nowrap; }
.vv-lock-clear { font-size: 11px !important; padding: 1px 7px !important;
border-color: #b71c1c !important; color: #ef9a9a !important; }
.vv-lock-clear:hover { background: #7f0000 !important; color: #fff !important; }
/* Partner */
.vv-partner-row { display: flex; align-items: center; gap: 8px; padding: 4px 0; font-size: 12px; }
.vv-partner-name { color: #ccc; }
.vv-partner-detail { color: #666; font-size: 11px; }
.vv-partner-down { color: #f44336 !important; }
/* Disabled scripts */
.vv-disabled-list { display: flex; flex-direction: column; gap: 1px; }
.vv-disabled-row { display: flex; align-items: center; gap: 8px; padding: 3px 0;
border-bottom: 1px solid #1a1a1a; font-size: 12px; }
.vv-disabled-row:last-child { border-bottom: none; }
.vv-disabled-name { color: #888; flex: 1; }
.vv-disabled-grp { color: #444; font-size: 10px; font-family: monospace; white-space: nowrap; }
/* Notification board */
.vv-nb-board { display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
padding: 7px 10px; background: #141414; border-bottom: 1px solid #222;
font-size: 12px; min-height: 34px; }
.vv-nb-stat { color: #aaa; }
.vv-nb-sep { color: #444; }
.vv-nb-running { color: #f0a040; }
.vv-nb-conf-btns { display: flex; gap: 6px; margin-left: auto; }
.vv-nb-conf-btn { font-size: 11px; font-family: monospace; }
/* Advanced mode button */
.vv-adv-mode-btn { border: 1px solid #555; color: #999; transition: background 0.15s, color 0.15s, border-color 0.15s; }
.vv-adv-mode-btn:hover { border-color: #888; color: #fff; }
.vv-adv-mode-btn.vv-adv-mode-on { background: #1565c0; border-color: #1565c0; color: #fff; }
/* Script browser in suggestions panel */
.vv-sb-desc { font-size: 12px; color: #888; margin: 4px 0 6px; line-height: 1.5; }
.vv-sb-child-desc { margin-left: 16px; }
.vv-sb-hdr { font-family: monospace; font-size: 11px; color: #666; white-space: pre-wrap;
word-break: break-word; background: none; border: none; margin: 4px 0 8px;
padding: 0; line-height: 1.5; border-left: 2px solid #222; padding-left: 8px; }
.vv-sb-full { font-family: monospace; font-size: 11px; color: #888; white-space: pre-wrap;
word-break: break-word; background: #0a0a0a; border: 1px solid #222;
border-radius: 3px; margin: 6px 0 8px; padding: 8px 10px; line-height: 1.5;
max-height: 480px; overflow-y: auto; }
.vv-sb-child-block { border-top: 1px solid #1a1a1a; margin-top: 8px; padding-top: 8px; }
.vv-sb-child-name { display: flex; align-items: center; gap: 6px; margin-bottom: 3px; flex-wrap: wrap; }
/* README content display */
.vv-readme-body { font-family: monospace; font-size: 11px; color: #777; white-space: pre-wrap;
word-break: break-word; background: none; border: none; margin: 0;
padding: 0 4px; line-height: 1.6; }
/* Script browser tree rows */
#vv-sb-tree { padding: 0; }
.vv-sb-entry { }
.vv-sb-row { display: flex; align-items: center; gap: 6px; padding: 5px 6px;
cursor: pointer; border-radius: 3px; user-select: none; }
.vv-sb-row:hover { background: rgba(255,255,255,0.04); }
.vv-sb-selected { background: rgba(100,149,237,0.12) !important; outline: 1px solid #3a5a8a; }
.vv-sb-orch-row { border-bottom: 1px solid #1c1c1c; }
.vv-sb-child-row { padding-left: 2px; }
.vv-sb-expand { width: 14px; flex-shrink: 0; color: #555; font-size: 10px; text-align: center; }
.vv-sb-expand:hover { color: #aaa; }
.vv-sb-leaf { cursor: default; pointer-events: none; }
.vv-sb-name { flex: 1; font-size: 13px; color: #b0c4d0; min-width: 0;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.vv-sb-orch-row .vv-sb-name { font-weight: bold; color: #9ab; }
.vv-sb-children { padding-left: 10px; }
.vv-sb-child-indent { width: 14px; flex-shrink: 0; color: #333; font-size: 11px;
text-align: center; pointer-events: none; }
.vv-sb-badge { flex-shrink: 0; font-size: 11px; }
.vv-sb-cron { color: #555; background: #111; border: 1px solid #222;
padding: 0 4px; border-radius: 2px; font-size: 10px;
font-family: monospace; flex-shrink: 0; }
.vv-sb-status { font-size: 10px; flex-shrink: 0; }
/* Script info content area */
.vv-si-hdr { font-family: monospace; font-size: 12px; color: #4d894d; white-space: pre-wrap;
word-break: break-word; background: none; border: none; margin: 0;
padding: 8px 12px; line-height: 1.6; }
.vv-si-src { font-family: monospace; font-size: 11px; white-space: pre-wrap;
word-break: break-word; background: none; border: none; margin: 0;
padding: 8px 12px; line-height: 1.5; }
/* Cog settings button next to script name */
.vv-cog-btn { cursor:pointer; color:#444; font-size:.85em; margin-right:4px;
line-height:1; vertical-align:middle; user-select:none; }
.vv-cog-btn:hover { color:#aaa; }
/* Enriched script info blocks (advanced mode: header + docs + config) */
.vv-sinfo-block { border-top: 1px solid #222; padding: 0; }
.vv-sinfo-block:first-child { border-top: none; }
.vv-sinfo-lbl { font-size: 10px; font-weight: bold; text-transform: uppercase;
letter-spacing: .06em; color: #555; padding: 6px 12px 2px; }
/* Syntax highlight tokens — dark theme (VSCode-inspired) */
.vv-hl-sep { color: #2d2d2d; }
.vv-hl-shebang { color: #4a4a4a; }
.vv-hl-hash { color: #4a7340; }
.vv-hl-comment { color: #6a9955; }
.vv-hl-section { color: #9cdcfe; font-weight: bold; letter-spacing: 0.04em; }
.vv-hl-key { color: #9cdcfe; }
.vv-hl-value { color: #ce9178; }
.vv-hl-cron { color: #d7ba7d; font-weight: bold; }
.vv-hl-text { color: #4d894d; }
.vv-hl-keyword { color: #569cd6; }
.vv-hl-builtin { color: #4ec9b0; }
.vv-hl-string { color: #ce9178; }
.vv-hl-var { color: #d7ba7d; }
.vv-hl-number { color: #b5cea8; }
.vv-hl-op { color: #808080; }
/* Log panel */
.vv-log-panel { margin-top: 10px; border-top: 1px solid #333; padding-top: 8px; }
.vv-log-toolbar { display: flex; justify-content: space-between; align-items: center;
flex-wrap: wrap; gap: 4px; margin-bottom: 4px; }
.vv-log-ts { font-size: 11px; color: #666; }
.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;
scroll-behavior: auto; }
/* Toggle switch */
.vv-toggle { position: relative; display: inline-block; width: 36px; height: 20px; flex-shrink: 0; }
.vv-toggle input { opacity: 0; width: 0; height: 0; }
.vv-slider { position: absolute; inset: 0; background: #444; border-radius: 20px; cursor: pointer;
transition: 0.2s; }
.vv-slider:before { content: ''; position: absolute; width: 14px; height: 14px; left: 3px; bottom: 3px;
background: #fff; border-radius: 50%; transition: 0.2s; }
.vv-toggle input:checked + .vv-slider { background: #4caf50; }
.vv-toggle input:checked + .vv-slider:before { transform: translateX(16px); }
/* Config editor */
#vv-conf-tabs { display: flex; gap: 4px; margin-bottom: 8px; }
.vv-conf-tab { padding: 4px 12px; text-decoration: none; color: #aaa;
border: 1px solid #444; border-radius: 4px; font-size: 13px; }
.vv-conf-tab.active { color: #fff; background: #333; border-color: #666; }
#vv-conf-editor { width: 100%; min-height: 500px; background: #111; color: #ddd;
border: 1px solid #444; padding: 12px; font-family: monospace;
font-size: 13px; line-height: 1.5; border-radius: 4px; box-sizing: border-box; resize: vertical; }
#vv-conf-actions { margin-top: 8px; display: flex; align-items: center; gap: 10px; }
#vv-conf-actions button { padding: 6px 18px; background: #4caf50; border: none;
color: #fff; border-radius: 4px; cursor: pointer; font-size: 14px; }
#vv-conf-actions button:hover { background: #388e3c; }
#vv-conf-status { font-size: 13px; color: #aaa; }
/* Docs */
#vv-docs { display: flex; gap: 16px; }
#vv-docs-sidebar { width: 220px; flex-shrink: 0; }
#vv-docs-sidebar h3 { font-size: 12px; text-transform: uppercase; color: #888; margin: 0 0 8px; }
#vv-docs-sidebar ul { list-style: none; padding: 0; margin: 0; }
#vv-docs-sidebar li { margin: 2px 0; }
#vv-docs-sidebar a { display: block; padding: 3px 8px; font-size: 12px; color: #aaa;
text-decoration: none; border-radius: 3px; }
#vv-docs-sidebar a:hover { background: #222; color: #fff; }
#vv-docs-sidebar a.active { background: #333; color: #fff; }
#vv-docs-content { flex: 1; min-width: 0; }
.vv-doc-body { background: #1a1a1a; border: 1px solid #444; border-radius: 6px;
padding: 20px; line-height: 1.7; }
.vv-doc-body h1, .vv-doc-body h2, .vv-doc-body h3 { color: #ddd; }
.vv-doc-body code { background: #111; padding: 1px 5px; border-radius: 3px; font-size: 12px; }
.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; }
/* ── Arrange mode ─────────────────────────────────────────────────────────── */
.vv-drag-handle { cursor: grab; color: #555; font-size: 14px; padding: 0 5px 0 0; user-select: none; flex-shrink: 0; }
.vv-drag-handle:hover { color: #888; }
.vv-drag-ghost { opacity: 0.35; }
.vv-drop-line { height: 2px; background: #4caf50; border-radius: 2px; margin: 2px 0; pointer-events: none; }
.vv-arrange-active .vv-children { min-height: 28px; border: 1px dashed #2a2a2a; border-radius: 4px;
padding: 4px 2px; transition: border-color 0.12s, background 0.12s; }
.vv-arrange-active .vv-children.vv-drop-target { border-color: #4caf50; background: rgba(76,175,80,0.07); }
.vv-arrange-btn-active { background: #1a3a1e !important; color: #4caf50 !important; border-color: #2d5c33 !important; }
.vv-arrange-save-btn { background: #1a3a1e; border-color: #2d5c33; color: #4caf50; }
.vv-arrange-save-btn:hover { background: #22502a; }
#vv-arrange-btn { background: #7b1fa2; border-color: #7b1fa2; }
#vv-arrange-btn:hover { background: #4a148c; border-color: #4a148c; }
/* ── Arrange workspace panel ─────────────────────────────────────────────── */
.vv-arrange-ws-hdr { font-size: 11px; font-weight: bold; color: #888; text-transform: uppercase;
letter-spacing: 0.6px; margin-bottom: 8px; display: flex; align-items: center; gap: 8px; }
#vv-pending-badge { background: #ff9800; color: #000; font-size: 10px; padding: 1px 7px;
border-radius: 10px; font-weight: bold; }
.vv-arrange-pending-hdr { font-size: 10px; color: #555; text-transform: uppercase; letter-spacing: 0.5px;
margin-bottom: 5px; }
.vv-pending-row { display: flex; align-items: center; gap: 8px; padding: 3px 0;
font-size: 11px; border-bottom: 1px solid #1e1e1e; }
.vv-pending-script { color: #ddd; font-weight: 500; }
.vv-pending-arrow { color: #555; font-size: 10px; }
.vv-library-zone { border: 1px dashed #2a2a2a; border-radius: 4px; padding: 6px;
min-height: 60px; transition: border-color 0.12s, background 0.12s; }
.vv-library-zone.vv-drop-target { border-color: #c62828; background: rgba(198,40,40,0.07); }
.vv-arrange-drop-hint { font-size: 10px; color: #444; text-align: center; padding: 2px 0 7px; }
.vv-lib-card { background: #1c1c1c; border: 1px solid #2e2e2e; border-radius: 3px;
padding: 4px 8px; margin-bottom: 4px; cursor: grab; display: flex;
align-items: center; justify-content: space-between; gap: 8px; }
.vv-lib-card:hover { border-color: #444; }
.vv-lib-card.vv-drag-ghost { opacity: 0.35; }
.vv-lib-card-name { font-size: 16px; color: #ccc; font-weight: bold; }
.vv-lib-card-path { font-size: 10px; color: #444; font-family: monospace; }
/* ── Custom script folders ────────────────────────────────────────────────── */
.vv-folder-group { margin-bottom: 1px; }
.vv-folder-row { display: flex; align-items: center; gap: 6px; padding: 3px 6px;
cursor: pointer; border-radius: 3px; color: #888; font-size: 12px; user-select: none; }
.vv-folder-row:hover { background: #1e1e1e; }
.vv-folder-chevron { font-size: 10px; color: #555; width: 10px; flex-shrink: 0; }
.vv-folder-name { flex: 1; font-weight: 500; color: #aaa; }
.vv-folder-count { font-size: 10px; color: #555; background: #1c1c1c;
padding: 0 5px; border-radius: 8px; border: 1px solid #2a2a2a; }
.vv-folder-children { padding-left: 12px; min-height: 4px; }
.vv-folder-children.vv-drop-target { background: rgba(76,175,80,0.07); border-radius: 4px;
outline: 1px dashed #3a6a3e; }
.vv-folder-new-row { display: flex; align-items: center; gap: 6px; padding: 4px 6px; }
.vv-new-folder-btn { background: #0277bd !important; border: none !important; color: #fff !important; }
.vv-new-folder-btn:hover { background: #01579b !important; }
/* ── Snapshot footer (right panel status bar) ────────────────────────────── */
.vv-snap-footer { display: flex !important; flex-direction: row !important;
align-items: center !important; justify-content: center; gap: 20px; flex-wrap: wrap; }
.vv-snap-item { display: flex; align-items: center; gap: 7px; }
.vv-snap-label { font-size: 12px; color: #555; text-transform: uppercase; letter-spacing: 0.05em; }
.vv-snap-bar { width: 90px; height: 8px; background: #222; border-radius: 4px; overflow: hidden; flex-shrink: 0; }
.vv-snap-bar span { display: block; height: 100%; border-radius: 4px; width: 0;
transition: width 0.5s, background-color 0.5s; }
.vv-snap-val { font-size: 15px; color: #aaa; font-family: monospace; min-width: 36px; }
.vv-snap-div { color: #333; font-size: 15px; }
.vv-snap-state { font-size: 15px; font-weight: bold; }
#vv-snap-partner { font-size: 15px; color: #666; }
.vv-snap-media { font-size: 15px; color: #666; }
/* ── Containers and VMs card ──────────────────────────────────────────────── */
.vv-df-section-hdr { font-size: 10px; font-weight: bold; color: #555; text-transform: uppercase;
letter-spacing: 0.08em; padding: 4px 2px 5px; border-bottom: 1px solid #222;
margin-bottom: 4px; }
.vv-df-empty { font-size: 12px; color: #555; font-style: italic; padding: 4px 2px 8px; }
.vv-df-vm-row { display: flex; align-items: center; gap: 8px; padding: 5px 2px;
border-bottom: 1px solid #1a1a1a; }
.vv-df-vm-row:last-of-type { border-bottom: none; }
.vv-df-vm-icon { font-size: 15px; line-height: 1; flex-shrink: 0; }
.vv-df-vm-meta { font-size: 10px; color: #555; }
.vv-df-cols { display: flex; gap: 10px; align-items: flex-start; }
.vv-df-col { flex: 1; min-width: 0; }
.vv-df-folder { border-bottom: 1px solid #1a1a1a; }
.vv-df-folder:last-child { border-bottom: none; }
.vv-df-folder-hdr { display: flex; align-items: center; gap: 6px; padding: 5px 4px;
cursor: pointer; user-select: none; border-radius: 3px; }
.vv-df-folder-hdr:hover { background: rgba(255,255,255,0.03); }
.vv-df-chevron { color: #555; font-size: 10px; width: 10px; flex-shrink: 0; }
.vv-df-fname { flex: 1; font-size: 12px; color: #aaa; font-weight: 500;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.vv-df-folder-body { padding-left: 10px; padding-bottom: 3px; }
.vv-df-container { display: flex; align-items: center; gap: 7px; padding: 3px 6px;
cursor: pointer; border-radius: 3px; user-select: none; }
.vv-df-container:hover { background: rgba(255,255,255,0.04); }
.vv-df-active { background: rgba(255,255,255,0.05) !important; outline: 1px solid #444; }
.vv-df-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
.vv-df-cname { flex: 1; font-size: 12px; color: #ccc;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.vv-df-status { font-size: 10px; color: #555; flex-shrink: 0; white-space: nowrap; }
.vv-df-actions { display: flex; gap: 6px; padding: 3px 6px 5px 24px; flex-wrap: wrap; }
/* ── Rsync standalone controls ────────────────────────────────────────────── */
.vv-rsync-location { width: 120px; flex-shrink: 1; min-width: 60px; font-size: 11px; font-family: monospace;
background: #111; border: 1px solid #333; color: #aaa;
padding: 2px 6px; border-radius: 3px; }
.vv-rsync-location:focus { border-color: #555; outline: none; }
.vv-rsync-location::placeholder { color: #444; }
.vv-rsync-save-btn { background: #1a2e1a; border-color: #2a4a2a; color: #6aaa6a; }
.vv-rsync-save-btn:hover { background: #22382a; }
+16
View File
@@ -0,0 +1,16 @@
#!/bin/bash
# Varaverk: fire all jobs with cron=array_start (background — non-blocking).
php -r "
require_once '/usr/local/emhttp/plugins/varaverk/include/scheduler.php';
\$s = vv_schedule_load();
\$runner = '/usr/local/emhttp/plugins/varaverk/run_job.sh';
foreach (\$s as \$id => \$e) {
if (strncmp(\$id, '__', 2) === 0) continue;
if ((\$e['cron'] ?? '') !== 'array_start') continue;
if (empty(\$e['enabled'])) continue;
\$script = SCRIPTS_DIR . '/' . \$id;
if (!file_exists(\$script)) continue;
\$flags = !empty(\$e['log_enabled']) ? ' --log' : '';
exec('nohup bash ' . escapeshellarg(\$runner) . ' ' . escapeshellarg(\$id) . ' ' . escapeshellarg(\$script) . \$flags . ' > /dev/null 2>&1 &');
}
" 2>/dev/null
+9
View File
@@ -0,0 +1,9 @@
#!/bin/bash
# Register plugin with Unraid's cron system and rebuild cron entries after every boot.
# /var/log/plugins/ and /etc/cron.d/ are RAM-based and wiped on reboot.
cp /boot/config/plugins/varaverk.plg /var/log/plugins/varaverk.plg 2>/dev/null
php -r "
require_once '/usr/local/emhttp/plugins/varaverk/include/scheduler.php';
\$schedule = vv_schedule_load();
if (\$schedule) vv_cron_rebuild(\$schedule);
" 2>/dev/null
+16
View File
@@ -0,0 +1,16 @@
#!/bin/bash
# Varaverk: fire all jobs with cron=array_stop (foreground — blocks until all done).
php -r "
require_once '/usr/local/emhttp/plugins/varaverk/include/scheduler.php';
\$s = vv_schedule_load();
\$runner = '/usr/local/emhttp/plugins/varaverk/run_job.sh';
foreach (\$s as \$id => \$e) {
if (strncmp(\$id, '__', 2) === 0) continue;
if ((\$e['cron'] ?? '') !== 'array_stop') continue;
if (empty(\$e['enabled'])) continue;
\$script = SCRIPTS_DIR . '/' . \$id;
if (!file_exists(\$script)) continue;
\$flags = !empty(\$e['log_enabled']) ? ' --log' : '';
passthru('bash ' . escapeshellarg(\$runner) . ' ' . escapeshellarg(\$id) . ' ' . escapeshellarg(\$script) . \$flags);
}
" 2>/dev/null
Binary file not shown.

After

Width:  |  Height:  |  Size: 103 B

+278
View File
@@ -0,0 +1,278 @@
<?php
// Arr (Sonarr / Radarr / Lidarr) data helpers
require_once __DIR__ . '/config.php';
// ── Conf helpers ──────────────────────────────────────────────────────────────
function vv_arr_scalar(string $raw, string $key): string {
return preg_match('/^\s*' . preg_quote($key, '/') . '\s*=\s*"?([^"\n]*)"?/m', $raw, $m)
? trim($m[1]) : '';
}
// Returns all configured hosts found in master.conf as ['host1'=>'hostname', ...]
// Returns ['host1' => 'hostname', 'host2' => 'hostname', ...] from master.conf.
// Matches both HOST1="name" and HOST1_NAME="name" (either convention).
function vv_arr_known_hosts(): array {
$vars = vv_conf_vars(); // already parses HOST1="val" correctly
$hosts = [];
foreach ($vars as $k => $v) {
if (preg_match('/^HOST(\d+)$/', $k, $m) && $v !== '') {
$hosts['host' . $m[1]] = $v;
}
}
ksort($hosts);
return $hosts ?: ['host1' => 'HOST1'];
}
function vv_arr_node_names(): array {
return array_map(fn($name) => $name, vv_arr_known_hosts());
}
// ── Discovery ─────────────────────────────────────────────────────────────────
function vv_discover_arrs(): array {
$nodes = [];
$defs = [
'sonarr' => ['SONARR_URL', 'SONARR_API_KEY', 'SONARR_TV_ROOT', 'v3'],
'radarr' => ['RADARR_URL', 'RADARR_API_KEY', 'RADARR_MOVIES_ROOT', 'v3'],
'lidarr' => ['LIDARR_URL', 'LIDARR_API_KEY', 'LIDARR_MUSIC_ROOT', 'v1'],
];
foreach (array_keys(vv_arr_known_hosts()) as $h) {
$raw = vv_read_conf_raw($h . '.conf');
if (!$raw) continue;
$pfx = strtoupper($h) . '_';
$get = fn($k) => vv_arr_scalar($raw, $pfx . $k);
$arrs = [];
foreach ($defs as $type => [$uk, $ak, $rk, $api]) {
$url = $get($uk);
$key = $get($ak);
if ($url && $key && !str_contains($key, 'your-')) {
$arrs[] = ['type' => $type, 'url' => $url, 'key' => $key,
'root' => $get($rk), 'api' => $api];
}
}
if ($arrs) $nodes[] = ['host' => $h, 'arrs' => $arrs];
}
return $nodes;
}
// ── HTTP ──────────────────────────────────────────────────────────────────────
function vv_arr_http(string $url, string $apiKey, string $path, int $timeout = 4): ?array {
$ctx = stream_context_create(['http' => [
'timeout' => $timeout,
'header' => "X-Api-Key: $apiKey\r\nAccept: application/json\r\n",
'ignore_errors' => true,
]]);
$raw = @file_get_contents(rtrim($url, '/') . $path, false, $ctx);
return $raw ? (json_decode($raw, true) ?: null) : null;
}
// ── Live arr data ─────────────────────────────────────────────────────────────
function vv_fetch_arr_live(array $arr): array {
$url = $arr['url'];
$key = $arr['key'];
$base = '/api/' . $arr['api'];
$type = $arr['type'];
$out = ['online' => false, 'version' => null, 'health' => [],
'queue' => ['dl' => 0, 'warn' => 0, 'err' => 0], 'disk' => []];
$sys = vv_arr_http($url, $key, "$base/system/status");
if (!$sys) return $out;
$out['online'] = true;
$out['version'] = $sys['version'] ?? null;
if ($type === 'sonarr') {
$data = vv_arr_http($url, $key, "$base/series");
if (is_array($data)) {
$out['total'] = count($data);
$out['monitored'] = count(array_filter($data, fn($x) => !empty($x['monitored'])));
$out['episodes'] = array_sum(array_column($data, 'episodeFileCount'));
}
} elseif ($type === 'radarr') {
$data = vv_arr_http($url, $key, "$base/movie");
if (is_array($data)) {
$out['total'] = count($data);
$out['monitored'] = count(array_filter($data, fn($x) => !empty($x['monitored'])));
$out['files'] = count(array_filter($data, fn($x) => !empty($x['hasFile'])));
}
} elseif ($type === 'lidarr') {
$data = vv_arr_http($url, $key, "$base/artist");
if (is_array($data)) {
$out['total'] = count($data);
$out['monitored'] = count(array_filter($data, fn($x) => !empty($x['monitored'])));
$out['albums'] = array_sum(array_map(
fn($a) => $a['statistics']['albumCount'] ?? $a['albumCount'] ?? 0, $data));
}
}
$q = vv_arr_http($url, $key, "$base/queue?page=1&pageSize=500");
if (is_array($q)) {
foreach (($q['records'] ?? $q) as $r) {
if (!is_array($r)) continue;
$s = $r['status'] ?? '';
$tds = strtolower($r['trackedDownloadStatus'] ?? '');
$tst = strtolower($r['trackedDownloadState'] ?? '');
if ($s === 'downloading') $out['queue']['dl']++;
if ($tds === 'warning' || $tst === 'downloadingstalled') $out['queue']['warn']++;
if ($tds === 'error') $out['queue']['err']++;
}
}
$h = vv_arr_http($url, $key, "$base/health");
if (is_array($h)) $out['health'] = $h;
$d = vv_arr_http($url, $key, "$base/diskspace");
if (is_array($d)) $out['disk'] = $d;
return $out;
}
// ── Log stats ─────────────────────────────────────────────────────────────────
function vv_arr_cleanup_stats(string $type): array {
$slugs = ['sonarr' => 'Media/sonarr_cleanup',
'radarr' => 'Media/radarr_cleanup',
'lidarr' => 'Media/lidarr_cleanup'];
$base = LOG_DIR . '/' . ($slugs[$type] ?? '');
$out = ['last_run' => null, 'end' => null, 'status' => null,
'tracked' => null, 'total' => null,
'orphans' => 0, 'orphans_sz' => '0B', 'junk' => 0];
$jf = $base . '.json';
if (!file_exists($jf)) return $out;
$meta = json_decode(file_get_contents($jf), true) ?: [];
$out['last_run'] = $meta['start'] ?? null;
$out['end'] = $meta['end'] ?? null;
$out['status'] = $meta['status'] ?? null;
$lf = $base . '.log';
if (!file_exists($lf)) return $out;
$log = file_get_contents($lf);
$parts = preg_split('/━{3,}[^\n]*SUMMARY[^\n]*/u', $log);
$blk = count($parts) > 1 ? end($parts) : $log;
if (preg_match('/Tracked:\s*([\d,]+)\s*files\s*\(([\d,]+)/u', $blk, $m)) {
$out['tracked'] = (int)str_replace(',', '', $m[1]);
$out['total'] = (int)str_replace(',', '', $m[2]);
}
if (preg_match('/Orphans:\s*([\d,]+)\s*files\s*\(([^)]+)\)/u', $blk, $m)) {
$out['orphans'] = (int)str_replace(',', '', $m[1]);
$out['orphans_sz'] = trim($m[2]);
}
if (preg_match('/Junk:\s*([\d,]+)\s*files/u', $blk, $m)) {
$out['junk'] = (int)str_replace(',', '', $m[1]);
}
return $out;
}
function vv_arr_discovery_stats(string $type): array {
$slugs = ['sonarr' => 'Media/playback_aware_sonarr_discovery',
'radarr' => 'Media/playback_aware_radarr_discovery',
'lidarr' => 'Media/playback_aware_lidarr_discovery'];
$base = LOG_DIR . '/' . ($slugs[$type] ?? '');
$out = ['last_run' => null, 'status' => null, 'added' => null];
$jf = $base . '.json';
if (!file_exists($jf)) return $out;
$meta = json_decode(file_get_contents($jf), true) ?: [];
$out['last_run'] = $meta['start'] ?? null;
$out['status'] = $meta['status'] ?? null;
$lf = $base . '.log';
if (file_exists($lf)) {
$log = file_get_contents($lf);
if (preg_match('/Added[:\s]+(\d+)/i', $log, $m)) $out['added'] = (int)$m[1];
elseif (preg_match('/(\d+)\s+added/i', $log, $m)) $out['added'] = (int)$m[1];
}
return $out;
}
function vv_arr_sync_stats(): array {
$base = LOG_DIR . '/Media/arr_sync';
$out = ['last_run' => null, 'status' => null, 'added' => null,
'nodes' => null, 'blocklist_count' => null];
$jf = $base . '.json';
if (file_exists($jf)) {
$meta = json_decode(file_get_contents($jf), true) ?: [];
$out['last_run'] = $meta['start'] ?? null;
$out['status'] = $meta['status'] ?? null;
}
$master = vv_read_conf_raw('master.conf');
if (preg_match('/ARR_SYNC_BLOCKLIST\s*=\s*"?([^"\n#]+)"?/m', $master, $m)) {
$blPath = trim($m[1]);
if (file_exists($blPath)) {
$out['blocklist_count'] = count(array_filter(
file($blPath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES)));
}
}
$lf = $base . '.log';
if (file_exists($lf)) {
$log = file_get_contents($lf);
if (preg_match('/Total added[:\s]+(\d+)/i', $log, $m)) $out['added'] = (int)$m[1];
if (preg_match('/Nodes?[:\s]+(\d+)/i', $log, $m)) $out['nodes'] = (int)$m[1];
}
return $out;
}
function vv_arr_recovery_stats(): array {
$base = LOG_DIR . '/Media/arrs_failed_stalled_recovery';
$out = ['last_run' => null, 'status' => null, 'fixed' => 0, 'searched' => 0];
$jf = $base . '.json';
if (!file_exists($jf)) return $out;
$meta = json_decode(file_get_contents($jf), true) ?: [];
$out['last_run'] = $meta['start'] ?? null;
$out['status'] = $meta['status'] ?? null;
$lf = $base . '.log';
if (file_exists($lf)) {
$log = file_get_contents($lf);
if (preg_match('/Removed[:\s]+(\d+)/i', $log, $m)) $out['fixed'] = (int)$m[1];
if (preg_match('/Re-searched[:\s]+(\d+)/i',$log, $m)) $out['searched'] = (int)$m[1];
}
return $out;
}
// ── Entry point ───────────────────────────────────────────────────────────────
function vv_arrs_all(): array {
$currentHost = vv_detect_host();
$names = vv_arr_node_names();
$allNodes = vv_discover_arrs();
$result = [];
foreach ($allNodes as $node) {
$h = $node['host'];
$isLocal = ($h === $currentHost || $currentHost === 'unknown');
$nodeOut = ['host' => $h, 'name' => $names[$h] ?? strtoupper($h), 'local' => $isLocal, 'arrs' => []];
foreach ($node['arrs'] as $arr) {
$entry = ['type' => $arr['type'], 'root' => $arr['root']];
if ($isLocal) {
$entry = array_merge($entry, vv_fetch_arr_live($arr));
$entry['cleanup'] = vv_arr_cleanup_stats($arr['type']);
$entry['discovery'] = vv_arr_discovery_stats($arr['type']);
} else {
$entry['online'] = false;
$entry['remote'] = true;
}
$nodeOut['arrs'][] = $entry;
}
$result[] = $nodeOut;
}
return [
'nodes' => $result,
'sync' => vv_arr_sync_stats(),
'recovery' => vv_arr_recovery_stats(),
'host' => $currentHost,
'ts' => time(),
];
}
+209
View File
@@ -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;
}
+93
View File
@@ -0,0 +1,93 @@
<?php
// Config file parser and writer.
// Reads master.conf and the appropriate host*.conf based on running host.
define('PLUGIN_CFG', '/boot/config/plugins/varaverk/varaverk.cfg');
$_vv_cfg = @parse_ini_file(PLUGIN_CFG) ?: [];
define('SCRIPTS_DIR', $_vv_cfg['SCRIPTS_DIR'] ?? '/mnt/user/appdata/unraid_scripts');
define('CONF_DIR', SCRIPTS_DIR . '/Configurations');
define('LOG_DIR', '/var/log/varaverk');
unset($_vv_cfg);
function vv_get_hostname(): string {
return trim(shell_exec('hostname -s') ?: '');
}
// Mirror of common.sh resolve_tailscale_ip(): tries `tailscale ip -4` first (Tailscale manages
// the mapping so this survives IP changes), falls back to parsing `tailscale status` text.
function vv_resolve_tailscale_ip(string $hostname): string {
$h = strtolower($hostname);
$ip = trim(shell_exec('tailscale ip -4 ' . escapeshellarg($h) . ' 2>/dev/null') ?: '');
if ($ip) return $ip;
$out = shell_exec('tailscale status 2>/dev/null') ?: '';
foreach (explode("\n", $out) as $line) {
$cols = preg_split('/\s+/', trim($line));
if (isset($cols[1]) && stripos($cols[1], $h . '.') === 0) return $cols[0];
}
return '';
}
function vv_detect_host(): string {
// Reads master.conf for HOST1="name" (or HOST1_NAME="name") and matches running hostname.
// Returns 'host1', 'host2', 'host3', ... or 'unknown'. Works for any number of hosts.
$master = vv_read_conf_raw('master.conf');
preg_match_all('/^\s*(HOST\d+)(?:_NAME)?\s*=\s*["\']?(\S+?)["\']?\s*$/m', $master, $m);
$hostname = vv_get_hostname();
foreach ($m[1] as $i => $key) {
if (strcasecmp($hostname, trim($m[2][$i])) === 0) return strtolower($key);
}
return 'unknown';
}
function vv_is_owner(): bool {
return vv_detect_host() === 'host1';
}
function vv_read_conf_raw(string $filename): string {
$path = CONF_DIR . '/' . $filename;
return file_exists($path) ? file_get_contents($path) : '';
}
function vv_write_conf_raw(string $filename, string $content): bool {
$path = CONF_DIR . '/' . $filename;
return file_put_contents($path, $content) !== false;
}
function vv_get_conf_files(): array {
// Returns conf files this host is allowed to view/edit
$host = vv_detect_host();
$files = [];
if ($host === 'host1') {
// Owner sees master.conf + their own host conf
$files[] = 'master.conf';
$files[] = 'host1.conf';
} elseif (preg_match('/^host(\d+)$/', $host)) {
// Any other numbered host sees only their own conf
$files[] = $host . '.conf';
} else {
// Unknown host — show all for dev/debug
foreach (glob(CONF_DIR . '/*.conf') as $f) {
$files[] = basename($f);
}
}
return $files;
}
// Parse conf into key=>value map for $VAR substitution in docs
function vv_conf_vars(): array {
$vars = [];
$files = ['master.conf'];
$host = vv_detect_host();
if (preg_match('/^host\d+$/', $host)) $files[] = $host . '.conf';
foreach ($files as $f) {
$raw = vv_read_conf_raw($f);
// Match: VAR_NAME="value" or VAR_NAME=value (no quotes)
preg_match_all('/^\s*([A-Z0-9_]+)\s*=\s*["\']?([^"\'#\n]*?)["\']?\s*(?:#.*)?$/m', $raw, $m);
foreach ($m[1] as $i => $key) {
$vars[$key] = trim($m[2][$i]);
}
}
return $vars;
}
+99
View File
@@ -0,0 +1,99 @@
<?php
function vv_local_ip(): string {
static $ip = null;
if ($ip !== null) return $ip;
$ip = trim(shell_exec("ip route get 8.8.8.8 2>/dev/null | awk '/src/{for(i=1;i<=NF;i++)if(\$i==\"src\")print \$(i+1)}'") ?? '');
if (!$ip) $ip = gethostbyname(gethostname());
return $ip;
}
function vv_container_webui(string $name, array $portMap): string {
$template = '/boot/config/plugins/dockerMan/templates-user/my-' . $name . '.xml';
if (!file_exists($template)) return '';
$xml = @file_get_contents($template) ?: '';
if (!preg_match('/<WebUI>(.*?)<\/WebUI>/s', $xml, $m)) return '';
$url = trim($m[1]);
if (!$url) return '';
$url = str_replace('[IP]', vv_local_ip(), $url);
// [PORT:XXXX] → mapped host port
$url = preg_replace_callback('/\[PORT:(\d+)\]/', function($pm) use ($name, $portMap) {
return $portMap[$name][$pm[1]] ?? $pm[1];
}, $url);
return $url;
}
function vv_get_docker_folders(): array {
$folderFile = '/boot/config/plugins/folder.view3/docker.json';
$folderData = file_exists($folderFile)
? (json_decode(@file_get_contents($folderFile), true) ?: [])
: [];
// One docker ps call: names, status, port mappings
$raw = shell_exec("docker ps -a --format '{{.Names}}\t{{.Status}}\t{{.Ports}}' 2>/dev/null") ?? '';
$statusMap = [];
$portMap = [];
foreach (explode("\n", trim($raw)) as $line) {
$parts = explode("\t", $line, 3);
if (count($parts) < 2) continue;
[$cname, $status, $ports] = array_pad($parts, 3, '');
$cname = trim($cname);
if ($cname === '') continue;
$statusMap[$cname] = trim($status);
foreach (explode(',', $ports) as $entry) {
if (preg_match('/(\d+)->(\d+)\/tcp/', trim($entry), $pm)) {
$portMap[$cname][$pm[2]] = $pm[1]; // containerPort => hostPort
}
}
}
$folderContainerNames = [];
$folders = [];
foreach ($folderData as $id => $f) {
$containers = [];
foreach ($f['containers'] ?? [] as $cname) {
$folderContainerNames[] = $cname;
$status = $statusMap[$cname] ?? '';
$running = str_starts_with($status, 'Up');
$containers[] = [
'name' => $cname,
'running' => $running,
'status' => $status,
'webui' => vv_container_webui($cname, $portMap),
];
}
usort($containers, fn($a, $b) => $b['running'] <=> $a['running'] ?: strcmp($a['name'], $b['name']));
$folders[] = [
'id' => $id,
'name' => $f['name'] ?? 'Unnamed',
'icon' => $f['icon'] ?? '',
'containers' => $containers,
];
}
usort($folders, fn($a, $b) => strcmp($a['name'], $b['name']));
$ungrouped = [];
foreach ($statusMap as $cname => $status) {
if (in_array($cname, $folderContainerNames, true)) continue;
$running = str_starts_with($status, 'Up');
$ungrouped[] = [
'name' => $cname,
'running' => $running,
'status' => $status,
'webui' => vv_container_webui($cname, $portMap),
];
}
usort($ungrouped, fn($a, $b) => strcmp($a['name'], $b['name']));
return [
'available' => true,
'folders' => $folders,
'ungrouped' => $ungrouped,
];
}
+50
View File
@@ -0,0 +1,50 @@
<?php
// Docs — markdown file discovery, $VAR substitution, and rendering.
// Requires parsedown or similar. Falls back to <pre> if not available.
require_once __DIR__ . '/config.php';
define('PARSEDOWN_PATH', '/usr/local/emhttp/plugins/varaverk/lib/Parsedown.php');
function vv_docs_tree(): array {
$base = SCRIPTS_DIR;
$tree = [];
$files = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($base, FilesystemIterator::SKIP_DOTS),
RecursiveIteratorIterator::SELF_FIRST
);
foreach ($files as $f) {
if ($f->isFile() && strtolower($f->getExtension()) === 'md') {
$rel = ltrim(str_replace($base, '', $f->getPathname()), '/');
$tree[] = $rel;
}
}
sort($tree);
return $tree;
}
function vv_docs_render(string $rel, array $vars): string {
$path = SCRIPTS_DIR . '/' . $rel;
if (!file_exists($path)) return '<p>File not found.</p>';
$md = file_get_contents($path);
// Substitute `$VAR_NAME` markers with live conf values
$md = preg_replace_callback('/`\$([A-Z0-9_]+)`/', function($m) use ($vars) {
$key = $m[1];
return isset($vars[$key])
? '<code class="vv-live-var">' . htmlspecialchars($vars[$key]) . '</code>'
: '<code class="vv-unknown-var">$' . htmlspecialchars($key) . '</code>';
}, $md);
// Render markdown
if (file_exists(PARSEDOWN_PATH)) {
require_once PARSEDOWN_PATH;
$pd = new Parsedown();
$pd->setSafeMode(true);
return $pd->text($md);
}
// Fallback: plain preformatted text
return '<pre>' . htmlspecialchars($md) . '</pre>';
}
+184
View File
@@ -0,0 +1,184 @@
<?php
// Fallback tab data helpers
require_once __DIR__ . '/config.php';
require_once __DIR__ . '/partnership.php'; // vv_pt_ssh(), vv_pt_ts_peers()
// ── Conf parsers ──────────────────────────────────────────────────────────────
function vv_fb_bash_array(string $raw, string $varname): array {
if (!preg_match('/^\s*' . preg_quote($varname, '/') . '\s*=\s*\(\s*(.*?)\s*\)/ms', $raw, $m))
return [];
preg_match_all('/"([^"]*)"/', $m[1], $items);
return array_values(array_filter($items[1]));
}
function vv_fb_scalar(string $raw, string $varname): string {
return preg_match('/^\s*' . preg_quote($varname, '/') . '\s*=\s*"?([^"\n]*)"?/m', $raw, $m)
? trim($m[1]) : '';
}
// ── State file ────────────────────────────────────────────────────────────────
function vv_fb_parse_state(string $text): array {
$out = [
'state' => 'UNKNOWN',
'fallback_start' => 0,
'handback_strikes' => 0,
'tier2_started' => false,
'tier3_started' => false,
'tier4_started' => false,
];
foreach (explode("\n", $text) as $line) {
$line = trim($line);
if (!$line || !str_contains($line, '=')) continue;
[$k, $v] = array_pad(explode('=', $line, 2), 2, '');
$k = trim($k); $v = trim($v, '"\'');
switch ($k) {
case 'state': $out['state'] = $v; break;
case 'fallback_start': $out['fallback_start'] = (int)$v; break;
case 'handback_strikes': $out['handback_strikes'] = (int)$v; break;
case 'tier2_started': $out['tier2_started'] = $v === 'true'; break;
case 'tier3_started': $out['tier3_started'] = $v === 'true'; break;
case 'tier4_started': $out['tier4_started'] = $v === 'true'; break;
}
}
return $out;
}
function vv_fb_local_state(): array {
$path = '/boot/config/fallback_state.db';
return vv_fb_parse_state(file_exists($path) ? file_get_contents($path) : '');
}
function vv_fb_remote_state(string $ip, string $sshKey): array {
$out = vv_pt_ssh($ip, $sshKey, 'cat /boot/config/fallback_state.db 2>/dev/null');
return vv_fb_parse_state($out);
}
// ── Running containers ────────────────────────────────────────────────────────
function vv_fb_local_running(): array {
$out = shell_exec("docker ps --format '{{.Names}}' 2>/dev/null") ?: '';
return array_values(array_filter(explode("\n", trim($out))));
}
function vv_fb_remote_running(string $ip, string $sshKey): array {
$out = vv_pt_ssh($ip, $sshKey, "docker ps --format '{{.Names}}' 2>/dev/null");
return array_values(array_filter(explode("\n", trim($out))));
}
// ── Covers — what a node runs for the other when it's down ───────────────────
function vv_fb_covers(string $covering, string $remote, string $coveringRaw, string $remoteRaw): array {
$cu = strtoupper($covering); // HOST1
$ru = strtoupper($remote); // HOST2
$tiers = [];
for ($t = 1; $t <= 4; $t++) {
$tiers["tier$t"] = vv_fb_bash_array($coveringRaw, "FALLBACK_{$cu}_COVERS_{$ru}_TIER{$t}");
}
// Delays: how long the remote (covered) host must be down before each tier fires.
// Stored in the *remote* host's conf as REMOTE_TIER*_DELAY.
$tiers['delays'] = [
'tier2' => (int)(vv_fb_scalar($remoteRaw, "{$ru}_TIER2_DELAY") ?: 240),
'tier3' => (int)(vv_fb_scalar($remoteRaw, "{$ru}_TIER3_DELAY") ?: 720),
'tier4' => (int)(vv_fb_scalar($remoteRaw, "{$ru}_TIER4_DELAY") ?: 1440),
];
return $tiers;
}
// ── Known hosts ───────────────────────────────────────────────────────────────
function vv_fb_known_hosts(): array {
$master = vv_read_conf_raw('master.conf');
preg_match_all('/^\s*(HOST(\d+))(?:_NAME)?\s*=\s*["\']?(\S+?)["\']?\s*$/m', $master, $m);
$hosts = [];
foreach ($m[1] as $i => $key) {
$num = $m[2][$i];
$name = trim($m[3][$i]);
$hosts['host' . $num] = $name;
}
ksort($hosts);
return $hosts ?: ['host1' => 'HOST1'];
}
// ── Main data builder ─────────────────────────────────────────────────────────
function vv_fb_all(): array {
$currentHost = vv_detect_host();
$hosts = vv_fb_known_hosts();
$tsPeers = vv_pt_ts_peers();
$handbackReq = (int)(vv_fb_scalar(vv_read_conf_raw('master.conf'), 'FALLBACK_HANDBACK_STRIKES') ?: 3);
$fbEnabled = vv_fb_scalar(vv_read_conf_raw('master.conf'), 'FALLBACK_ENABLED') === 'true';
// Read all host conf raws upfront
$raws = [];
foreach (array_keys($hosts) as $slot) {
$raws[$slot] = vv_read_conf_raw($slot . '.conf');
}
// SSH key — from local host conf
$myId = strtoupper($currentHost);
$myRaw = $raws[$currentHost] ?? '';
$mySshKey = vv_fb_scalar($myRaw, $myId . '_SSH_KEY');
$nodes = [];
foreach ($hosts as $slot => $hostname) {
$isMe = ($slot === $currentHost || $currentHost === 'unknown');
$tsLabel = strtolower($hostname);
$ts = $tsPeers[$tsLabel] ?? ['online' => null, 'active' => false, 'ip' => null];
$ip = $ts['ip'] ?? null;
// State
if ($isMe) {
$state = vv_fb_local_state();
} elseif ($ip && $mySshKey) {
$state = vv_fb_remote_state($ip, $mySshKey);
} else {
$state = vv_fb_parse_state('');
$state['state'] = $ts['online'] === false ? 'OFFLINE' : 'UNREACHABLE';
}
// Running containers
if ($isMe) {
$running = vv_fb_local_running();
} elseif ($ip && $mySshKey && $ts['online']) {
$running = vv_fb_remote_running($ip, $mySshKey);
} else {
$running = [];
}
// Covers: for a 2-node setup, each covers the other
// For N nodes this would need a different approach — for now, assume 2-node
$covers = null;
foreach ($hosts as $otherSlot => $otherHostname) {
if ($otherSlot === $slot) continue;
$coveringRaw = $raws[$slot] ?? '';
$remoteRaw = $raws[$otherSlot] ?? '';
$covers = [
'slot' => $otherSlot,
'id' => strtoupper($otherSlot),
'hostname' => $otherHostname,
] + vv_fb_covers($slot, $otherSlot, $coveringRaw, $remoteRaw);
break; // 2-node only
}
$nodes[] = [
'slot' => $slot,
'id' => strtoupper($slot),
'hostname' => $hostname,
'is_me' => $isMe,
'ts_online' => $ts['online'],
'state' => $state,
'running' => $running,
'covers' => $covers,
];
}
return [
'ts' => time(),
'fb_enabled' => $fbEnabled,
'handback_req' => $handbackReq,
'nodes' => $nodes,
];
}
+197
View File
@@ -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),
];
}
+697
View File
@@ -0,0 +1,697 @@
<?php
require_once __DIR__ . '/config.php';
// 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 {
$out = shell_exec('docker ps --format \'{"name":"{{.Names}}","status":"{{.Status}}","image":"{{.Image}}"}\' 2>/dev/null');
$containers = [];
foreach (explode("\n", trim($out ?? '')) as $line) {
if (!$line) continue;
$c = json_decode($line, true);
if ($c) $containers[] = $c;
}
return $containers;
}
function vv_docker_stopped(): array {
$out = shell_exec('docker ps -a --filter "status=exited" --filter "status=created" --format \'{"name":"{{.Names}}","status":"{{.Status}}"}\' 2>/dev/null');
$containers = [];
foreach (explode("\n", trim($out ?? '')) as $line) {
if (!$line) continue;
$c = json_decode($line, true);
if ($c) $containers[] = $c;
}
return $containers;
}
function vv_gpu_stats(): array {
$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];
$parts = array_map('trim', explode(',', $out));
$power = is_numeric($parts[5] ?? '') ? round((float)$parts[5], 1) : null;
return [
'available' => true,
'name' => $parts[0] ?? '',
'memory_used' => (int)($parts[1] ?? 0),
'memory_total' => (int)($parts[2] ?? 0),
'utilization' => (int)($parts[3] ?? 0),
'temperature' => (int)($parts[4] ?? 0),
'power_w' => $power,
'enc_pct' => (int)($parts[6] ?? 0),
'dec_pct' => (int)($parts[7] ?? 0),
];
}
function vv_gpu_processes(): array {
$out = shell_exec('nvidia-smi --query-compute-apps=pid,used_gpu_memory,name --format=csv,noheader,nounits 2>/dev/null');
$procs = [];
foreach (explode("\n", trim($out ?? '')) as $line) {
if (!$line) continue;
$parts = array_map('trim', explode(',', $line));
$procs[] = [
'pid' => $parts[0] ?? '',
'memory_mb' => $parts[1] ?? '',
'name' => $parts[2] ?? '',
];
}
return $procs;
}
function vv_system_resources(): array {
$mem = [];
foreach (file('/proc/meminfo') ?: [] as $line) {
if (preg_match('/^(MemTotal|MemAvailable):\s+(\d+)/', $line, $m))
$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'),
];
}
function vv_cpu_per_core(): array {
// 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]];
}
$stateFile = '/tmp/vv_cpu_stat.json';
$prev = file_exists($stateFile) ? (json_decode(file_get_contents($stateFile), true) ?: []) : [];
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 [
'total_kb' => $totalKb,
'system_kb' => $systemKb,
'vm_kb' => $vmKb,
'zfs_kb' => $arcKb,
'docker_kb' => $dockerKb,
'free_kb' => $freeKb,
'top_procs' => $topProcs,
];
}
function vv_df(string $path): array {
$out = shell_exec("df -BM --output=size,used,avail '$path' 2>/dev/null | tail -1");
if (!$out) return ['available' => false, 'path' => $path];
$parts = preg_split('/\s+/', trim($out));
return [
'available' => true,
'path' => $path,
'size_mb' => (int)$parts[0],
'used_mb' => (int)$parts[1],
'free_mb' => (int)$parts[2],
];
}
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);
}
$hostIds = array_filter(array_keys($vars), fn($k) => preg_match('/^HOST\d+$/', $k) && ($vars[$k] ?? '') !== '');
sort($hostIds);
$hosts = [];
foreach ($hostIds 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 {
// State file written by fallback.sh
$stateFile = '/tmp/fallback_state.db';
if (!file_exists($stateFile)) return ['state' => 'UNKNOWN'];
$raw = [];
foreach (file($stateFile) ?: [] as $line) {
[$k, $v] = array_pad(explode('=', trim($line), 2), 2, '');
$raw[trim($k)] = trim($v);
}
return [
'state' => $raw['state'] ?? 'UNKNOWN',
'failover_start' => $raw['failover_start'] ?? '0',
'tier2_started' => $raw['tier2_started'] ?? 'false',
'tier3_started' => $raw['tier3_started'] ?? 'false',
'tier4_started' => $raw['tier4_started'] ?? 'false',
];
}
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
$allHostIds = array_filter(array_keys($vars), fn($k) => preg_match('/^HOST\d+$/', $k) && ($vars[$k] ?? '') !== '');
sort($allHostIds);
$myId = null;
foreach ($allHostIds 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 ($allHostIds 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 {
$stateFile = '/tmp/transcode_state.db';
if (!file_exists($stateFile)) return ['available' => false];
$raw = [];
foreach (file($stateFile) ?: [] as $line) {
[$k, $v] = array_pad(explode('=', trim($line), 2), 2, '');
$raw[trim($k)] = trim($v);
}
$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 {
$logDir = LOG_DIR;
$statFiles = array_merge(
glob("$logDir/*.json") ?: [],
glob("$logDir/*/*.json") ?: []
);
$scripts = [];
foreach ($statFiles as $statFile) {
$stat = json_decode(@file_get_contents($statFile) ?: '{}', true) ?: [];
$status = $stat['status'] ?? 'unknown';
// Stale running — PID gone (crash or reboot with no cleanup)
if ($status === 'running' && !empty($stat['pid'])) {
if (!file_exists("/proc/{$stat['pid']}")) $status = 'error';
}
$id = $stat['id'] ?? basename($statFile, '.json');
$name = basename(preg_replace('/\.sh$/', '', $id));
$ts = (int)($stat['end'] ?? $stat['start'] ?? @filemtime($statFile) ?: 0);
$scripts[] = [
'name' => $name,
'last_ts' => $ts,
'status' => $status,
'running' => $status === 'running',
'exit' => $stat['exit'] ?? null,
'duration' => isset($stat['start'], $stat['end'])
? (int)$stat['end'] - (int)$stat['start'] : null,
];
}
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')),
'error_count' => count(array_filter($scripts, fn($s) => $s['status'] === 'error')),
];
}
+178
View File
@@ -0,0 +1,178 @@
<?php
// Partnership page data helpers
require_once __DIR__ . '/config.php';
require_once __DIR__ . '/arrs.php'; // vv_arr_known_hosts(), vv_arr_scalar()
// ── Config ────────────────────────────────────────────────────────────────────
function vv_pt_config(): array {
$v = vv_conf_vars();
return [
'enabled' => ($v['PARTNERSHIP_ENABLED'] ?? 'false') === 'true',
'owner_host' => $v['PARTNERSHIP_OWNER_HOST'] ?? '',
'sync_min' => (int)($v['PARTNERSHIP_SYNC_INTERVAL'] ?? 15),
'grace_hours' => (int)($v['PARTNERSHIP_GRACE_HOURS'] ?? 6),
'offline_threshold' => (int)($v['PARTNERSHIP_OFFLINE_THRESHOLD'] ?? 30),
'remove_tailscale' => ($v['PARTNERSHIP_REMOVE_TAILSCALE'] ?? 'true') === 'true',
'folderview3' => ($v['PARTNERSHIP_FOLDERVIEW3'] ?? 'false') === 'true',
'tailscale_configured' => !empty($v['TAILSCALE_API_KEY']) && !empty($v['TAILSCALE_TAILNET']),
];
}
// ── State file parser ─────────────────────────────────────────────────────────
function vv_pt_read_db(string $path): array {
if (!file_exists($path)) return [];
$out = [];
foreach (file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
[$k, $v] = array_pad(explode('=', trim($line), 2), 2, '');
if ($k) $out[trim($k)] = trim($v, '"\'');
}
return $out;
}
// ── Tailscale peers ───────────────────────────────────────────────────────────
function vv_pt_ts_peers(): array {
$raw = shell_exec('tailscale status --json 2>/dev/null') ?: '{}';
$data = json_decode($raw, true) ?: [];
$peers = [];
// Self
$self = $data['Self'] ?? [];
$selfLabel = strtolower(explode('.', $self['DNSName'] ?? '')[0]);
if ($selfLabel) {
$peers[$selfLabel] = [
'online' => true,
'active' => true,
'ip' => $self['TailscaleIPs'][0] ?? null,
];
}
// Peers
foreach ($data['Peer'] ?? [] as $peer) {
$label = strtolower(explode('.', $peer['DNSName'] ?? '')[0]);
if (!$label) continue;
$peers[$label] = [
'online' => (bool)($peer['Online'] ?? false),
'active' => (bool)($peer['Active'] ?? false),
'ip' => $peer['TailscaleIPs'][0] ?? null,
];
}
return $peers;
}
// ── SSH helper — run a single command on a remote host ────────────────────────
function vv_pt_ssh(string $ip, string $sshKey, string $cmd, int $timeout = 4): string {
if (!$ip || !$sshKey || !file_exists($sshKey)) return '';
$full = sprintf(
'ssh -i %s -o ConnectTimeout=%d -o StrictHostKeyChecking=no -o BatchMode=yes root@%s %s 2>/dev/null',
escapeshellarg($sshKey), $timeout, escapeshellarg($ip), escapeshellarg($cmd)
);
return shell_exec($full) ?: '';
}
// ── System info ───────────────────────────────────────────────────────────────
function vv_pt_local_system(): array {
$ver = '';
if (file_exists('/etc/unraid-version')) {
preg_match('/VERSION="([^"]+)"/', file_get_contents('/etc/unraid-version'), $m);
$ver = $m[1] ?? '';
}
$uptime = 0;
if (file_exists('/proc/uptime')) {
$uptime = (int)explode(' ', file_get_contents('/proc/uptime'))[0];
}
return ['unraid_version' => $ver, 'uptime_sec' => $uptime];
}
function vv_pt_remote_system(string $ip, string $sshKey): array {
$out = vv_pt_ssh($ip, $sshKey,
'printf "%s\nUPTIME:%s\n" "$(cat /etc/unraid-version 2>/dev/null)" "$(cat /proc/uptime 2>/dev/null)"');
$ver = '';
preg_match('/VERSION="([^"]+)"/', $out, $m);
if ($m) $ver = $m[1];
$uptime = 0;
if (preg_match('/UPTIME:([\d.]+)/', $out, $m)) $uptime = (int)$m[1];
return ['unraid_version' => $ver, 'uptime_sec' => $uptime];
}
// ── Per-node data ─────────────────────────────────────────────────────────────
function vv_pt_nodes(): array {
$currentHost = vv_detect_host();
$hosts = vv_arr_known_hosts(); // ['host1' => 'hostname', ...]
$vars = vv_conf_vars();
$tsPeers = vv_pt_ts_peers();
$ownerSlot = strtolower($vars['PARTNERSHIP_OWNER_HOST'] ?? '');
// SSH key for this host
$myId = strtoupper($currentHost);
$myRaw = vv_read_conf_raw($currentHost . '.conf');
$mySshKey = vv_arr_scalar($myRaw, $myId . '_SSH_KEY');
$nodes = [];
foreach ($hosts as $slot => $hostname) {
$isMe = ($slot === $currentHost || $currentHost === 'unknown');
$isOwner = (strtolower($ownerSlot) === $slot);
// Tailscale
$tsLabel = strtolower($hostname);
$ts = $tsPeers[$tsLabel] ?? ['online' => null, 'active' => false, 'ip' => null];
// Fallback state
$fbState = 'UNKNOWN';
$fbPath = '/boot/config/fallback_state.db';
if ($isMe) {
$fb = vv_pt_read_db($fbPath);
$fbState = $fb['state'] ?? 'UNKNOWN';
} elseif ($ts['online'] && $ts['ip'] && $mySshKey) {
$out = vv_pt_ssh($ts['ip'], $mySshKey, 'cat /boot/config/fallback_state.db 2>/dev/null');
if ($out) {
$fb = [];
foreach (explode("\n", $out) as $line) {
[$k, $v] = array_pad(explode('=', trim($line), 2), 2, '');
if ($k) $fb[trim($k)] = trim($v, '"\'');
}
$fbState = $fb['state'] ?? 'UNKNOWN';
}
}
// Partnership DB — local only (each server writes its own)
$dbPath = "/boot/config/partnership_{$hostname}.db";
$ptDb = vv_pt_read_db($dbPath);
// System info
$system = $isMe
? vv_pt_local_system()
: ($ts['online'] && $ts['ip'] && $mySshKey ? vv_pt_remote_system($ts['ip'], $mySshKey) : []);
$nodes[] = [
'slot' => $slot,
'id' => strtoupper($slot),
'hostname' => $hostname,
'is_me' => $isMe,
'is_owner' => $isOwner,
'ts_online' => $ts['online'],
'ts_active' => $ts['active'],
'ts_ip' => $ts['ip'],
'fallback' => $fbState,
'partnership' => $ptDb,
'system' => $system,
];
}
return $nodes;
}
// ── Entry point ───────────────────────────────────────────────────────────────
function vv_partnership_all(): array {
return [
'config' => vv_pt_config(),
'nodes' => vv_pt_nodes(),
'ts' => time(),
];
}
+600
View File
@@ -0,0 +1,600 @@
<?php
// Scheduler — manages schedule.json and the Unraid plugin cron file.
// 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', '/boot/config/plugins/varaverk/varaverk.cron');
function vv_pretty_label(string $slug): string {
return ucwords(str_replace('_', ' ', $slug));
}
function vv_schedule_load(): array {
if (!file_exists(SCHEDULE_FILE)) return [];
$data = json_decode(file_get_contents(SCHEDULE_FILE), true);
return is_array($data) ? $data : [];
}
function vv_schedule_save(array $schedule): bool {
$dir = dirname(SCHEDULE_FILE);
if (!is_dir($dir)) mkdir($dir, 0755, true);
return file_put_contents(SCHEDULE_FILE, json_encode($schedule, JSON_PRETTY_PRINT)) !== false;
}
function vv_schedule_update(string $id, bool $enabled, string $cron, bool $log_enabled = false): bool {
$schedule = vv_schedule_load();
$schedule[$id] = [
'id' => $id,
'enabled' => $enabled,
'cron' => $cron,
'log_enabled' => $log_enabled,
'updated' => date('c'),
];
if (!vv_schedule_save($schedule)) return false;
return vv_cron_rebuild($schedule);
}
function vv_schedule_update_batch(array $entries): bool {
$schedule = vv_schedule_load();
foreach ($entries as $e) {
$id = trim($e['id'] ?? '');
if (!$id) continue;
$schedule[$id] = [
'id' => $id,
'enabled' => (bool)($e['enabled'] ?? false),
'cron' => trim($e['cron'] ?? ''),
'log_enabled' => (bool)($e['log_enabled'] ?? false),
'updated' => date('c'),
];
}
if (!vv_schedule_save($schedule)) return false;
return vv_cron_rebuild($schedule);
}
function vv_job_flags(string $id): string {
$schedule = vv_schedule_load();
return !empty($schedule[$id]['log_enabled']) ? '--log' : '';
}
function vv_job_log_path(string $id): string {
return LOG_DIR . '/' . preg_replace('/\.sh$/', '.log', $id);
}
function vv_job_stat_path(string $id): string {
return LOG_DIR . '/' . preg_replace('/\.sh$/', '.json', $id);
}
function vv_cron_rebuild(array $schedule): bool {
if (!is_dir(LOG_DIR)) mkdir(LOG_DIR, 0755, true);
$runner = dirname(__DIR__) . '/run_job.sh';
$lines = ["# Varaverk — managed by plugin, do not edit manually"];
$lines[] = "# Regenerated: " . date('Y-m-d H:i:s');
$lines[] = "";
// Build child→orch map so we can suppress a child's independent cron when its orch is enabled.
$childToOrch = [];
$confRaw = file_get_contents(CONF_DIR . '/master.conf') ?: '';
foreach (glob(SCRIPTS_DIR . '/Orchestrators/*.sh') ?: [] as $orchPath) {
$orchId = 'Orchestrators/' . basename($orchPath);
$content = file_get_contents($orchPath) ?: '';
preg_match_all('/\$[A-Z_]+\/(?:\.\.\/)?([A-Za-z][A-Za-z0-9_.\-]*\/[A-Za-z0-9_.\-]+\.sh)/', $content, $m1);
foreach ($m1[1] as $rel) $childToOrch[$rel] = $orchId;
preg_match_all('/\$\{([A-Z_]+_SCRIPTS)\[@\]\}/', $content, $refs);
foreach (array_unique($refs[1] ?? []) as $var) {
foreach (vv_parse_conf_array_full($confRaw, $var) as $item) $childToOrch[$item['path']] = $orchId;
}
}
$scriptsDir = SCRIPTS_DIR;
foreach ($schedule as $entry) {
if (empty($entry['enabled']) || empty($entry['cron']) || empty($entry['id'])) continue;
// Event-triggered jobs are handled by static event scripts, not cron.
if (str_starts_with($entry['cron'], 'array_')) continue;
$id = $entry['id'];
// When a child's orch is enabled it is the sole trigger — suppress independent cron.
if (isset($childToOrch[$id]) && !empty($schedule[$childToOrch[$id]]['enabled'])) continue;
$script = "$scriptsDir/$id";
$flags = !empty($entry['log_enabled']) ? ' --log' : '';
$lines[] = "{$entry['cron']} bash \"$runner\" \"$id\" \"$script\"$flags";
}
$lines[] = "";
// Standalone rsync entries: fire when orch is disabled but location + cron are both configured.
$scriptsDir = SCRIPTS_DIR;
foreach ($schedule as $key => $entry) {
if (!str_starts_with((string)$key, '__rsync_')) continue;
$orchId = $entry['orch_id'] ?? '';
$location = $entry['location'] ?? '';
$cron = $entry['cron'] ?? '';
if (!$orchId || !$location || !$cron) continue;
// Skip if orch is still enabled
if (!empty($schedule[$orchId]['enabled'])) continue;
$rsyncScript = "$scriptsDir/Rsync/rsync.sh";
if (!file_exists($rsyncScript)) continue;
$locArg = escapeshellarg('--location=' . $location);
$logFlag = !empty($entry['log_enabled']) ? ' --log' : '';
$lines[] = "$cron bash \"$runner\" \"Rsync/rsync.sh\" \"$rsyncScript\" $locArg$logFlag";
}
$lines[] = "";
// Write to the plugin cron file; update_cron merges all plugin *.cron files into /etc/cron.d/root.
if (file_put_contents(CRON_FILE, implode("\n", $lines)) === false) return false;
exec('/usr/local/sbin/update_cron');
// Remove legacy direct cron file left from before update_cron migration — prevents duplicate job firing.
@unlink('/etc/cron.d/varaverk');
return true;
}
// Extract the suggested cron and label from a bash script header.
// Looks for: # Schedule: */7 * * * * (every 7 minutes via User Scripts plugin)
function vv_script_suggested_cron(string $path): array {
if (!file_exists($path)) return ['cron' => '', 'label' => ''];
$lines = array_slice(file($path) ?: [], 0, 30);
foreach ($lines as $raw) {
$raw = rtrim($raw);
if (!preg_match('/^#\s*Schedule:\s*(.+)$/i', $raw, $m)) continue;
$tail = trim($m[1]);
$parts = preg_split('/\s+/', $tail, 6);
$cron = implode(' ', array_slice($parts, 0, 5));
$label = isset($parts[5]) ? trim($parts[5], '() ') : '';
return ['cron' => $cron, 'label' => $label];
}
return ['cron' => '', 'label' => ''];
}
// Parse user_script_plug-in.sh into an array of script blocks.
// Each block: title, schedule, desc (array of lines), scripts (array of {rel, cron})
function vv_parse_user_script_template(): array {
$file = SCRIPTS_DIR . '/user_script_plug-in.sh';
if (!file_exists($file)) return [];
$lines = file($file, FILE_IGNORE_NEW_LINES);
$prefix = rtrim(SCRIPTS_DIR, '/') . '/';
$blocks = [];
$cur = null;
foreach ($lines as $line) {
// Block header: # ── TITLE ──...
if (preg_match('/^# ── (.+?) ─/', $line, $m)) {
if ($cur) $blocks[] = $cur;
$cur = ['title' => trim($m[1]), 'schedule' => '', 'desc' => [], 'scripts' => []];
continue;
}
if (!$cur) continue;
// Schedule / Background — captured but not added to desc
if (preg_match('/^# (Schedule|Background):\s+(.+)$/i', $line, $m)) {
if (strtolower($m[1]) === 'schedule') $cur['schedule'] = trim($m[2]);
continue;
}
// Sunday block inline cron: # 0 6 * * 0 bash /path/script.sh [args]
if (preg_match('/^#\s+(\S+ +\S+ +\S+ +\S+ +\S+)\s+bash\s+(\S+\.sh)/', $line, $m)) {
$rel = str_replace($prefix, '', trim($m[2]));
$cur['scripts'][] = ['rel' => $rel, 'cron' => preg_replace('/\s+/', ' ', trim($m[1]))];
continue;
}
// Standard bash line: # bash /prefix/path/script.sh [args]
if (preg_match('/^# bash\s+(\S+\.sh)/', $line, $m)) {
$rel = str_replace($prefix, '', $m[1]);
// Only add primary command (dedupe by rel)
$rels = array_column($cur['scripts'], 'rel');
if (!in_array($rel, $rels)) {
$cur['scripts'][] = ['rel' => $rel, 'cron' => ''];
}
continue;
}
// Description line
if (preg_match('/^# ?(.*)$/', $line, $m)) {
$inner = $m[1];
if (!preg_match('/^[─━=\-]{3,}\s*$/', $inner) && !preg_match('/^█/', $inner)) {
$cur['desc'][] = $inner;
}
}
}
if ($cur) $blocks[] = $cur;
return $blocks;
}
// Extract a one-line description from a bash script header.
// Supports two patterns:
// 1. # PURPOSE (or # DESCRIPTION) block — returns first non-separator line after it
// 2. First meaningful comment line after the banner
function vv_script_description(string $path): string {
if (!file_exists($path)) return '';
$lines = array_slice(file($path) ?: [], 0, 50);
$purposeNext = false;
$first = '';
foreach ($lines as $raw) {
$raw = rtrim($raw);
if (str_starts_with($raw, '#!')) continue; // shebang
if (!str_starts_with($raw, '#')) continue; // non-comment
$inner = ltrim(substr($raw, 1)); // strip leading #
if ($inner === '') continue; // blank
if (preg_match('/^[\s=\-─━\*]+$/', $inner)) continue; // pure separator
if (preg_match('/^={3,}/', $inner)) continue; // banner (=== Title ===)
if (preg_match('/^\s*(PURPOSE|DESCRIPTION|Description)\s*$/i', $inner)) {
$purposeNext = true;
continue;
}
if ($purposeNext) {
return mb_substr(trim($inner), 0, 200);
}
if (!$first) $first = mb_substr(trim($inner), 0, 200);
}
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' => vv_pretty_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;
}
// Load rsync standalone config (location + cron) for a flag name from schedule.json.
function vv_rsync_standalone(string $flagName): array {
$s = vv_schedule_load();
$r = $s['__rsync_' . $flagName] ?? [];
return [
'location' => (string)($r['location'] ?? ''),
'cron' => (string)($r['cron'] ?? ''),
];
}
// Extract *_SCRIPTS array variable names that an orchestrator iterates over.
function vv_orch_conf_arrays(string $orchPath): array {
$content = file_get_contents($orchPath) ?: '';
preg_match_all('/\$\{([A-Z_]+_SCRIPTS)\[@\]\}/', $content, $refs);
return array_unique($refs[1] ?? []);
}
// Return .sh scripts that exist in SCRIPTS_DIR but are not referenced in any
// master.conf *_SCRIPTS array and are not orchestrators or custom scripts.
function vv_script_library(): array {
$scriptsDir = SCRIPTS_DIR;
$confMap = vv_conf_script_map();
$orchIds = [];
foreach (glob("$scriptsDir/Orchestrators/*.sh") ?: [] as $p) {
$orchIds[] = 'Orchestrators/' . basename($p);
}
$exclude = ['Plugin', '.git', 'Orchestrators', 'Custom', 'Configurations'];
$library = [];
try {
$ri = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($scriptsDir, RecursiveDirectoryIterator::SKIP_DOTS)
);
$base = rtrim($scriptsDir, '/') . '/';
foreach ($ri as $rf) {
if (!$rf->isFile() || strtolower($rf->getExtension()) !== 'sh') continue;
$rel = ltrim(str_replace($base, '', $rf->getPathname()), '/');
$parts = explode('/', $rel);
if (count($parts) < 2 || in_array($parts[0], $exclude)) continue;
if (in_array($rel, $orchIds) || isset($confMap[$rel])) continue;
$library[] = ['id' => $rel, 'label' => vv_pretty_label(basename($rel, '.sh'))];
}
} catch (Exception $e) {}
usort($library, fn($a, $b) => strcmp($a['id'], $b['id']));
return $library;
}
// Load custom-script folder assignments from schedule.json (__folders key).
function vv_folders_load(): array {
$s = vv_schedule_load();
$f = $s['__folders'] ?? [];
return is_array($f) ? $f : [];
}
// Walk the scripts repo and return the job tree.
// Type is derived from the saved cron value: array_start / array_stop → 'event', else 'orchestrator'.
// Well-known event orchs get their cron seeded from $eventDefaults when not yet in schedule.json.
// Any orch or custom script can carry array_start / array_stop as its cron — the event scripts fire all of them.
function vv_job_tree(): array {
$scriptsDir = SCRIPTS_DIR;
$schedule = vv_schedule_load();
// First-run seeds only — applied when schedule.json has no entry for these IDs yet.
$eventDefaults = [
'Orchestrators/array_started.sh' => 'array_start',
'Orchestrators/array_stopping.sh' => 'array_stop',
];
$orchs = [];
foreach (glob("$scriptsDir/Orchestrators/*.sh") ?: [] as $path) {
$id = 'Orchestrators/' . basename($path);
$default = $eventDefaults[$id] ?? '';
$entry = $schedule[$id] ?? ['enabled' => false, 'cron' => $default];
$cron = $entry['cron'] ?? $default;
$isEvent = str_starts_with($cron, 'array_');
$suggested = $isEvent ? ['cron' => '', 'label' => ''] : vv_script_suggested_cron($path);
$orchs[] = [
'id' => $id,
'label' => vv_pretty_label(basename($path, '.sh')),
'desc' => vv_script_description($path),
'type' => $isEvent ? 'event' : 'orchestrator',
'enabled' => (bool)($entry['enabled'] ?? false),
'cron' => $cron,
'log_enabled' => (bool)($entry['log_enabled'] ?? false),
'suggested_cron' => $suggested['cron'],
'suggested_label' => $suggested['label'],
'children' => vv_script_children($path, $schedule),
'conf_arrays' => vv_orch_conf_arrays($path),
];
}
// Events first (array_start before array_stop), then alphabetical by label.
usort($orchs, function($a, $b) {
$ae = $a['type'] === 'event' ? 0 : 1;
$be = $b['type'] === 'event' ? 0 : 1;
if ($ae !== $be) return $ae - $be;
if ($ae === 0) {
$as = str_contains($a['cron'], 'start') ? 0 : 1;
$bs = str_contains($b['cron'], 'start') ? 0 : 1;
if ($as !== $bs) return $as - $bs;
}
return strcmp($a['label'], $b['label']);
});
return $orchs;
}
// Parse a bash array from master.conf content and return its script paths.
// Handles entries with inline args ("script.sh --flag") and skips commented lines (#"...").
function vv_parse_conf_array(string $conf, string $varName): array {
if (!preg_match('/^\s*' . preg_quote($varName, '/') . '\s*=\s*\((.*?)^\s*\)/ms', $conf, $m)) {
return [];
}
$scripts = [];
preg_match_all('/^\s*(?!#)"([^"]+)"/m', $m[1], $entries);
foreach ($entries[1] as $entry) {
$parts = preg_split('/\s+/', trim($entry));
$path = $parts[0] ?? '';
if (substr($path, -3) === '.sh') $scripts[] = $path;
}
return $scripts;
}
// Like vv_parse_conf_array but includes commented entries.
// Returns array of ['path' => string, 'enabled' => bool].
function vv_parse_conf_array_full(string $conf, string $varName): array {
if (!preg_match('/^\s*' . preg_quote($varName, '/') . '\s*=\s*\((.*?)^\s*\)/ms', $conf, $m)) {
return [];
}
$results = [];
foreach (explode("\n", $m[1]) as $line) {
if (!preg_match('/^\s*(#\s*)?"([^"]+)"/', $line, $e)) continue;
$commented = trim($e[1]) !== '';
$parts = preg_split('/\s+/', trim($e[2]));
$path = $parts[0] ?? '';
if (substr($path, -3) !== '.sh') continue;
$results[] = ['path' => $path, 'enabled' => !$commented];
}
return $results;
}
// Build a map of script rel-path → conf status by scanning all *_SCRIPTS arrays in master.conf.
// Cached per-request so multiple callers only read the file once.
function vv_conf_script_map(): array {
static $cache = null;
if ($cache !== null) return $cache;
$confPath = CONF_DIR . '/master.conf';
if (!file_exists($confPath)) return $cache = [];
$lines = file($confPath, FILE_IGNORE_NEW_LINES) ?: [];
$map = [];
$inArray = false;
$arrayVar = '';
foreach ($lines as $line) {
if (preg_match('/^\s*([A-Z_]+_SCRIPTS)\s*=\s*\(/', $line, $am)) { $inArray = true; $arrayVar = $am[1]; }
if ($inArray && preg_match('/^\s*\)\s*(?:#.*)?$/', $line) && !str_contains($line, '(')) $inArray = false;
if (!$inArray) continue;
if (!preg_match('/^\s*(#\s*)?"([^"]+)"/', $line, $e)) continue;
$commented = trim($e[1]) !== '';
$parts = preg_split('/\s+/', trim($e[2]));
$path = $parts[0] ?? '';
if (substr($path, -3) !== '.sh') continue;
if (!isset($map[$path])) $map[$path] = ['array' => $arrayVar, 'enabled' => !$commented, 'managed' => true];
}
return $cache = $map;
}
// Read a boolean flag value (e.g. INTERMEDIATE_RSYNC_ENABLED) from master.conf.
function vv_conf_flag_value(string $name): bool {
$conf = file_get_contents(CONF_DIR . '/master.conf') ?: '';
if (preg_match('/^\s*' . preg_quote($name, '/') . '\s*=\s*(true|false)\s*$/m', $conf, $m)) {
return $m[1] === 'true';
}
return false;
}
// Write a boolean flag value to master.conf.
function vv_conf_flag_set(string $name, bool $value): bool {
$confPath = CONF_DIR . '/master.conf';
$content = file_get_contents($confPath);
if ($content === false) return false;
$val = $value ? 'true' : 'false';
$new = preg_replace(
'/^(\s*' . preg_quote($name, '/') . '\s*=\s*)(true|false)(\s*(?:#.*)?)$/m',
'${1}' . $val . '${3}',
$content, -1, $count
);
if (!$count) return false;
return file_put_contents($confPath, $new) !== false;
}
// Comment or uncomment a script's line in the first master.conf array that contains it.
function vv_conf_toggle_script(string $rel, bool $enable): bool {
$confPath = CONF_DIR . '/master.conf';
$lines = file($confPath, FILE_KEEP_BLANK_LINES);
if (!$lines) return false;
$changed = false;
$inArray = false;
$relEsc = preg_quote($rel, '/');
foreach ($lines as &$line) {
if (preg_match('/^\s*[A-Z_]+_SCRIPTS\s*=\s*\(/', $line)) $inArray = true;
if ($inArray && preg_match('/^\s*\)\s*(?:#.*)?$/', $line) && !str_contains($line, '(')) $inArray = false;
if (!$inArray) continue;
if (!preg_match('/^\s*(?:#\s*)?"' . $relEsc . '(?:\s[^"]*)?"/', $line)) continue;
$isCommented = (bool)preg_match('/^\s*#/', $line);
if ($enable && $isCommented) {
$line = preg_replace('/^(\s*)#\s*("' . $relEsc . ')/', '$1$2', $line);
$changed = true;
} elseif (!$enable && !$isCommented) {
$line = preg_replace('/^(\s*)("' . $relEsc . ')/', '$1# $2', $line);
$changed = true;
}
break;
}
unset($line);
if (!$changed) return true;
return file_put_contents($confPath, implode('', $lines)) !== false;
}
// Parse an orchestrator script to find which child scripts it calls.
// Two strategies, merged and deduped:
// 1. Static paths: $SCRIPT_DIR/../Category/script.sh or $SCRIPTS_ROOT/Category/script.sh
// 2. master.conf arrays: detects ${VARNAME[@]} iteration and reads the array from master.conf
// Root-level files (load_config.sh etc.) excluded — must be in a subdirectory.
// Each candidate validated against the filesystem.
function vv_script_children(string $orchPath, array $schedule): array {
$scriptsDir = SCRIPTS_DIR;
$content = file_get_contents($orchPath) ?: '';
$children = [];
$seen = [];
$confMap = vv_conf_script_map();
// Detect which tier rsync flag this orch controls (e.g. "INTERMEDIATE" → INTERMEDIATE_RSYNC_ENABLED)
$rsyncFlagName = null;
if (preg_match('/check_rsync_enabled\s+"([A-Z]+)"/', $content, $rm)) {
$rsyncFlagName = $rm[1] . '_RSYNC_ENABLED';
}
$addChild = function(string $rel) use ($scriptsDir, $schedule, $confMap, &$children, &$seen) {
if (isset($seen[$rel]) || !file_exists("$scriptsDir/$rel")) return;
$seen[$rel] = true;
$entry = $schedule[$rel] ?? ['enabled' => false, 'cron' => ''];
$conf = $confMap[$rel] ?? ['array' => null, 'enabled' => null, 'managed' => false];
$suggested = vv_script_suggested_cron("$scriptsDir/$rel");
$children[] = [
'id' => $rel,
'label' => vv_pretty_label(basename($rel, '.sh')),
'desc' => vv_script_description("$scriptsDir/$rel"),
'type' => 'script',
'enabled' => (bool)($entry['enabled'] ?? false),
'cron' => $entry['cron'] ?? '',
'log_enabled' => (bool)($entry['log_enabled'] ?? false),
'conf_managed' => $conf['managed'],
'conf_enabled' => $conf['enabled'], // null if not in any *_SCRIPTS array
'conf_array' => $conf['array'],
'suggested_cron' => $suggested['cron'],
'suggested_label' => $suggested['label'],
];
};
// Strategy 1: static variable paths ($VAR/../Category/script.sh or $VAR/Category/script.sh)
preg_match_all(
'/\$[A-Z_]+\/(?:\.\.\/)?([A-Za-z][A-Za-z0-9_.\-]*\/[A-Za-z0-9_.\-]+\.sh)/',
$content, $m
);
foreach ($m[1] as $rel) $addChild($rel);
// Strategy 2: master.conf arrays — includes commented (disabled) entries so they appear in the UI
preg_match_all('/\$\{([A-Z_]+_SCRIPTS)\[@\]\}/', $content, $refs);
if (!empty($refs[1])) {
$confRaw = file_get_contents(CONF_DIR . '/master.conf') ?: '';
foreach (array_unique($refs[1]) as $varName) {
foreach (vv_parse_conf_array_full($confRaw, $varName) as $item) $addChild($item['path']);
}
}
// Annotate Rsync/rsync.sh as a conf_flag child if this orch controls a rsync tier flag
if ($rsyncFlagName) {
foreach ($children as &$c) {
if ($c['id'] === 'Rsync/rsync.sh') {
$c['type'] = 'conf_flag';
$c['flag_name'] = $rsyncFlagName;
$c['flag_value'] = vv_conf_flag_value($rsyncFlagName);
break;
}
}
unset($c);
}
return $children;
}
// Extract the comment header block from a bash script (shebang + all leading comment lines).
// Returns raw lines with # markers intact.
function vv_script_header(string $path): string {
if (!file_exists($path)) return '';
$lines = array_slice(file($path) ?: [], 0, 80);
$out = [];
foreach ($lines as $line) {
$t = rtrim($line);
if (str_starts_with($t, '#') || ($out === [] && str_starts_with($t, '#!'))) {
$out[] = $t;
} elseif ($t === '' && !empty($out)) {
$out[] = $t; // allow blank lines within header
} else {
break;
}
}
// Trim trailing blank lines
while (!empty($out) && trim(end($out)) === '') array_pop($out);
return implode("\n", $out);
}
// Strip the leading # marker from each line of a script header for cleaner display.
// Also drops the shebang line (#!/bin/bash) since it's not informative in this context.
function vv_script_header_clean(string $path): string {
$raw = vv_script_header($path);
if (!$raw) return '';
$lines = explode("\n", $raw);
$out = [];
foreach ($lines as $line) {
if (str_starts_with($line, '#!')) continue; // shebang — not useful in header display
$out[] = preg_replace('/^#\s?/', '', $line); // strip # and optional space
}
while (!empty($out) && trim(end($out)) === '') array_pop($out);
return implode("\n", $out);
}
// Read a named section from a markdown file.
// Calls $matcher(heading, isIntro) where isIntro=true for content before the first heading.
// Returns the first matching section body, capped at $maxChars.
function vv_readme_section(string $readmePath, callable $matcher, int $maxChars = 3000): string {
if (!file_exists($readmePath)) return '';
$content = file_get_contents($readmePath) ?: '';
$parts = preg_split('/^(#{1,4}[^\n]*)/m', $content, -1, PREG_SPLIT_DELIM_CAPTURE);
$heading = '';
$isIntro = true;
foreach ($parts as $i => $part) {
if ($i % 2 === 1) {
$heading = trim(preg_replace('/^#{1,4}\s*/', '', $part));
$isIntro = false;
continue;
}
$body = trim($part);
if ($body === '') continue;
if ($matcher($heading, $isIntro)) {
return strlen($body) > $maxChars ? substr($body, 0, $maxChars) . "\n[…]" : $body;
}
}
return '';
}
+46
View File
@@ -0,0 +1,46 @@
<?php
function vv_get_vms(): array {
if (!file_exists('/usr/bin/virsh')) return ['available' => false, 'vms' => []];
exec('virsh list --all --name 2>/dev/null', $names, $rc);
if ($rc !== 0) return ['available' => false, 'vms' => []];
$vms = [];
foreach ($names as $raw) {
$name = trim($raw);
if ($name === '') continue;
$state = trim(shell_exec('virsh domstate ' . escapeshellarg($name) . ' 2>/dev/null') ?? 'unknown');
$vcpus = null;
$memMb = null;
if ($state === 'running') {
$info = shell_exec('virsh dominfo ' . escapeshellarg($name) . ' 2>/dev/null') ?? '';
if (preg_match('/CPU\(s\)\s*:\s*(\d+)/i', $info, $m)) $vcpus = (int)$m[1];
if (preg_match('/Used memory\s*:\s*(\d+)/i', $info, $m)) $memMb = (int)round((int)$m[1] / 1024);
}
// OS detection from libvirt XML
$os = 'linux';
$xmlPath = '/etc/libvirt/qemu/' . $name . '.xml';
if (file_exists($xmlPath)) {
$xml = @file_get_contents($xmlPath) ?: '';
if (stripos($xml, 'windows') !== false || stripos($xml, 'win10') !== false || stripos($xml, 'win11') !== false) $os = 'windows';
elseif (stripos($xml, 'darwin') !== false || stripos($xml, 'macos') !== false) $os = 'macos';
}
$nl = strtolower($name);
if (str_contains($nl, 'win')) $os = 'windows';
elseif (str_contains($nl, 'mac') || str_contains($nl, 'osx')) $os = 'macos';
elseif (str_contains($nl, 'bsd') || str_contains($nl, 'freebsd')) $os = 'bsd';
$vms[] = [
'name' => $name,
'state' => $state,
'os' => $os,
'vcpus' => $vcpus,
'mem_mb' => $memMb,
];
}
return ['available' => true, 'vms' => $vms];
}
+341
View File
@@ -0,0 +1,341 @@
<?php
// Watchdog tab data helpers
require_once __DIR__ . '/config.php';
require_once __DIR__ . '/partnership.php'; // vv_pt_ssh(), vv_pt_ts_peers()
// ── Conf array parser (bash arrays) ──────────────────────────────────────────
function vv_wd_bash_array(string $raw, string $varname): array {
if (!preg_match('/^\s*' . preg_quote($varname, '/') . '\s*=\s*\(\s*(.*?)\s*\)/ms', $raw, $m))
return [];
preg_match_all('/"([^"]*)"/', $m[1], $items);
return array_values(array_filter($items[1]));
}
function vv_wd_bash_assoc(string $raw, string $varname): array {
// declare -A VARNAME=( ["key"]=val ["key2"]=val2 )
if (!preg_match('/^\s*declare\s+-A\s+' . preg_quote($varname, '/') . '\s*=\s*\(\s*(.*?)\s*\)/ms', $raw, $m))
return [];
preg_match_all('/\["([^"]+)"\]\s*=\s*"?([^"\s\)]*)"?/', $m[1], $pairs);
$out = [];
foreach ($pairs[1] as $i => $k) $out[$k] = $pairs[2][$i];
return $out;
}
function vv_wd_scalar(string $raw, string $varname): string {
return preg_match('/^\s*' . preg_quote($varname, '/') . '\s*=\s*"?([^"\n]*)"?/m', $raw, $m)
? trim($m[1]) : '';
}
// ── State file parsers ────────────────────────────────────────────────────────
// Parses key:value format (with optional key=value mixed in)
function vv_wd_parse_kv(string $text): array {
$out = [];
foreach (explode("\n", $text) as $line) {
$line = trim($line);
if (!$line) continue;
if (str_contains($line, ':')) {
[$k, $v] = explode(':', $line, 2);
$out[trim($k)] = trim($v);
} elseif (str_contains($line, '=')) {
[$k, $v] = explode('=', $line, 2);
$out[trim($k)] = trim($v, '"\'');
}
}
return $out;
}
// Restart log: "container|timestamp" one per line
function vv_wd_parse_restart_log(string $text, int $windowSeconds = 86400): array {
$now = time();
$cutoff = $now - $windowSeconds;
$entries = [];
foreach (explode("\n", trim($text)) as $line) {
$line = trim($line);
if (!$line || !str_contains($line, '|')) continue;
[$name, $ts] = explode('|', $line, 2);
$ts = (int)$ts;
if ($ts >= $cutoff) $entries[] = ['name' => trim($name), 'ts' => $ts];
}
usort($entries, fn($a, $b) => $b['ts'] - $a['ts']);
return $entries;
}
// Skip list: one container name per line
function vv_wd_parse_skiplist(string $text): array {
return array_values(array_filter(array_map('trim', explode("\n", $text))));
}
// Reboot log: one timestamp per line
function vv_wd_parse_reboot_log(string $text, int $windowHrs = 12): array {
$cutoff = time() - ($windowHrs * 3600);
$entries = [];
foreach (explode("\n", trim($text)) as $line) {
$ts = (int)trim($line);
if ($ts > 0 && $ts >= $cutoff) $entries[] = $ts;
}
rsort($entries);
return $entries;
}
// ── Local system snapshot ─────────────────────────────────────────────────────
function vv_wd_local_system(): array {
// RAM
$memRaw = file_exists('/proc/meminfo') ? file_get_contents('/proc/meminfo') : '';
$memTotal = 0; $memAvail = 0;
if (preg_match('/^MemTotal:\s+(\d+)/m', $memRaw, $m)) $memTotal = (int)$m[1] * 1024;
if (preg_match('/^MemAvailable:\s+(\d+)/m', $memRaw, $m)) $memAvail = (int)$m[1] * 1024;
// Load + cores
$loadRaw = file_exists('/proc/loadavg') ? file_get_contents('/proc/loadavg') : '0 0 0';
$loadParts = explode(' ', trim($loadRaw));
$load1 = (float)($loadParts[0] ?? 0);
$cores = (int)(trim(shell_exec('nproc 2>/dev/null') ?: '1'));
// Uptime
$uptimeRaw = file_exists('/proc/uptime') ? file_get_contents('/proc/uptime') : '0';
$uptime = (int)explode(' ', $uptimeRaw)[0];
// Docker daemon alive
$daemonOk = (trim(shell_exec('docker info >/dev/null 2>&1; echo $?') ?: '1') === '0');
// OOM count (from stability watchdog OOM file — just the prev cycle count)
$oomFile = '/tmp/system_watchdog_oom.db';
$oomCount = file_exists($oomFile) ? (int)trim(file_get_contents($oomFile)) : 0;
return [
'mem_total' => $memTotal,
'mem_avail' => $memAvail,
'load1' => $load1,
'cores' => $cores,
'uptime' => $uptime,
'daemon_ok' => $daemonOk,
'oom_count' => $oomCount,
];
}
// ── Local state files ─────────────────────────────────────────────────────────
function vv_wd_local_states(string $restartLogPath): array {
$rwRaw = @file_get_contents('/tmp/resource_watchdog_state.db') ?: '';
$dockRaw = @file_get_contents('/tmp/container_watchdog_state.db') ?: '';
$skipRaw = @file_get_contents('/boot/config/system_watchdog_failed.db') ?: '';
$sysRaw = @file_get_contents('/tmp/system_watchdog_state.db') ?: '';
$rebootRaw = @file_get_contents('/boot/config/system_watchdog_reboots.db')?: '';
$restartRaw= @file_get_contents($restartLogPath) ?: '';
$rw = vv_wd_parse_kv($rwRaw);
$dock = vv_wd_parse_kv($dockRaw);
$sys = vv_wd_parse_kv($sysRaw);
// Container strikes: everything in docker state that isn't a flag
$strikes = [];
foreach ($dock as $k => $v) {
if ($k !== 'daemon_strikes' && $k !== 'daemon_restarted_flag' && (int)$v > 0)
$strikes[$k] = (int)$v;
}
// Stability strikes: everything in sys state that isn't a flag key
$sysStrikes = [];
foreach ($sys as $k => $v) {
if (!str_contains($k, '=') && (int)$v > 0)
$sysStrikes[$k] = (int)$v;
}
return [
'rw_level' => (int)($rw['rm_action_level'] ?? 0),
'rw_recover' => (int)($rw['rm_recover_cycles'] ?? 0),
'rw_paused' => array_filter(explode(',', $rw['rm_paused_containers'] ?? '')),
'rw_stopped' => array_filter(explode(',', $rw['rm_stopped_containers'] ?? '')),
'mem_shutdown' => ($rw['mem_shutdown_active'] ?? 'false') === 'true',
'daemon_strikes' => (int)($dock['daemon_strikes'] ?? 0),
'daemon_restart' => ($dock['daemon_restarted_flag'] ?? 'false') === 'true',
'ctr_strikes' => $strikes,
'skiplist' => vv_wd_parse_skiplist($skipRaw),
'sys_strikes' => $sysStrikes,
'reboots' => vv_wd_parse_reboot_log($rebootRaw),
'restarts' => vv_wd_parse_restart_log($restartRaw),
];
}
// ── Remote data via SSH ───────────────────────────────────────────────────────
function vv_wd_remote_data(string $ip, string $sshKey, string $restartLogPath): array {
// Bundle into one SSH call
$cmd = "printf 'UPTIME:%s\nLOAD:%s\nCORES:%s\nMEMTOTAL:%s\nMEMAVAIL:%s\nDAEMON:%s\nOOM:%s\n---RW---\n%s\n---DOCK---\n%s\n---SKIP---\n%s\n---SYS---\n%s\n---REBOOT---\n%s\n---RESTART---\n%s\n' "
. '"$(cat /proc/uptime|cut -d\" \" -f1)" '
. '"$(cat /proc/loadavg|cut -d\" \" -f1)" '
. '"$(nproc)" '
. '"$(grep -m1 MemTotal /proc/meminfo|awk \"{print \\\$2}\")" '
. '"$(grep -m1 MemAvailable /proc/meminfo|awk \"{print \\\$2}\")" '
. '"$(docker info >/dev/null 2>&1 && echo ok || echo err)" '
. '"$(cat /tmp/system_watchdog_oom.db 2>/dev/null||echo 0)" '
. '"$(cat /tmp/resource_watchdog_state.db 2>/dev/null)" '
. '"$(cat /tmp/container_watchdog_state.db 2>/dev/null)" '
. '"$(cat /boot/config/system_watchdog_failed.db 2>/dev/null)" '
. '"$(cat /tmp/system_watchdog_state.db 2>/dev/null)" '
. '"$(cat /boot/config/system_watchdog_reboots.db 2>/dev/null)" '
. '"$(cat ' . escapeshellarg($restartLogPath) . ' 2>/dev/null)"';
$out = vv_pt_ssh($ip, $sshKey, $cmd, 8);
if (!$out) return null;
// Parse sections
$sections = preg_split('/^---\w+---$/m', $out);
$header = $sections[0] ?? '';
$rwRaw = $sections[1] ?? '';
$dockRaw = $sections[2] ?? '';
$skipRaw = $sections[3] ?? '';
$sysRaw = $sections[4] ?? '';
$rebootRaw= $sections[5] ?? '';
$restartRaw=$sections[6] ?? '';
// Parse header lines
$hdr = [];
foreach (explode("\n", $header) as $line) {
if (preg_match('/^(\w+):(.*)$/', trim($line), $m)) $hdr[$m[1]] = trim($m[2]);
}
$rw = vv_wd_parse_kv($rwRaw);
$dock = vv_wd_parse_kv($dockRaw);
$sys = vv_wd_parse_kv($sysRaw);
$strikes = [];
foreach ($dock as $k => $v) {
if ($k !== 'daemon_strikes' && $k !== 'daemon_restarted_flag' && (int)$v > 0)
$strikes[$k] = (int)$v;
}
$sysStrikes = [];
foreach ($sys as $k => $v) {
if (!str_contains($k, '=') && (int)$v > 0) $sysStrikes[$k] = (int)$v;
}
$memTotal = (int)($hdr['MEMTOTAL'] ?? 0) * 1024;
$memAvail = (int)($hdr['MEMAVAIL'] ?? 0) * 1024;
return [
'system' => [
'mem_total' => $memTotal,
'mem_avail' => $memAvail,
'load1' => (float)($hdr['LOAD'] ?? 0),
'cores' => (int)($hdr['CORES'] ?? 1),
'uptime' => (int)($hdr['UPTIME'] ?? 0),
'daemon_ok' => ($hdr['DAEMON'] ?? '') === 'ok',
'oom_count' => (int)($hdr['OOM'] ?? 0),
],
'states' => [
'rw_level' => (int)($rw['rm_action_level'] ?? 0),
'rw_recover' => (int)($rw['rm_recover_cycles'] ?? 0),
'rw_paused' => array_filter(explode(',', $rw['rm_paused_containers'] ?? '')),
'rw_stopped' => array_filter(explode(',', $rw['rm_stopped_containers'] ?? '')),
'mem_shutdown' => ($rw['mem_shutdown_active'] ?? 'false') === 'true',
'daemon_strikes'=> (int)($dock['daemon_strikes'] ?? 0),
'daemon_restart'=> ($dock['daemon_restarted_flag'] ?? 'false') === 'true',
'ctr_strikes' => $strikes,
'skiplist' => vv_wd_parse_skiplist($skipRaw),
'sys_strikes' => $sysStrikes,
'reboots' => vv_wd_parse_reboot_log($rebootRaw),
'restarts' => vv_wd_parse_restart_log($restartRaw),
],
];
}
// ── Config inventory ──────────────────────────────────────────────────────────
function vv_wd_node_config(string $slot, string $raw, string $masterRaw): array {
$id = strtoupper($slot);
return [
'monitored' => vv_wd_bash_assoc($raw, "{$id}_WATCHDOG_CONTAINERS"),
'urls' => vv_wd_bash_assoc($raw, "{$id}_WATCHDOG_CONTAINER_URLS"),
'required' => vv_wd_bash_array($raw, "{$id}_WATCHDOG_REQUIRED_CONTAINERS"),
'ignore' => vv_wd_bash_array($raw, "{$id}_WATCHDOG_SCAN_IGNORE"),
'pause_list' => vv_wd_bash_array($raw, "{$id}_RW_PAUSE_CONTAINERS"),
'stop_list' => vv_wd_bash_array($raw, "{$id}_RW_STOP_CONTAINERS"),
'critical' => vv_wd_bash_array($masterRaw, "RW_CRITICAL_CONTAINERS"),
];
}
// ── Entry point ───────────────────────────────────────────────────────────────
function vv_wd_all(): array {
$currentHost = vv_detect_host();
$tsPeers = vv_pt_ts_peers();
$masterRaw = vv_read_conf_raw('master.conf');
$restartLog = '/mnt/user/appdata/unraid_scripts/data/container_restart_history.db';
// Config thresholds from master.conf
$cfg = [
'rw_soft_gb' => (float)(vv_wd_scalar($masterRaw, 'RW_RAM_SOFT_GB') ?: 12),
'rw_medium_gb' => (float)(vv_wd_scalar($masterRaw, 'RW_RAM_MEDIUM_GB') ?: 8),
'rw_hard_gb' => (float)(vv_wd_scalar($masterRaw, 'RW_RAM_HARD_GB') ?: 6),
'rw_recover_gb' => (float)(vv_wd_scalar($masterRaw, 'RW_RAM_RECOVER_GB') ?: 20),
'rw_load_soft' => (float)(vv_wd_scalar($masterRaw, 'RW_LOAD_SOFT_MULTIPLIER') ?: 2.0),
'rw_load_med' => (float)(vv_wd_scalar($masterRaw, 'RW_LOAD_MEDIUM_MULTIPLIER') ?: 3.0),
'sys_mem_gb' => (float)(vv_wd_scalar($masterRaw, 'SYS_WATCHDOG_MEM_GB') ?: 4),
'sys_strikes' => (int)(vv_wd_scalar($masterRaw, 'SYS_WATCHDOG_STRIKE_LIMIT') ?: 2),
'reboot_limit' => (int)(vv_wd_scalar($masterRaw, 'SYS_WATCHDOG_REBOOT_LIMIT') ?: 3),
'reboot_window' => (int)(vv_wd_scalar($masterRaw, 'SYS_WATCHDOG_REBOOT_WINDOW_HRS') ?: 12),
'restart_limit' => (int)(vv_wd_scalar($masterRaw, 'WATCHDOG_CONTAINER_RESTART_LIMIT') ?: 3),
'startup_grace' => (int)(vv_wd_scalar($masterRaw, 'WATCHDOG_STARTUP_GRACE') ?: 600),
'soft_mem_pct' => (int)(vv_wd_scalar($masterRaw, 'SOFT_MEM_THRESHOLD') ?: 80),
'soft_cpu_pct' => (int)(vv_wd_scalar($masterRaw, 'SOFT_CPU_THRESHOLD') ?: 80),
'hard_cpu_pct' => (int)(vv_wd_scalar($masterRaw, 'HARD_CPU_THRESHOLD') ?: 85),
'cpu_fail_lim' => (int)(vv_wd_scalar($masterRaw, 'CPU_FAIL_LIMIT') ?: 2),
'resp_fail_lim' => (int)(vv_wd_scalar($masterRaw, 'RESP_FAIL_LIMIT') ?: 2),
];
// SSH key from current host conf
$myId = strtoupper($currentHost);
$myRaw = vv_read_conf_raw($currentHost . '.conf');
$mySshKey = vv_wd_scalar($myRaw, $myId . '_SSH_KEY');
// Known hosts
preg_match_all('/^\s*(HOST(\d+))(?:_NAME)?\s*=\s*["\']?(\S+?)["\']?\s*$/m', $masterRaw, $m);
$hosts = [];
foreach ($m[1] as $i => $key) {
$hosts['host' . $m[2][$i]] = trim($m[3][$i]);
}
ksort($hosts);
if (!$hosts) $hosts = ['host1' => 'HOST1'];
$nodes = [];
foreach ($hosts as $slot => $hostname) {
$isMe = ($slot === $currentHost || $currentHost === 'unknown');
$tsLabel = strtolower($hostname);
$ts = $tsPeers[$tsLabel] ?? ['online' => null, 'active' => false, 'ip' => null];
$ip = $ts['ip'] ?? null;
$raw = vv_read_conf_raw($slot . '.conf');
if ($isMe) {
$system = vv_wd_local_system();
$states = vv_wd_local_states($restartLog);
} elseif ($ip && $mySshKey && $ts['online']) {
$remote = vv_wd_remote_data($ip, $mySshKey, $restartLog);
$system = $remote['system'] ?? null;
$states = $remote['states'] ?? null;
} else {
$system = null;
$states = null;
}
$nodes[] = [
'slot' => $slot,
'id' => strtoupper($slot),
'hostname' => $hostname,
'is_me' => $isMe,
'ts_online' => $ts['online'],
'system' => $system,
'states' => $states,
'config' => vv_wd_node_config($slot, $raw, $masterRaw),
];
}
return [
'ts' => time(),
'cfg' => $cfg,
'nodes' => $nodes,
];
}
+9
View File
@@ -0,0 +1,9 @@
// Varaverk — shared JS utilities
// Page-specific JS lives inline in each page partial.
// Flash a status element briefly then fade
function vvFlashStatus(el, msg, ok) {
el.textContent = msg;
el.style.color = ok ? '#4caf50' : '#f44336';
setTimeout(() => { el.textContent = ''; }, 3000);
}
+231
View File
@@ -0,0 +1,231 @@
<style>
.vv-arr-card { background:#161616;border:1px solid #2a2a2a;border-radius:6px;padding:10px;min-width:0; }
.vv-arr-head { display:flex;justify-content:space-between;align-items:center;margin-bottom:8px; }
.vv-arr-name { font-size:12px;font-weight:bold;text-transform:uppercase;color:#888;letter-spacing:.05em; }
.vv-arr-status{ display:flex;align-items:center;gap:5px; }
.vv-arr-dot { width:7px;height:7px;border-radius:50%;flex-shrink:0; }
.vv-arr-ver { font-size:10px;color:#555; }
.vv-arr-row { display:flex;justify-content:space-between;align-items:baseline;gap:8px;margin:2px 0; }
.vv-arr-lbl { font-size:11px;color:#555;white-space:nowrap; }
.vv-arr-val { font-size:12px;color:#bbb;text-align:right; }
.vv-arr-sep { border:none;border-top:1px solid #222;margin:6px 0; }
.vv-arr-sub { font-size:10px;color:#444;margin:1px 0 1px 2px; }
.vv-arr-sub.warn { color:#e57; }
.vv-arr-path { font-size:10px;color:#393939;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-top:4px; }
.vv-arr-node { grid-column:1/-1; }
.vv-arr-node-head { display:flex;align-items:baseline;gap:8px;margin-bottom:10px; }
.vv-arr-node-title{ font-size:12px;font-weight:bold;color:#666;letter-spacing:.06em;text-transform:uppercase; }
.vv-arr-node-host { font-size:11px;color:#3a3a3a; }
.vv-arr-cards { display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:10px; }
.vv-arr-stat-row { display:flex;gap:16px;flex-wrap:wrap; }
.vv-arr-stat { display:flex;flex-direction:column;gap:1px; }
.vv-arr-stat-val { font-size:15px;font-weight:bold;color:#ccc; }
.vv-arr-stat-lbl { font-size:10px;color:#555; }
</style>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;padding:0 2px;">
<span style="font-size:13px;font-weight:bold;color:#888;text-transform:uppercase;letter-spacing:.06em;">Arrs</span>
<span style="font-size:11px;color:#3a3a3a;" id="vv-arrs-ts"></span>
</div>
<div id="vv-arrs-grid" style="display:grid;grid-template-columns:repeat(8,1fr);gap:12px;">
<div style="grid-column:1/-1;color:#444;font-size:12px;padding:16px 0;text-align:center;">Loading…</div>
</div>
<script>
(function() {
function _relTime(ts) {
if (!ts) return '—';
const d = Math.floor(Date.now() / 1000) - ts;
if (d < 60) return 'just now';
if (d < 3600) return Math.floor(d / 60) + 'm ago';
if (d < 86400) return Math.floor(d / 3600) + 'h ago';
if (d < 172800) return 'yesterday';
return Math.floor(d / 86400) + 'd ago';
}
function _n(v) { return v == null ? '—' : Number(v).toLocaleString(); }
function _bytes(b) {
if (!b) return '—';
const u = ['B','KB','MB','GB','TB']; let i = 0;
while (b >= 1024 && i < u.length - 1) { b /= 1024; i++; }
return b.toFixed(1) + ' ' + u[i];
}
function _row(lbl, val) {
return `<div class="vv-arr-row"><span class="vv-arr-lbl">${lbl}</span><span class="vv-arr-val">${val}</span></div>`;
}
function _arrCard(arr) {
const names = {sonarr:'Sonarr', radarr:'Radarr', lidarr:'Lidarr'};
const name = names[arr.type] || arr.type;
const online = arr.online;
const dotCol = online ? '#4caf50' : (arr.remote ? '#444' : '#c62828');
const verStr = online ? (arr.version || 'online') : (arr.remote ? 'not reachable' : 'offline');
let body = '';
if (online) {
// Library
if (arr.type === 'sonarr') {
body += _row('Series', _n(arr.total));
body += _row('Monitored', _n(arr.monitored));
body += _row('Episodes', _n(arr.episodes));
} else if (arr.type === 'radarr') {
body += _row('Movies', _n(arr.total));
body += _row('Monitored', _n(arr.monitored));
body += _row('Files', _n(arr.files));
} else if (arr.type === 'lidarr') {
body += _row('Artists', _n(arr.total));
body += _row('Monitored', _n(arr.monitored));
if (arr.albums) body += _row('Albums', _n(arr.albums));
}
// Disk — show largest entry
if (arr.disk && arr.disk.length) {
const d = arr.disk.reduce((a,b) => (b.totalSpace||0) > (a.totalSpace||0) ? b : a);
if (d.freeSpace && d.totalSpace) {
body += _row('Free', _bytes(d.freeSpace) + ' / ' + _bytes(d.totalSpace));
}
}
// Queue
const q = arr.queue || {};
const qDl = q.dl || 0;
const qWrn = q.warn || 0;
const qErr = q.err || 0;
const qTot = qDl + qWrn + qErr;
const qCol = qErr ? '#f44' : (qWrn ? '#ff9800' : '#bbb');
body += `<hr class="vv-arr-sep">`;
const qStr = qTot === 0
? `<span style="color:#3a3a3a">idle</span>`
: `<span style="color:${qCol}">${qDl}↓&nbsp; ${qWrn}⚠&nbsp; ${qErr}✗</span>`;
body += _row('Queue', qStr);
// Health
if (arr.health && arr.health.length) {
const col = arr.health.some(h => h.type === 'error') ? '#f44' : '#ff9800';
body += `<div class="vv-arr-sub warn" style="color:${col}">` +
arr.health.slice(0,2).map(h => h.message || h.type).join('<br>') + '</div>';
}
// Cleanup
const cl = arr.cleanup || {};
if (cl.last_run) {
body += `<hr class="vv-arr-sep">`;
const clOk = cl.status === 'ok'
? '<span style="color:#4caf50">✓</span>'
: '<span style="color:#f44">✗</span>';
body += _row('Cleanup', _relTime(cl.last_run) + '&nbsp;' + clOk);
if (cl.tracked != null) {
body += `<div class="vv-arr-sub">${_n(cl.tracked)} files · ${_n(cl.total)} items</div>`;
const orph = cl.orphans || 0, junk = cl.junk || 0;
if (orph || junk) {
body += `<div class="vv-arr-sub warn">${orph} orphans · ${junk} junk</div>`;
} else {
body += `<div class="vv-arr-sub">0 orphans · 0 junk</div>`;
}
}
}
// Discovery
const disc = arr.discovery || {};
if (disc.last_run) {
if (!cl.last_run) body += `<hr class="vv-arr-sep">`;
const added = disc.added != null ? `&nbsp;<span style="color:#6fcf97">+${disc.added}</span>` : '';
body += _row('Discovery', _relTime(disc.last_run) + added);
}
}
body += `<div class="vv-arr-path">${arr.root || '—'}</div>`;
return `<div class="vv-arr-card">
<div class="vv-arr-head">
<span class="vv-arr-name">${name}</span>
<span class="vv-arr-status">
<span class="vv-arr-dot" style="background:${dotCol}"></span>
<span class="vv-arr-ver">${verStr}</span>
</span>
</div>
${body}
</div>`;
}
function _nodeSection(node, ownerHost) {
const isOwner = node.host === ownerHost;
const badge = isOwner ? ' <span style="color:#3a4a3a;font-size:10px;">owner</span>' : '';
const cards = node.arrs.map(_arrCard).join('');
return `<div class="vv-card vv-arr-node">
<div class="vv-arr-node-head">
<span class="vv-arr-node-title">${node.host.toUpperCase()}</span>
<span class="vv-arr-node-host">${node.name}${badge}</span>
</div>
<div class="vv-arr-cards">${cards}</div>
</div>`;
}
function _syncCard(sync) {
let body = '';
if (!sync.last_run) {
body = '<div style="color:#3a3a3a;font-size:12px;">Never run</div>';
} else {
const ok = sync.status === 'ok'
? '<span style="color:#4caf50">✓</span>'
: '<span style="color:#f44">✗</span>';
body += _row('Last run', _relTime(sync.last_run) + '&nbsp;' + ok);
if (sync.added != null) body += _row('Added', `<span style="color:#6fcf97">+${_n(sync.added)}</span>`);
if (sync.nodes != null) body += _row('Nodes', _n(sync.nodes));
}
if (sync.blocklist_count != null) {
body += `<hr class="vv-arr-sep">`;
body += _row('Blocklist', _n(sync.blocklist_count) + ' entries');
}
return `<div class="vv-card" style="grid-column:span 4;">
<h3>Arrs Sync</h3>${body}</div>`;
}
function _recoveryCard(rec) {
let body = '';
if (!rec.last_run) {
body = '<div style="color:#3a3a3a;font-size:12px;">Never run</div>';
} else {
const ok = rec.status === 'ok'
? '<span style="color:#4caf50">✓</span>'
: '<span style="color:#f44">✗</span>';
body += _row('Last run', _relTime(rec.last_run) + '&nbsp;' + ok);
body += _row('Fixed', _n(rec.fixed));
body += _row('Re-searched',_n(rec.searched));
}
return `<div class="vv-card" style="grid-column:span 4;">
<h3>Failed / Stalled Recovery</h3>${body}</div>`;
}
function _render(data) {
let html = '';
for (const node of (data.nodes || [])) {
html += _nodeSection(node, data.host);
}
html += _syncCard(data.sync || {});
html += _recoveryCard(data.recovery || {});
document.getElementById('vv-arrs-grid').innerHTML = html || '<div style="grid-column:1/-1;color:#444;font-size:12px;padding:16px 0;text-align:center;">No arrs configured.</div>';
const ts = data.ts ? new Date(data.ts * 1000).toLocaleString([],
{month:'numeric',day:'numeric',year:'numeric',hour:'2-digit',minute:'2-digit',second:'2-digit'}) : '';
document.getElementById('vv-arrs-ts').textContent = ts ? 'Updated: ' + ts : '';
}
function vvArrsLoad() {
fetch('/plugins/varaverk/api/arrs.php')
.then(r => r.json())
.then(_render)
.catch(() => {});
}
vvArrsLoad();
setInterval(vvArrsLoad, 60000);
})();
</script>
+83
View File
@@ -0,0 +1,83 @@
<?php
require_once dirname(__DIR__) . '/include/config.php';
$files = vv_get_conf_files();
$active = $_GET['conf'] ?? ($files[0] ?? '');
if (!in_array($active, $files)) $active = $files[0] ?? '';
$currentScriptsDir = SCRIPTS_DIR;
?>
<div id="vv-config">
<!-- Plugin Settings -->
<div class="vv-card vv-wide" style="margin-bottom:16px;">
<h3>Plugin Settings</h3>
<div class="vv-job-row" style="max-width:700px;gap:8px;">
<label style="color:#aaa;font-size:13px;white-space:nowrap;">Scripts directory</label>
<input type="text" id="vv-scripts-dir" value="<?= htmlspecialchars($currentScriptsDir) ?>"
style="flex:1;background:#111;border:1px solid #444;color:#ddd;padding:4px 8px;
border-radius:4px;font-family:monospace;font-size:13px;">
<button type="button" onclick="vvSaveSettings()" style="padding:4px 14px;background:#4caf50;
border:none;color:#fff;border-radius:4px;cursor:pointer;font-size:13px;">Save</button>
<span id="vv-settings-status" style="font-size:13px;color:#aaa;"></span>
</div>
</div>
<!-- File selector -->
<div id="vv-conf-tabs">
<?php foreach ($files as $f): ?>
<a href="?tab=config&conf=<?= urlencode($f) ?>"
class="vv-conf-tab<?= $f === $active ? ' active' : '' ?>">
<?= htmlspecialchars($f) ?>
</a>
<?php endforeach; ?>
</div>
<?php if ($active): ?>
<form id="vv-conf-form">
<input type="hidden" name="file" value="<?= htmlspecialchars($active) ?>">
<textarea id="vv-conf-editor" name="content" spellcheck="false"><?=
htmlspecialchars(vv_read_conf_raw($active))
?></textarea>
<div id="vv-conf-actions">
<button type="button" onclick="vvSaveConf()">Save</button>
<span id="vv-conf-status"></span>
</div>
</form>
<?php else: ?>
<p>No configuration files found.</p>
<?php endif; ?>
</div>
<script>
function vvPost(url, data) {
const params = new URLSearchParams({csrf_token, ...data});
return fetch(url, {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: params
}).then(r => r.json());
}
function vvSaveSettings() {
const dir = document.getElementById('vv-scripts-dir').value.trim();
const status = document.getElementById('vv-settings-status');
if (!dir) { status.textContent = '✗ Path required'; return; }
status.textContent = 'Saving...';
vvPost('/plugins/varaverk/api/settings.php', {scripts_dir: dir})
.then(d => { status.textContent = d.ok ? '✓ Saved — reload to apply' : '✗ ' + (d.error ?? 'Error'); })
.catch(() => { status.textContent = '✗ Request failed'; });
}
function vvSaveConf() {
const form = document.getElementById('vv-conf-form');
const file = form.querySelector('[name=file]').value;
const content = form.querySelector('[name=content]').value;
const status = document.getElementById('vv-conf-status');
status.textContent = 'Saving...';
vvPost('/plugins/varaverk/api/config.php', {file, content})
.then(d => { status.textContent = d.ok ? '✓ Saved' : '✗ ' + (d.error ?? 'Error'); })
.catch(() => { status.textContent = '✗ Request failed'; });
}
</script>
+44
View File
@@ -0,0 +1,44 @@
<?php
require_once dirname(__DIR__) . '/include/docs.php';
require_once dirname(__DIR__) . '/include/config.php';
$tree = vv_docs_tree();
$vars = vv_conf_vars();
$active = $_GET['doc'] ?? '';
// Validate: must be a .md file within SCRIPTS_DIR
$active = preg_match('/^[a-zA-Z0-9_\-\/]+\.md$/', $active) ? $active : '';
if ($active && !file_exists(SCRIPTS_DIR . '/' . $active)) $active = '';
?>
<div id="vv-docs">
<div id="vv-docs-sidebar">
<h3>Documents</h3>
<ul>
<?php foreach ($tree as $rel): ?>
<li>
<a href="?tab=docs&doc=<?= urlencode($rel) ?>"
class="<?= $rel === $active ? 'active' : '' ?>">
<?= htmlspecialchars($rel) ?>
</a>
</li>
<?php endforeach; ?>
</ul>
</div>
<div id="vv-docs-content">
<?php if ($active): ?>
<div class="vv-doc-body">
<?= vv_docs_render($active, $vars) ?>
</div>
<p class="vv-doc-hint">
Values shown in <code class="vv-live-var">green</code> are live from your conf files.
<code class="vv-unknown-var">Orange</code> means the variable was not found.
</p>
<?php else: ?>
<p>Select a document from the sidebar.</p>
<?php endif; ?>
</div>
</div>
+243
View File
@@ -0,0 +1,243 @@
<style>
.vv-fb-active { background:#1a1200;border:1px solid #5a3800;border-radius:6px;padding:12px 14px; }
.vv-fb-active-h { display:flex;align-items:baseline;gap:10px;margin-bottom:8px; }
.vv-fb-badge { font-size:11px;font-weight:bold;letter-spacing:.06em;padding:2px 7px;border-radius:3px;flex-shrink:0; }
.vv-fb-badge.fb { background:#5a3800;color:#ffb74d; }
.vv-fb-badge.norm { background:#1a2a1a;color:#4caf50; }
.vv-fb-badge.dark { background:#2a1a2a;color:#9c27b0; }
.vv-fb-badge.nonet{ background:#1a1a2a;color:#5c7cfa; }
.vv-fb-meta { display:flex;gap:18px;flex-wrap:wrap;margin-bottom:10px; }
.vv-fb-meta-item{ display:flex;flex-direction:column;gap:1px; }
.vv-fb-meta-val { font-size:17px;font-weight:bold;color:#ffb74d; }
.vv-fb-meta-lbl { font-size:10px;color:#5a4020; }
.vv-fb-ctrs { display:flex;gap:6px;flex-wrap:wrap;margin-top:6px; }
.vv-fb-ctr { font-size:11px;padding:2px 8px;border-radius:3px;background:#2a1e00;color:#ffb74d;border:1px solid #4a2e00; }
.vv-fb-ctr.running { background:#1a2a1a;color:#6fcf97;border-color:#2d4a2d; }
.vv-fb-ctr.stopped { background:#2a1a1a;color:#e57;border-color:#4a2020;opacity:.7; }
.vv-fb-node { grid-column:span 4; }
.vv-fb-node-h { display:flex;align-items:baseline;gap:8px;margin-bottom:10px; }
.vv-fb-node-id { font-size:12px;font-weight:bold;color:#666;letter-spacing:.06em;text-transform:uppercase; }
.vv-fb-node-nm { font-size:11px;color:#3a3a3a; }
.vv-fb-arrow { font-size:11px;color:#333; }
.vv-fb-covers { font-size:11px;color:#3a3a3a; }
.vv-fb-tier { margin-bottom:8px; }
.vv-fb-tier-h { display:flex;align-items:baseline;gap:6px;margin-bottom:4px; }
.vv-fb-tier-lbl { font-size:11px;font-weight:bold;color:#555;text-transform:uppercase;letter-spacing:.04em; }
.vv-fb-tier-delay { font-size:10px;color:#3a3a3a; }
.vv-fb-tier-pills { display:flex;gap:5px;flex-wrap:wrap; }
.vv-fb-pill { font-size:11px;padding:2px 7px;border-radius:3px;background:#1e1e1e;color:#777;border:1px solid #2a2a2a; }
.vv-fb-pill.active-t { background:#1a2010;color:#8bc34a;border-color:#2d3a1d; }
.vv-fb-pill.empty { color:#333;font-style:italic; }
.vv-fb-state-dot { width:6px;height:6px;border-radius:50%;flex-shrink:0;margin-top:3px; }
.vv-fb-sep { border:none;border-top:1px solid #222;margin:8px 0; }
.vv-fb-disabled { grid-column:1/-1;color:#3a3a3a;font-size:12px;padding:20px 0;text-align:center; }
</style>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;padding:0 2px;">
<span style="font-size:13px;font-weight:bold;color:#888;text-transform:uppercase;letter-spacing:.06em;">FallBack</span>
<span style="font-size:11px;color:#3a3a3a;" id="vv-fb-ts"></span>
</div>
<div id="vv-fb-grid" style="display:grid;grid-template-columns:repeat(8,1fr);gap:12px;">
<div style="grid-column:1/-1;color:#444;font-size:12px;padding:16px 0;text-align:center;">Loading…</div>
</div>
<script>
(function() {
function _dur(startTs) {
if (!startTs || startTs === 0) return '—';
const s = Math.floor(Date.now() / 1000) - startTs;
if (s < 60) return s + 's';
if (s < 3600) return Math.floor(s / 60) + 'm';
const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60);
return m ? h + 'h ' + m + 'm' : h + 'h';
}
function _fmtDelay(min) {
if (!min) return 'immediate';
if (min < 60) return min + 'min';
const h = Math.floor(min / 60), m = min % 60;
return m ? h + 'h ' + m + 'm' : h + 'h';
}
function _activeTier(state) {
if (!state) return 0;
if (state.tier4_started) return 4;
if (state.tier3_started) return 3;
if (state.tier2_started) return 2;
return 1;
}
function _stateBadge(st) {
const map = {
NORMAL: ['norm', 'NORMAL'],
FALLBACK: ['fb', 'FALLBACK'],
NO_INTERNET: ['nonet', 'NO INTERNET'],
DARK: ['dark', 'DARK'],
OFFLINE: ['dark', 'OFFLINE'],
UNREACHABLE: ['dark', 'UNREACHABLE'],
UNKNOWN: ['dark', 'UNKNOWN'],
};
const [cls, label] = map[st] || ['dark', st];
return `<span class="vv-fb-badge ${cls}">${label}</span>`;
}
function _stateDot(st) {
const col = {
NORMAL:'#4caf50', FALLBACK:'#ffb74d',
NO_INTERNET:'#5c7cfa', DARK:'#9c27b0',
OFFLINE:'#555', UNREACHABLE:'#555', UNKNOWN:'#333',
}[st] || '#333';
return `<span class="vv-fb-state-dot" style="background:${col}"></span>`;
}
function _activeCard(nodes, handbackReq) {
const active = nodes.filter(n => n.state && n.state.state === 'FALLBACK');
if (!active.length) return '';
return active.map(covering => {
const st = covering.state;
const tier = _activeTier(st);
const cov = covering.covers;
const covered = cov ? cov.hostname : '?';
// All containers that should be running at current tier
let expected = [...(cov?.tier1 || [])];
if (tier >= 2) expected = expected.concat(cov?.tier2 || []);
if (tier >= 3) expected = expected.concat(cov?.tier3 || []);
if (tier >= 4) expected = expected.concat(cov?.tier4 || []);
const runningSet = new Set(covering.running || []);
const ctrPills = expected.length
? expected.map(c => {
const cls = runningSet.has(c) ? 'running' : 'stopped';
const sym = runningSet.has(c) ? '▲' : '▼';
return `<span class="vv-fb-ctr ${cls}">${sym} ${c}</span>`;
}).join('')
: '<span style="color:#5a4020;font-size:11px;">No containers configured for this tier</span>';
return `<div class="vv-card vv-fb-active" style="grid-column:1/-1;">
<div class="vv-fb-active-h">
${_stateBadge('FALLBACK')}
<span style="font-size:12px;color:#aa7020;">${covering.id} covering ${cov?.id || '?'} (${covered})</span>
</div>
<div class="vv-fb-meta">
<div class="vv-fb-meta-item">
<span class="vv-fb-meta-val">${_dur(st.fallback_start)}</span>
<span class="vv-fb-meta-lbl">DURATION</span>
</div>
<div class="vv-fb-meta-item">
<span class="vv-fb-meta-val">Tier ${tier}</span>
<span class="vv-fb-meta-lbl">ACTIVE TIER</span>
</div>
<div class="vv-fb-meta-item">
<span class="vv-fb-meta-val">${st.handback_strikes} / ${handbackReq}</span>
<span class="vv-fb-meta-lbl">HANDBACK STRIKES</span>
</div>
<div class="vv-fb-meta-item">
<span class="vv-fb-meta-val">${expected.length}</span>
<span class="vv-fb-meta-lbl">CONTAINERS</span>
</div>
</div>
<div class="vv-fb-ctrs">${ctrPills}</div>
</div>`;
}).join('');
}
function _tierSection(tiers, activeTier, delays) {
const defs = [
{ n: 1, key: 'tier1', label: 'Tier 1', delay: 0 },
{ n: 2, key: 'tier2', label: 'Tier 2', delay: delays?.tier2 },
{ n: 3, key: 'tier3', label: 'Tier 3', delay: delays?.tier3 },
{ n: 4, key: 'tier4', label: 'Tier 4', delay: delays?.tier4 },
];
return defs.map(({ n, key, label, delay }) => {
const containers = tiers[key] || [];
const isActive = activeTier >= n;
const pills = containers.length
? containers.map(c => `<span class="vv-fb-pill${isActive ? ' active-t' : ''}">${c}</span>`).join('')
: `<span class="vv-fb-pill empty">none</span>`;
const delayStr = n === 1 ? 'immediate' : _fmtDelay(delay);
return `<div class="vv-fb-tier">
<div class="vv-fb-tier-h">
<span class="vv-fb-tier-lbl">${label}</span>
<span class="vv-fb-tier-delay">${delayStr}</span>
</div>
<div class="vv-fb-tier-pills">${pills}</div>
</div>`;
}).join('');
}
function _nodeCard(node) {
const st = node.state || {};
const state = st.state || 'UNKNOWN';
const cov = node.covers;
const active = _activeTier(state === 'FALLBACK' ? st : null);
const covTarget = cov
? `<span class="vv-fb-arrow">→</span><span class="vv-fb-covers">covers ${cov.id} (${cov.hostname})</span>`
: '';
const tierSection = cov
? _tierSection(cov, active, cov.delays)
: '<div style="color:#3a3a3a;font-size:11px;">No coverage configured</div>';
return `<div class="vv-card vv-fb-node">
<div class="vv-fb-node-h">
${_stateDot(state)}
<span class="vv-fb-node-id">${node.id}</span>
<span class="vv-fb-node-nm">${node.hostname}</span>
${covTarget}
<span style="flex:1"></span>
${_stateBadge(state)}
</div>
<hr class="vv-fb-sep">
${tierSection}
</div>`;
}
function _render(data) {
if (!data.fb_enabled) {
document.getElementById('vv-fb-grid').innerHTML =
'<div class="vv-fb-disabled">FALLBACK_ENABLED=false — fallback monitoring is disabled</div>';
return;
}
const nodes = data.nodes || [];
let html = '';
// Top: active fallback card (if any)
html += _activeCard(nodes, data.handback_req || 3);
// Per-node cards
for (const node of nodes) {
html += _nodeCard(node);
}
if (!html) {
html = '<div class="vv-fb-disabled">No nodes configured.</div>';
}
document.getElementById('vv-fb-grid').innerHTML = html;
const ts = data.ts
? new Date(data.ts * 1000).toLocaleString([], {
month:'numeric', day:'numeric', year:'numeric',
hour:'2-digit', minute:'2-digit', second:'2-digit'})
: '';
document.getElementById('vv-fb-ts').textContent = ts ? 'Updated: ' + ts : '';
}
function vvFbLoad() {
fetch('/plugins/varaverk/api/fallback.php')
.then(r => r.json())
.then(_render)
.catch(() => {});
}
vvFbLoad();
setInterval(vvFbLoad, 30000);
})();
</script>
File diff suppressed because it is too large Load Diff
+290
View File
@@ -0,0 +1,290 @@
<style>
.vv-pt-grid { display:grid; gap:12px; }
.vv-pt-node { background:#161616; border:1px solid #2a2a2a; border-radius:6px; padding:12px; min-width:0; }
.vv-pt-node.me { border-color:#2a3a2a; }
.vv-pt-node-head { display:flex; justify-content:space-between; align-items:flex-start; margin-bottom:10px; }
.vv-pt-hostname { font-size:13px; font-weight:bold; color:#ccc; }
.vv-pt-slot { font-size:10px; color:#444; margin-top:1px; }
.vv-pt-tags { display:flex; gap:4px; flex-wrap:wrap; justify-content:flex-end; }
.vv-pt-tag { font-size:8px; padding:1px 5px; border-radius:3px; white-space:nowrap; }
.vv-pt-tag.me { background:#1a3a1a; color:#4caf50; }
.vv-pt-tag.owner { background:#1a2a3a; color:#4a9eff; }
.vv-pt-tag.mirror { background:#2a2a1a; color:#ff9800; }
.vv-pt-row { display:flex; justify-content:space-between; align-items:baseline; margin:3px 0; }
.vv-pt-lbl { font-size:11px; color:#555; }
.vv-pt-val { font-size:11px; color:#bbb; text-align:right; }
.vv-pt-sep { border:none; border-top:1px solid #222; margin:8px 0; }
.vv-pt-state { font-size:12px; font-weight:500; }
.vv-pt-dot { width:8px; height:8px; border-radius:50%; display:inline-block; flex-shrink:0; margin-right:5px; }
.vv-pt-actions { display:flex; gap:10px; flex-wrap:wrap; margin-top:4px; }
.vv-pt-action-btn { padding:5px 16px; border:none; border-radius:4px; cursor:pointer;
font-size:12px; font-weight:500; }
.vv-pt-action-btn.run { background:#1a3a1a; color:#6fcf97; border:1px solid #2e6b2e; }
.vv-pt-action-btn.warn { background:#3a1a1a; color:#f88; border:1px solid #6b2e2e; }
.vv-pt-action-btn.info { background:#1a2a3a; color:#7ab; border:1px solid #2e4a6b; }
.vv-pt-action-btn:disabled { opacity:0.4; cursor:default; }
.vv-pt-transfer-note { font-size:10px; color:#555; margin-top:6px; font-family:monospace; }
</style>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;padding:0 2px;">
<span style="font-size:13px;font-weight:bold;color:#888;text-transform:uppercase;letter-spacing:.06em;">Partnership</span>
<span style="font-size:11px;color:#3a3a3a;" id="vv-pt-ts"></span>
</div>
<!-- Config bar -->
<div class="vv-card" style="margin-bottom:12px;" id="vv-pt-config-card">
<div id="vv-pt-config-body" style="color:#444;font-size:12px;">Loading…</div>
</div>
<!-- Node grid -->
<div id="vv-pt-nodes" class="vv-pt-grid" style="margin-bottom:12px;">
<div style="color:#444;font-size:12px;padding:16px 0;text-align:center;">Loading…</div>
</div>
<!-- Actions -->
<div class="vv-card" id="vv-pt-actions-card">
<h3>Actions</h3>
<div id="vv-pt-actions-body" style="color:#444;font-size:12px;">Loading…</div>
</div>
<script>
(function() {
// ── Helpers ───────────────────────────────────────────────────────────────────
function _relTime(ts) {
if (!ts) return '—';
const d = Math.floor(Date.now() / 1000) - ts;
if (d < 60) return 'just now';
if (d < 3600) return Math.floor(d / 60) + 'm ago';
if (d < 86400) return Math.floor(d / 3600) + 'h ago';
if (d < 172800) return 'yesterday';
return Math.floor(d / 86400) + 'd ago';
}
function _uptime(sec) {
if (!sec) return '—';
const d = Math.floor(sec / 86400);
const h = Math.floor((sec % 86400) / 3600);
const m = Math.floor((sec % 3600) / 60);
return d > 0 ? `${d}d ${h}h ${m}m` : h > 0 ? `${h}h ${m}m` : `${m}m`;
}
function _row(lbl, val) {
return `<div class="vv-pt-row"><span class="vv-pt-lbl">${lbl}</span><span class="vv-pt-val">${val}</span></div>`;
}
// ── Config bar ────────────────────────────────────────────────────────────────
function _renderConfig(cfg) {
const dot = cfg.enabled ? '#4caf50' : '#555';
const label = cfg.enabled
? `<span style="color:#4caf50;">enabled</span>`
: `<span style="color:#555;">disabled</span>`;
const owner = cfg.owner_host || '—';
const items = [
label,
`Owner: <span style="color:#ccc;">${owner}</span>`,
`Sync: ${cfg.sync_min}min`,
`Grace: ${cfg.grace_hours}h`,
`Offline threshold: ${cfg.offline_threshold}d`,
cfg.remove_tailscale ? 'Tailscale removal: on' : 'Tailscale removal: off',
cfg.tailscale_configured ? '' : '<span style="color:#ff9800;">⚠ Tailscale API key not set</span>',
].filter(Boolean);
return `<div style="display:flex;align-items:center;gap:6px;flex-wrap:wrap;">
<span class="vv-pt-dot" style="background:${dot}"></span>
${items.map(i => `<span style="font-size:11px;color:#666;">${i}</span>`)
.join('<span style="color:#333;">·</span>')}
</div>`;
}
// ── Node card ─────────────────────────────────────────────────────────────────
const fbColors = {
NORMAL: '#4caf50',
FAILOVER: '#f44336',
NO_INTERNET: '#ff9800',
DARK: '#9e9e9e',
UNKNOWN: '#444',
};
const fbLabels = {
NORMAL: '✓ Nominal',
FAILOVER: '⚠ Failover',
NO_INTERNET: '⚡ No internet',
DARK: '◌ Dark mode',
UNKNOWN: '— Unknown',
};
const ptStates = {
ACTIVE: ['#4caf50', 'Active'],
INACTIVE: ['#555', 'Inactive'],
PENDING: ['#ff9800', 'Pending'],
};
function _nodeCard(node) {
const tsOnline = node.ts_online;
const dotCol = tsOnline === null ? '#555' : tsOnline ? '#4caf50' : '#f44336';
const dotTip = tsOnline === null ? 'unknown' : tsOnline ? (node.ts_active ? 'active' : 'idle') : 'offline';
const tags = [
node.is_me ? '<span class="vv-pt-tag me">US</span>' : '',
node.is_owner ? '<span class="vv-pt-tag owner">OWNER</span>' : '<span class="vv-pt-tag mirror">MIRROR</span>',
].join('');
// System info
const sys = node.system || {};
const ver = sys.unraid_version || '—';
const uptime = sys.uptime_sec ? _uptime(sys.uptime_sec) : '—';
// Fallback
const fb = (node.fallback || 'UNKNOWN').toUpperCase();
const fbCol = fbColors[fb] || '#444';
const fbLbl = fbLabels[fb] || fb;
// Partnership DB
const pt = node.partnership || {};
const ptState = (pt.state || '').toUpperCase();
const [ptCol, ptLbl] = ptStates[ptState] || ['#444', ptState || '—'];
const ptUpdated = pt.updated ? _relTime(parseInt(pt.updated)) : null;
let body = '';
// Network
body += `<div style="display:flex;align-items:center;gap:6px;margin-bottom:8px;">
<span class="vv-pt-dot" style="background:${dotCol}"></span>
<span style="font-size:11px;color:${dotCol};">${dotTip}</span>`;
if (node.ts_ip) body += `<span style="font-size:10px;color:#444;margin-left:4px;">${node.ts_ip}</span>`;
body += `</div>`;
// System
body += _row('unRAID', ver);
body += _row('Uptime', uptime);
body += `<hr class="vv-pt-sep">`;
// States
body += `<div class="vv-pt-row">
<span class="vv-pt-lbl">Fallback</span>
<span class="vv-pt-state" style="color:${fbCol};">${fbLbl}</span>
</div>`;
body += `<div class="vv-pt-row">
<span class="vv-pt-lbl">Partnership</span>
<span class="vv-pt-state" style="color:${ptCol};">${ptLbl}${ptUpdated ? `<span style="font-size:9px;color:#444;margin-left:4px;">${ptUpdated}</span>` : ''}</span>
</div>`;
if (pt.reason) {
body += `<div style="font-size:10px;color:#555;margin-top:3px;text-align:right;">${pt.reason}</div>`;
}
return `<div class="vv-pt-node${node.is_me ? ' me' : ''}">
<div class="vv-pt-node-head">
<div>
<div class="vv-pt-hostname">${node.hostname}</div>
<div class="vv-pt-slot">${node.id}</div>
</div>
<div class="vv-pt-tags">${tags}</div>
</div>
${body}
</div>`;
}
// ── Actions ───────────────────────────────────────────────────────────────────
function _renderActions(nodes, cfg) {
// Onboard: only show when partnership is disabled (not yet set up)
// Offboard: only when enabled
// Transfer: only when enabled + we are owner
const isOwner = nodes.some(n => n.is_me && n.is_owner);
const remotes = nodes.filter(n => !n.is_me);
const remoteOpts = remotes.map(n =>
`<option value="${n.slot}">${n.id} — ${n.hostname}</option>`).join('');
let html = '<div class="vv-pt-actions">';
// Onboard
html += `<button class="vv-pt-action-btn run" onclick="vvPtOnboard(this)"
title="Run partnership_onboard.sh — auto-detects role (run on mirror first, then owner)">
▶ Onboard
</button>`;
// Offboard
html += `<button class="vv-pt-action-btn warn" onclick="vvPtOffboard(this)"
${!cfg.enabled ? 'title="No active partnership to offboard" style="opacity:.35;cursor:default;"' : ''}>
▶ Offboard
</button>`;
html += '</div>';
// Transfer — show manual command, too destructive to one-click
if (cfg.enabled && isOwner) {
const confirmStr = 'i-understand-this-transfers-ownership';
html += `<div style="margin-top:14px;padding-top:10px;border-top:1px solid #1e1e1e;">
<span style="font-size:11px;color:#555;">Transfer ownership — run manually from the current owner:</span>
<div class="vv-pt-transfer-note" style="margin-top:4px;padding:6px 10px;background:#111;border-radius:4px;color:#666;">
bash Partnership/partnership_transfer.sh --confirm=${confirmStr}
</div>
</div>`;
}
return html;
}
// ── Main render ───────────────────────────────────────────────────────────────
function _render(data) {
const cfg = data.config || {};
const nodes = data.nodes || [];
// Config bar
document.getElementById('vv-pt-config-body').innerHTML = _renderConfig(cfg);
// Node grid — columns based on count
const cols = nodes.length <= 2 ? nodes.length : nodes.length <= 4 ? 2 : 3;
const grid = document.getElementById('vv-pt-nodes');
grid.style.gridTemplateColumns = `repeat(${cols}, 1fr)`;
grid.innerHTML = nodes.length
? nodes.map(_nodeCard).join('')
: '<div style="color:#444;font-size:12px;padding:12px 0;">No hosts found in master.conf.</div>';
// Actions
document.getElementById('vv-pt-actions-body').innerHTML = _renderActions(nodes, cfg);
// Timestamp
const ts = data.ts ? new Date(data.ts * 1000).toLocaleString([],
{month:'numeric',day:'numeric',year:'numeric',hour:'2-digit',minute:'2-digit',second:'2-digit'}) : '';
document.getElementById('vv-pt-ts').textContent = ts ? 'Updated: ' + ts : '';
}
// ── Actions ───────────────────────────────────────────────────────────────────
function vvPtOnboard(btn) {
if (!confirm('Run partnership_onboard.sh?\n\nRun on the MIRROR first, then on the OWNER.')) return;
btn.disabled = true;
btn.textContent = '⟳ Starting…';
vvRunById('Partnership/partnership_onboard.sh');
setTimeout(() => { btn.disabled = false; btn.textContent = '▶ Onboard'; }, 4000);
}
function vvPtOffboard(btn) {
if (btn.style.opacity === '0.35' || btn.style.cursor === 'default') return;
if (!confirm('Run partnership_offboard.sh?\n\nThis will end the partnership, reconfigure WebUIs, and revoke SSH access.\n\nContinue?')) return;
btn.disabled = true;
btn.textContent = '⟳ Starting…';
vvRunById('Partnership/partnership_offboard.sh');
setTimeout(() => { btn.disabled = false; btn.textContent = '▶ Offboard'; }, 4000);
}
// ── Poll ──────────────────────────────────────────────────────────────────────
function vvPtLoad() {
fetch('/plugins/varaverk/api/partnership.php')
.then(r => r.json())
.then(_render)
.catch(() => {});
}
vvPtLoad();
setInterval(vvPtLoad, 10000);
})();
</script>
File diff suppressed because it is too large Load Diff
+370
View File
@@ -0,0 +1,370 @@
<style>
.vv-wd-card { background:#161616;border:1px solid #2a2a2a;border-radius:6px;padding:10px;min-width:0; }
.vv-wd-sec { font-size:10px;font-weight:bold;color:#444;letter-spacing:.07em;text-transform:uppercase;margin-bottom:6px; }
.vv-wd-row { display:flex;justify-content:space-between;align-items:baseline;gap:6px;margin:2px 0; }
.vv-wd-lbl { font-size:11px;color:#444;white-space:nowrap; }
.vv-wd-val { font-size:12px;color:#bbb;text-align:right; }
.vv-wd-sep { border:none;border-top:1px solid #1e1e1e;margin:6px 0; }
.vv-wd-pill { font-size:10px;padding:1px 6px;border-radius:2px;background:#1e1e1e;color:#666;border:1px solid #272727; }
.vv-wd-pill.ok { background:#0d1f0d;color:#4caf50;border-color:#1a3a1a; }
.vv-wd-pill.warn { background:#1f1500;color:#ffb74d;border-color:#3a2800; }
.vv-wd-pill.err { background:#200d0d;color:#ef5350;border-color:#3a1a1a; }
.vv-wd-pill-row { display:flex;flex-wrap:wrap;gap:4px;margin-top:4px; }
.vv-wd-bar { height:5px;border-radius:2px;background:#1e1e1e;margin-top:3px;overflow:hidden; }
.vv-wd-bar-fill{ height:100%;border-radius:2px;transition:width .3s; }
.vv-wd-badge { font-size:11px;font-weight:bold;padding:2px 8px;border-radius:3px; }
.vv-wd-badge.ok { background:#0d1f0d;color:#4caf50; }
.vv-wd-badge.soft { background:#1f1f00;color:#cddc39; }
.vv-wd-badge.med { background:#1f1000;color:#ffb74d; }
.vv-wd-badge.hard { background:#200d0d;color:#ef5350; }
.vv-wd-node-h { display:flex;align-items:center;gap:8px;margin-bottom:10px; }
.vv-wd-node-id { font-size:12px;font-weight:bold;color:#666;letter-spacing:.06em;text-transform:uppercase; }
.vv-wd-dot { width:6px;height:6px;border-radius:50%;flex-shrink:0; }
.vv-wd-strike-row { display:flex;align-items:baseline;gap:6px;margin:2px 0; }
.vv-wd-strike-name{ font-size:11px;color:#777;flex:1; }
.vv-wd-strike-cnt { font-size:11px;font-weight:bold;color:#ffb74d; }
.vv-wd-reboot-ts { font-size:11px;color:#555;margin:1px 0; }
.vv-wd-ctr-row { display:flex;justify-content:space-between;align-items:baseline;margin:2px 0; }
.vv-wd-ctr-name{ font-size:11px;color:#888; }
.vv-wd-ctr-lim { font-size:11px;color:#555; }
.vv-wd-pressure{ grid-column:1/-1;border-color:#3a2000;background:#1a1000; }
</style>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;padding:0 2px;">
<span style="font-size:13px;font-weight:bold;color:#888;text-transform:uppercase;letter-spacing:.06em;">Watchdog</span>
<span style="font-size:11px;color:#3a3a3a;" id="vv-wd-ts"></span>
</div>
<div id="vv-wd-grid" style="display:grid;grid-template-columns:repeat(8,1fr);gap:12px;">
<div style="grid-column:1/-1;color:#444;font-size:12px;padding:16px 0;text-align:center;">Loading…</div>
</div>
<script>
(function() {
const GB = 1073741824;
function _relTime(ts) {
if (!ts) return '—';
const d = Math.floor(Date.now() / 1000) - ts;
if (d < 60) return 'just now';
if (d < 3600) return Math.floor(d / 60) + 'm ago';
if (d < 86400)return Math.floor(d / 3600) + 'h ' + Math.floor((d % 3600) / 60) + 'm ago';
return Math.floor(d / 86400) + 'd ago';
}
function _fmtBytes(b) {
if (b >= GB) return (b / GB).toFixed(1) + ' GB';
if (b >= 1048576) return (b / 1048576).toFixed(0) + ' MB';
return (b / 1024).toFixed(0) + ' KB';
}
function _dur(s) {
const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60);
if (d) return d + 'd ' + h + 'h';
if (h) return h + 'h ' + m + 'm';
return m + 'm';
}
function _row(lbl, val) {
return `<div class="vv-wd-row"><span class="vv-wd-lbl">${lbl}</span><span class="vv-wd-val">${val}</span></div>`;
}
function _bar(pct, col) {
return `<div class="vv-wd-bar"><div class="vv-wd-bar-fill" style="width:${Math.min(pct,100)}%;background:${col}"></div></div>`;
}
function _pill(label, cls) {
return `<span class="vv-wd-pill ${cls}">${label}</span>`;
}
function _levelLabel(level) {
return ['OK', 'SOFT', 'MEDIUM', 'HARD'][level] || '?';
}
function _levelCls(level) {
return ['ok', 'soft', 'med', 'hard'][level] || 'ok';
}
// ── Pressure alert card ───────────────────────────────────────────────────────
function _pressureCard(node) {
const st = node.states;
if (!st || st.rw_level === 0) return '';
const level = st.rw_level;
const cls = _levelCls(level);
const label = _levelLabel(level);
const paused = (st.rw_paused || []).filter(Boolean);
const stopped = (st.rw_stopped || []).filter(Boolean);
const pausedHtml = paused.length ? paused.map(c => _pill(c, 'warn')).join('') : '';
const stoppedHtml = stopped.length ? stopped.map(c => _pill(c, 'err')).join('') : '';
return `<div class="vv-wd-card vv-wd-pressure" style="grid-column:1/-1">
<div style="display:flex;align-items:center;gap:10px;margin-bottom:8px;">
<span class="vv-wd-badge ${cls}">PRESSURE ${label}</span>
<span style="font-size:11px;color:#7a5020;">${node.id} (${node.hostname})</span>
${st.mem_shutdown ? `<span class="vv-wd-badge hard" style="margin-left:auto;">MEM SHUTDOWN ACTIVE</span>` : ''}
</div>
${paused.length ? `<div style="margin-bottom:4px;"><span class="vv-wd-lbl">Paused:</span> <span class="vv-wd-pill-row" style="display:inline-flex;">${pausedHtml}</span></div>` : ''}
${stopped.length ? `<div><span class="vv-wd-lbl">Stopped:</span> <span class="vv-wd-pill-row" style="display:inline-flex;">${stoppedHtml}</span></div>` : ''}
</div>`;
}
// ── System health card ────────────────────────────────────────────────────────
function _systemCard(node, cfg) {
const sys = node.system;
if (!sys) {
return `<div class="vv-wd-card" style="grid-column:span 2;">
<div class="vv-wd-node-h">
<span class="vv-wd-dot" style="background:#444"></span>
<span class="vv-wd-node-id">${node.id}</span>
<span style="font-size:11px;color:#3a3a3a;">${node.hostname}</span>
<span class="vv-wd-badge" style="margin-left:auto;background:#1a1a1a;color:#444">UNREACHABLE</span>
</div>
</div>`;
}
const memGb = sys.mem_avail / GB;
const memTotGb = sys.mem_total / GB;
const usedPct = memTotGb > 0 ? ((memTotGb - memGb) / memTotGb) * 100 : 0;
const memCol = memGb < cfg.sys_mem_gb ? '#ef5350'
: memGb < cfg.rw_hard_gb ? '#ef5350'
: memGb < cfg.rw_medium_gb ? '#ffb74d'
: memGb < cfg.rw_soft_gb ? '#cddc39' : '#4caf50';
const loadPct = sys.cores > 0 ? (sys.load1 / (sys.cores * cfg.rw_load_med)) * 100 : 0;
const loadCol = sys.load1 > sys.cores * cfg.rw_load_med ? '#ef5350'
: sys.load1 > sys.cores * cfg.rw_load_soft ? '#ffb74d'
: '#4caf50';
const daemonDot = sys.daemon_ok ? '#4caf50' : '#ef5350';
const st = node.states || {};
const level = st.rw_level || 0;
const dotCol = level >= 3 ? '#ef5350' : level >= 2 ? '#ffb74d' : level >= 1 ? '#cddc39' : '#4caf50';
return `<div class="vv-wd-card" style="grid-column:span 2;">
<div class="vv-wd-node-h">
<span class="vv-wd-dot" style="background:${dotCol}"></span>
<span class="vv-wd-node-id">${node.id}</span>
<span style="font-size:11px;color:#3a3a3a;">${node.hostname}</span>
<span class="vv-wd-badge ${_levelCls(level)}" style="margin-left:auto;">${_levelLabel(level)}</span>
</div>
<div class="vv-wd-sec">System</div>
${_row('RAM free', `<span style="color:${memCol}">${_fmtBytes(sys.mem_avail)}</span> / ${_fmtBytes(sys.mem_total)}`)}
${_bar(usedPct, memCol)}
<div style="display:flex;justify-content:space-between;margin-top:1px;font-size:10px;color:#333">
<span>free</span>
<span>${cfg.rw_soft_gb}G soft · ${cfg.rw_hard_gb}G hard · ${cfg.sys_mem_gb}G reboot</span>
</div>
<div style="height:5px"></div>
${_row('Load avg', `<span style="color:${loadCol}">${sys.load1.toFixed(2)}</span> / ${sys.cores} cores`)}
${_bar(loadPct, loadCol)}
<div style="height:5px"></div>
${_row('Uptime', _dur(sys.uptime))}
${_row('Docker', `<span style="color:${daemonDot}">${sys.daemon_ok ? 'daemon ok' : 'daemon err'}</span>`)}
${sys.oom_count > 0 ? _row('OOM kills', `<span style="color:#ef5350">${sys.oom_count}</span>`) : _row('OOM kills', '<span style="color:#333">0</span>')}
</div>`;
}
// ── Docker watchdog state card ────────────────────────────────────────────────
function _dockerCard(node, cfg) {
const st = node.states;
if (!st) return `<div class="vv-wd-card" style="grid-column:span 2;"><div class="vv-wd-sec">Docker Watchdog</div><div style="color:#3a3a3a;font-size:11px;padding:8px 0;">No data</div></div>`;
const strikes = Object.entries(st.ctr_strikes || {});
const skiplist = st.skiplist || [];
const restarts = (st.restarts || []).slice(0, 10);
const daemonCls = st.daemon_strikes > 0 ? 'err' : 'ok';
const allOk = strikes.length === 0 && skiplist.length === 0 && st.daemon_strikes === 0;
// Group restarts by container for last-24h summary
const rCounts = {};
for (const r of restarts) {
rCounts[r.name] = (rCounts[r.name] || 0) + 1;
}
let strikesHtml = '';
if (strikes.length === 0 && st.daemon_strikes === 0) {
strikesHtml = '<div style="color:#333;font-size:11px;">0 active strikes</div>';
} else {
if (st.daemon_strikes > 0) {
strikesHtml += `<div class="vv-wd-strike-row"><span class="vv-wd-strike-name">daemon</span><span class="vv-wd-strike-cnt">${st.daemon_strikes}</span></div>`;
}
for (const [name, cnt] of strikes) {
strikesHtml += `<div class="vv-wd-strike-row"><span class="vv-wd-strike-name">${name}</span><span class="vv-wd-strike-cnt">${cnt} / ${cfg.cpu_fail_lim}</span></div>`;
}
}
let skipHtml = '';
if (skiplist.length === 0) {
skipHtml = '<div style="color:#333;font-size:11px;">empty</div>';
} else {
skipHtml = `<div class="vv-wd-pill-row">${skiplist.map(c => _pill(c, 'err')).join('')}</div>`;
}
let restartHtml = '';
const rcEntries = Object.entries(rCounts);
if (rcEntries.length === 0) {
restartHtml = '<div style="color:#333;font-size:11px;">none (24h)</div>';
} else {
restartHtml = rcEntries.map(([n, c]) =>
`<div class="vv-wd-row"><span class="vv-wd-lbl">${n}</span><span class="vv-wd-val" style="color:${c >= cfg.restart_limit ? '#ef5350' : '#ffb74d'}">${c}×</span></div>`
).join('');
}
return `<div class="vv-wd-card" style="grid-column:span 3;">
<div class="vv-wd-sec">Docker Watchdog</div>
<div style="display:flex;gap:6px;margin-bottom:8px;">
${_pill(st.daemon_restart ? 'daemon restarted' : 'daemon ok', daemonCls)}
${allOk ? _pill('all clear', 'ok') : ''}
</div>
<div class="vv-wd-sec">Strikes</div>
${strikesHtml}
<hr class="vv-wd-sep">
<div class="vv-wd-sec">Skip list</div>
${skipHtml}
<hr class="vv-wd-sep">
<div class="vv-wd-sec">Restarts (24h)</div>
${restartHtml}
</div>`;
}
// ── Stability / reboot card ───────────────────────────────────────────────────
function _stabilityCard(node, cfg) {
const st = node.states;
if (!st) return `<div class="vv-wd-card" style="grid-column:span 1;"><div class="vv-wd-sec">Stability</div><div style="color:#3a3a3a;font-size:11px;padding:8px 0;">No data</div></div>`;
const reboots = st.reboots || [];
const sysStr = Object.entries(st.sys_strikes || {});
const rebootCls = reboots.length >= cfg.reboot_limit ? 'err' : reboots.length > 0 ? 'warn' : 'ok';
let sysHtml = '';
if (sysStr.length === 0) {
sysHtml = '<div style="color:#333;font-size:11px;">0 active strikes</div>';
} else {
sysHtml = sysStr.map(([k, v]) =>
`<div class="vv-wd-strike-row"><span class="vv-wd-strike-name" style="font-size:10px;">${k.replace(/_/g,' ')}</span><span class="vv-wd-strike-cnt">${v}</span></div>`
).join('');
}
const rebootHtml = reboots.length === 0
? '<div style="color:#333;font-size:11px;">none (12h)</div>'
: reboots.map(ts => `<div class="vv-wd-reboot-ts">${_relTime(ts)}</div>`).join('');
return `<div class="vv-wd-card" style="grid-column:span 3;">
<div class="vv-wd-sec">Stability</div>
<div style="display:flex;gap:6px;margin-bottom:8px;">
${_pill(`${reboots.length} / ${cfg.reboot_limit} reboots`, rebootCls)}
${_pill(`${cfg.reboot_window}h window`, '')}
</div>
<div class="vv-wd-sec">Strikes</div>
${sysHtml}
<hr class="vv-wd-sep">
<div class="vv-wd-sec">Reboot history</div>
${rebootHtml}
</div>`;
}
// ── Config inventory card ─────────────────────────────────────────────────────
function _configCard(node) {
const cfg = node.config || {};
const mon = Object.entries(cfg.monitored || {});
const req = cfg.required || [];
const ign = cfg.ignore || [];
const paus = cfg.pause_list|| [];
const stop = cfg.stop_list || [];
const crit = cfg.critical || [];
const monHtml = mon.length === 0
? '<div style="color:#333;font-size:11px;">none</div>'
: mon.map(([name, mb]) => {
const gb = (mb / 1024).toFixed(0);
return `<div class="vv-wd-ctr-row"><span class="vv-wd-ctr-name">${name}</span><span class="vv-wd-ctr-lim">${gb} GB</span></div>`;
}).join('');
const reqHtml = req.length === 0
? '<div style="color:#333;font-size:11px;">none</div>'
: `<div class="vv-wd-pill-row">${req.map(c => _pill(c, crit.includes(c) ? '' : '')).join('')}</div>`;
const ignHtml = ign.length === 0
? '<div style="color:#3a3a3a;font-size:11px;">none</div>'
: `<div class="vv-wd-pill-row">${ign.map(c => _pill(c, '')).join('')}</div>`;
const pausHtml = paus.length === 0
? '<div style="color:#3a3a3a;font-size:11px;">none</div>'
: `<div class="vv-wd-pill-row">${paus.map(c => _pill(c, 'warn')).join('')}</div>`;
const stopHtml = stop.length === 0
? '<div style="color:#3a3a3a;font-size:11px;">none</div>'
: `<div class="vv-wd-pill-row">${stop.map(c => _pill(c, 'err')).join('')}</div>`;
return `<div class="vv-wd-card" style="grid-column:span 4;">
<div style="display:flex;gap:6px;align-items:center;margin-bottom:8px;">
<span class="vv-wd-node-id">${node.id}</span>
<span style="font-size:11px;color:#3a3a3a;">${node.hostname}</span>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;">
<div>
<div class="vv-wd-sec">Mem limits (Tier 1)</div>
${monHtml}
<div style="height:8px"></div>
<div class="vv-wd-sec">Required</div>
${reqHtml}
</div>
<div>
<div class="vv-wd-sec">Pause at medium pressure</div>
${pausHtml}
<div style="height:8px"></div>
<div class="vv-wd-sec">Stop at hard pressure</div>
${stopHtml}
<div style="height:8px"></div>
<div class="vv-wd-sec">Scan ignore</div>
${ignHtml}
</div>
</div>
</div>`;
}
// ── Main render ───────────────────────────────────────────────────────────────
function _render(data) {
const nodes = data.nodes || [];
const cfg = data.cfg || {};
let html = '';
// Pressure alerts (full width, per node)
for (const node of nodes) html += _pressureCard(node);
// System + watchdog state rows per node
for (const node of nodes) {
html += _systemCard(node, cfg);
html += _dockerCard(node, cfg);
html += _stabilityCard(node, cfg);
}
// Config inventory per node
for (const node of nodes) html += _configCard(node);
if (!html) html = '<div style="grid-column:1/-1;color:#444;font-size:12px;padding:16px 0;text-align:center;">No nodes configured.</div>';
document.getElementById('vv-wd-grid').innerHTML = html;
const ts = data.ts
? new Date(data.ts * 1000).toLocaleString([], {
month:'numeric', day:'numeric', year:'numeric',
hour:'2-digit', minute:'2-digit', second:'2-digit'})
: '';
document.getElementById('vv-wd-ts').textContent = ts ? 'Updated: ' + ts : '';
}
function vvWdLoad() {
fetch('/plugins/varaverk/api/watchdog.php')
.then(r => r.json())
.then(_render)
.catch(() => {});
}
vvWdLoad();
setInterval(vvWdLoad, 30000);
})();
</script>
+99
View File
@@ -0,0 +1,99 @@
#!/bin/bash
# Varaverk job runner — wraps script execution with JSON status tracking.
# Called by /etc/cron.d/varaverk for every scheduled job.
#
# Usage: bash run_job.sh <job_id> <script_path> [flags...]
#
# Flags consumed by run_job.sh (stripped before passing to script):
# --manual — marks a UI-triggered run; writes a sentinel on completion so
# the next cron fire is suppressed if it falls within the job's
# own cron interval (prevents double-firing after manual run).
#
# Writes: /var/log/varaverk/<id>.json — status, timestamps, exit code, pid
# /var/log/varaverk/<id>.log — appended per run, trimmed to LOG_MAX_LINES
# /var/log/varaverk/<id>.manual_ts — sentinel: epoch of last manual completion
#
# Status values: running → ok (exit 0) | warn (exit 1) | error (exit 2+)
JOB_ID="$1"
SCRIPT="$2"
shift 2
LOG_DIR="/var/log/varaverk"
BASE="${JOB_ID%.sh}"
LOG_FILE="$LOG_DIR/$BASE.log"
STAT_FILE="$LOG_DIR/$BASE.json"
MANUAL_TS_FILE="$LOG_DIR/$BASE.manual_ts"
LOG_MAX_LINES=1000
# Strip --manual from script args — it's for run_job.sh only
MANUAL=false
SCRIPT_ARGS=()
for _arg in "$@"; do
[[ "$_arg" == "--manual" ]] && MANUAL=true || SCRIPT_ARGS+=("$_arg")
done
unset _arg
mkdir -p "$(dirname "$LOG_FILE")" "$(dirname "$STAT_FILE")"
# Cron invocation: skip if a manual run completed recently within this job's own interval.
if [[ "$MANUAL" == false && -f "$MANUAL_TS_FILE" ]]; then
LAST_MANUAL=$(cat "$MANUAL_TS_FILE" 2>/dev/null || echo 0)
ELAPSED=$(( $(date +%s) - LAST_MANUAL ))
# Only suppress for interval-based crons (*/N * * * *).
# Static schedules like "30 2 * * 0" run at their appointed time and are never suppressed.
INTERVAL=0
SCHEDULE_FILE="/boot/config/plugins/varaverk/schedule.json"
if [[ -f "$SCHEDULE_FILE" ]]; then
CRON_EXPR=$(php -r "
\$s = json_decode(file_get_contents('$SCHEDULE_FILE'), true) ?: [];
echo \$s['$JOB_ID']['cron'] ?? '';
" 2>/dev/null)
if [[ "$CRON_EXPR" =~ ^\*/([0-9]+) ]]; then
INTERVAL=$(( ${BASH_REMATCH[1]} * 60 ))
fi
fi
if (( INTERVAL > 0 && ELAPSED < INTERVAL )); then
printf '\n── %s [SKIPPED — ran manually %ds ago, interval %ds] ────────\n' \
"$(date '+%Y-%m-%d %H:%M:%S')" "$ELAPSED" "$INTERVAL" >> "$LOG_FILE"
printf '{"id":"%s","status":"skipped","manual_elapsed":%s,"interval":%s}\n' \
"$JOB_ID" "$ELAPSED" "$INTERVAL" > "$STAT_FILE"
rm -f "$MANUAL_TS_FILE"
exit 0
fi
rm -f "$MANUAL_TS_FILE"
fi
START=$(date +%s)
printf '{"id":"%s","status":"running","start":%s,"pid":%s}\n' \
"$JOB_ID" "$START" "$$" > "$STAT_FILE"
printf '\n── %s ────────────────────────────────────────────────\n' \
"$(date '+%Y-%m-%d %H:%M:%S')" >> "$LOG_FILE"
bash "$SCRIPT" "${SCRIPT_ARGS[@]}" >> "$LOG_FILE" 2>&1
EC=$?
END=$(date +%s)
if [ "$EC" -eq 0 ]; then STATUS="ok"
elif [ "$EC" -eq 1 ]; then STATUS="warn"
else STATUS="error"
fi
printf '{"id":"%s","status":"%s","start":%s,"end":%s,"exit":%s}\n' \
"$JOB_ID" "$STATUS" "$START" "$END" "$EC" > "$STAT_FILE"
# Write manual sentinel so cron can skip the next fire within the interval
if [[ "$MANUAL" == true ]]; then
echo "$END" > "$MANUAL_TS_FILE"
fi
# Trim to last LOG_MAX_LINES to prevent unbounded growth
line_count=$(wc -l < "$LOG_FILE" 2>/dev/null || echo 0)
if [ "$line_count" -gt "$LOG_MAX_LINES" ]; then
tail -n "$LOG_MAX_LINES" "$LOG_FILE" > "${LOG_FILE}.tmp" && mv "${LOG_FILE}.tmp" "$LOG_FILE"
fi
exit $EC