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
+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());