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:
@@ -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(),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
<?php
|
||||
// confform.php — script→conf-section mapping, field parsing, and write-back.
|
||||
|
||||
// Map: script relative id → subsection names (must match # ━━━ Name ━━━ or # ── Name ── headers).
|
||||
const VV_SCRIPT_CONF_SECTIONS = [
|
||||
// Orchestrators
|
||||
'Orchestrators/array_started.sh' => ['Array Start'],
|
||||
'Orchestrators/array_stopping.sh' => ['Array Stop'],
|
||||
'Orchestrators/watchdog_orchestrator.sh' => ['Watchdog Orchestrator', 'System Watchdog'],
|
||||
'Orchestrators/critical_sync_maintenance.sh' => ['Critical Sync Maintenance', 'Critical Sync Shares'],
|
||||
'Orchestrators/intermediate_sync_maintenance.sh' => ['Intermediate Sync Maintenance', 'Intermediate Sync Shares'],
|
||||
'Orchestrators/daily_sync_maintenance.sh' => ['Daily Sync Maintenance', 'Daily Sync Shares'],
|
||||
'Orchestrators/weekly_sync_maintenance.sh' => ['Weekly Sync Maintenance', 'Weekly Sync Shares'],
|
||||
'Orchestrators/monthly_maintenance.sh' => ['Monthly Maintenance'],
|
||||
'Orchestrators/transcode_management.sh' => ['Transcode Manager', 'Transcode Server Array', 'Transcodes'],
|
||||
// Docker Essentials
|
||||
'Docker_Essentials/docker_daily_restart.sh' => ['Docker Daily Restart'],
|
||||
'Docker_Essentials/docker_weekly_restart.sh' => ['Docker Weekly Restart'],
|
||||
'Docker_Essentials/docker_network_connect.sh' => ['Docker Network Connect'],
|
||||
'Docker_Essentials/downloaders_reset.sh' => ['Downloaders Reset', 'Downloaders'],
|
||||
// Watchdogs
|
||||
'Watchdogs/docker_watchdog.sh' => ['Docker Watchdog'],
|
||||
'Watchdogs/resource_watchdog.sh' => ['Pressure Levels'],
|
||||
'Watchdogs/System/network_watchdog.sh' => ['Network Watchdog'],
|
||||
'Watchdogs/System/webgui_watchdog.sh' => ['WebGUI Watchdog'],
|
||||
// Media
|
||||
'Media/media_cleaner.sh' => ['Media Cleaner'],
|
||||
'Media/media_shares_permissions.sh' => ['Media Permissions'],
|
||||
'Media/arrs_failed_stalled_recovery.sh' => ['Arr Failed/Stalled Recovery'],
|
||||
'Media/radarr_cleanup.sh' => ['Arr Cleanup'],
|
||||
'Media/lidarr_cleanup.sh' => ['Arr Cleanup'],
|
||||
'Media/sonarr_cleanup.sh' => ['Arr Cleanup'],
|
||||
// Monitors
|
||||
'Monitors/cert_monitor.sh' => ['Certificate Monitor'],
|
||||
'Monitors/backup_verify.sh' => ['Backup Verify'],
|
||||
'Monitors/smart_health.sh' => ['SMART Health'],
|
||||
'Monitors/bandwidth_monitor.sh' => ['Bandwidth Monitor'],
|
||||
'Monitors/emby_session_report.sh' => ['Emby Session Report'],
|
||||
'Monitors/zfs_memory_snapshot.sh' => ['ZFS Report'],
|
||||
];
|
||||
|
||||
function vv_conf_has_sections(string $id): bool {
|
||||
return !empty(VV_SCRIPT_CONF_SECTIONS[$id] ?? []);
|
||||
}
|
||||
|
||||
// Parse fields from a named subsection in raw conf content.
|
||||
// Headers accepted: # ━━━ Name ━━━ OR # ── Name ── (any mix of ━ ─ chars).
|
||||
// Returns array of field defs, or null if subsection not found.
|
||||
function vv_conf_parse_subsection(string $raw, string $subName, string $filename): ?array {
|
||||
$lines = explode("\n", $raw);
|
||||
$n = count($lines);
|
||||
$needle = mb_strtolower(trim(preg_replace('/\s+/', ' ', $subName)));
|
||||
$start = -1;
|
||||
|
||||
for ($i = 0; $i < $n; $i++) {
|
||||
if (!preg_match('/^#\s*[━─]{2,}\s+([A-Za-z].+?)\s+[━─]{2,}/', $lines[$i], $m)) continue;
|
||||
$t = mb_strtolower(trim(preg_replace('/\s+/', ' ', $m[1])));
|
||||
if ($t === $needle) { $start = $i + 1; break; }
|
||||
}
|
||||
if ($start === -1) return null;
|
||||
|
||||
// End at next section/subsection line (3+ consecutive divider chars after #)
|
||||
$end = $n;
|
||||
for ($i = $start; $i < $n; $i++) {
|
||||
if (preg_match('/^#\s*[━─═=]{3,}/', $lines[$i])) { $end = $i; break; }
|
||||
}
|
||||
|
||||
$fields = [];
|
||||
$pendingDesc = [];
|
||||
|
||||
for ($i = $start; $i < $end; $i++) {
|
||||
$line = rtrim($lines[$i]);
|
||||
|
||||
if ($line === '' || $line === '#') { $pendingDesc = []; continue; }
|
||||
|
||||
// Pure comment line
|
||||
if (preg_match('/^#\s*(.*)$/', $line, $cm)) {
|
||||
$inner = trim($cm[1]);
|
||||
if ($inner !== '' && !preg_match('/^[━─═=\-\s]+$/', $inner)) {
|
||||
$pendingDesc[] = $inner;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
$desc = implode(' ', $pendingDesc);
|
||||
$pendingDesc = [];
|
||||
|
||||
// declare -A KEY=(
|
||||
if (preg_match('/^(\s*)declare\s+-A\s+([A-Z_][A-Z0-9_]*)\s*=\s*\(/', $line, $m)) {
|
||||
$indent = $m[1]; $key = $m[2];
|
||||
$blockLines = [];
|
||||
$j = $i + 1;
|
||||
while ($j < $end && !preg_match('/^\s*\)\s*$/', $lines[$j])) {
|
||||
$blockLines[] = rtrim($lines[$j]);
|
||||
$j++;
|
||||
}
|
||||
$fields[] = ['key' => $key, 'value' => implode("\n", $blockLines),
|
||||
'type' => 'assoc_array', 'desc' => $desc, 'file' => $filename, 'indent' => $indent];
|
||||
$i = $j;
|
||||
continue;
|
||||
}
|
||||
|
||||
// KEY=( (array)
|
||||
if (preg_match('/^(\s*)([A-Z_][A-Z0-9_]*)\s*=\s*\(/', $line, $m)) {
|
||||
$indent = $m[1]; $key = $m[2];
|
||||
// Single-line: KEY=( ... )
|
||||
if (preg_match('/^[^(]*\(([^)]*)\)/', $line, $sm)) {
|
||||
$fields[] = ['key' => $key, 'value' => $sm[1],
|
||||
'type' => 'array_single', 'desc' => $desc, 'file' => $filename, 'indent' => $indent];
|
||||
continue;
|
||||
}
|
||||
// Multi-line
|
||||
$blockLines = [];
|
||||
$j = $i + 1;
|
||||
while ($j < $end && !preg_match('/^\s*\)\s*$/', $lines[$j])) {
|
||||
$blockLines[] = rtrim($lines[$j]);
|
||||
$j++;
|
||||
}
|
||||
$fields[] = ['key' => $key, 'value' => implode("\n", $blockLines),
|
||||
'type' => 'array', 'desc' => $desc, 'file' => $filename, 'indent' => $indent];
|
||||
$i = $j;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Scalar: KEY="value" or KEY=value
|
||||
if (preg_match('/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*"([^"]*)"(?:\s+#\s*(.+))?$/', $line, $m)) {
|
||||
$fields[] = ['key' => $m[1], 'value' => $m[2],
|
||||
'type' => 'scalar', 'desc' => ($m[3] ?? '') ?: $desc, 'file' => $filename];
|
||||
continue;
|
||||
}
|
||||
if (preg_match('/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*([^(\n#]*?)(?:\s+#\s*(.+))?$/', $line, $m)) {
|
||||
$val = trim($m[2]);
|
||||
if ($val === '') continue;
|
||||
$fields[] = ['key' => $m[1], 'value' => $val,
|
||||
'type' => 'scalar', 'desc' => ($m[3] ?? '') ?: $desc, 'file' => $filename];
|
||||
}
|
||||
}
|
||||
|
||||
return $fields ?: null;
|
||||
}
|
||||
|
||||
// Return all conf groups (subsection + fields) for a script on the current host.
|
||||
function vv_conf_fields_for_script(string $id): array {
|
||||
$sectionNames = VV_SCRIPT_CONF_SECTIONS[$id] ?? [];
|
||||
if (!$sectionNames) return [];
|
||||
|
||||
$groups = [];
|
||||
foreach ($sectionNames as $name) {
|
||||
foreach (vv_get_conf_files() as $filename) {
|
||||
$fields = vv_conf_parse_subsection(vv_read_conf_raw($filename), $name, $filename);
|
||||
if ($fields !== null) {
|
||||
$groups[] = ['subsection' => $name, 'file' => $filename, 'fields' => $fields];
|
||||
}
|
||||
}
|
||||
}
|
||||
return $groups;
|
||||
}
|
||||
|
||||
// Write a batch of field changes back to their respective conf files.
|
||||
// Each change: {file, key, value, type}
|
||||
function vv_conf_write_changes(array $changes): array {
|
||||
$byFile = [];
|
||||
foreach ($changes as $c) {
|
||||
if (!empty($c['file']) && !empty($c['key'])) $byFile[$c['file']][] = $c;
|
||||
}
|
||||
|
||||
$results = [];
|
||||
foreach ($byFile as $file => $fileChanges) {
|
||||
$raw = vv_read_conf_raw($file);
|
||||
if ($raw === '') { $results[$file] = false; continue; }
|
||||
|
||||
foreach ($fileChanges as $c) {
|
||||
$qKey = preg_quote($c['key'], '/');
|
||||
$value = $c['value'];
|
||||
$type = $c['type'] ?? 'scalar';
|
||||
|
||||
if ($type === 'scalar') {
|
||||
$raw = preg_replace_callback(
|
||||
'/^(\s*' . $qKey . '\s*=\s*)("(?:[^"\\\\]|\\\\.)*"|\'(?:[^\'\\\\]|\\\\.)*\'|[^#\n]*?)(\s*(?:#[^\n]*)?)$/m',
|
||||
fn($m) => $m[1] . '"' . str_replace(['"', '\\'], ['\\"', '\\\\'], $value) . '"' . $m[3],
|
||||
$raw
|
||||
) ?? $raw;
|
||||
|
||||
} elseif ($type === 'array_single') {
|
||||
$raw = preg_replace_callback(
|
||||
'/^(\s*' . $qKey . '\s*=\s*\()([^)]*)(\)(?:\s*(?:#[^\n]*)?)?)$/m',
|
||||
fn($m) => $m[1] . $value . $m[3],
|
||||
$raw
|
||||
) ?? $raw;
|
||||
|
||||
} elseif ($type === 'array') {
|
||||
$raw = preg_replace_callback(
|
||||
'/^(\s*)(' . $qKey . '\s*=\s*\()[^)]*\)/ms',
|
||||
fn($m) => $m[1] . $m[2] . "\n" . $value . "\n" . $m[1] . ")",
|
||||
$raw
|
||||
) ?? $raw;
|
||||
|
||||
} elseif ($type === 'assoc_array') {
|
||||
$raw = preg_replace_callback(
|
||||
'/^(\s*)(declare\s+-A\s+' . $qKey . '\s*=\s*\()[^)]*\)/ms',
|
||||
fn($m) => $m[1] . $m[2] . "\n" . $value . "\n" . $m[1] . ")",
|
||||
$raw
|
||||
) ?? $raw;
|
||||
}
|
||||
}
|
||||
$results[$file] = vv_write_conf_raw($file, $raw);
|
||||
}
|
||||
return $results;
|
||||
}
|
||||
@@ -0,0 +1,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;
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
@@ -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>';
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
@@ -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),
|
||||
];
|
||||
}
|
||||
@@ -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')),
|
||||
];
|
||||
}
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
@@ -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 '';
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
Reference in New Issue
Block a user