Add Rsync page, upgrade monitor rsync card, partnership overhaul, API key periodic check, docker watchdog manual-stop detection

This commit is contained in:
Gmer4Lfe
2026-06-03 15:58:50 -04:00
parent 8be54ab5ee
commit de6fcc3997
13 changed files with 2546 additions and 343 deletions
+17 -18
View File
@@ -435,29 +435,19 @@ for lname in "${!USER_MAP[@]}"; do
if [[ -z "$_iid" ]]; then
_ptype="${_pkey%%:*}"
_pval="${_pkey##*:}"
_search_field=""
case "$_ptype" in
imdb) _search_field="imdb.${_pval}" ;;
tmdb) _search_field="tmdb.${_pval##movie:}" ;;
tvdb)
# AnyProviderIdEquals is unreliable for TVDB in Jellyfin 10.x —
# returns the entire library. Search by season+episode instead,
# then validate by ProviderIds.Tvdb.
# pkey format: tvdb:ep:TVDB_EP_ID:sSEASONePEP
_tvdb_ep_id="${_pkey#tvdb:ep:}"
_tvdb_ep_id="${_tvdb_ep_id%%:*}"
if [[ "$_pval" =~ ^s([0-9]+)e([0-9]+)$ ]]; then
_iid=$(_api_get "${SRV_URL[$_si]}" "${SRV_KEY[$_si]}" \
"Items?IncludeItemTypes=Episode&ParentIndexNumber=${BASH_REMATCH[1]}&IndexNumber=${BASH_REMATCH[2]}&Recursive=true&Fields=ProviderIds&Limit=500" 2>/dev/null \
| jq -r --arg tvdb "$_tvdb_ep_id" \
'.Items[] | select(.ProviderIds.Tvdb == $tvdb) | .Id' 2>/dev/null | head -1)
fi
# _pkey format: tvdb:ep:{tvdb_id}:s{season}e{ep}
# ##*: gives "s7e2" (wrong); strip prefix then first :
_tvdb_num="${_pkey#tvdb:ep:}"; _tvdb_num="${_tvdb_num%%:*}"
_search_field="tvdb.${_tvdb_num}"
;;
mb) : ;; # skip music if not found
mb) _search_field="" ;; # skip music if not found
esac
if [[ -z "$_iid" && -n "$_search_field" ]]; then
if [[ -n "$_search_field" ]]; then
_iid=$(_api_get "${SRV_URL[$_si]}" "${SRV_KEY[$_si]}" \
"Items?AnyProviderIdEquals=${_search_field}&Recursive=true&Fields=ProviderIds&Limit=1" 2>/dev/null \
| jq -r '.Items[0].Id // empty' 2>/dev/null)
@@ -477,8 +467,17 @@ for lname in "${!USER_MAP[@]}"; do
if [[ "$_auth_played" == "true" ]]; then
# Mark as played with date
_date_param=""
[[ "$_auth_lplayed" != "null" && -n "$_auth_lplayed" ]] && \
_date_param="?DatePlayed=${_auth_lplayed//[: ]/%3A}"
if [[ "$_auth_lplayed" != "null" && -n "$_auth_lplayed" ]]; then
# Emby's PlayedItems endpoint rejects 7-digit fractional seconds (.0000000)
# with HTTP 500; strip to whole seconds before encoding
if [[ "$_auth_lplayed" == *.* ]]; then
_lp="${_auth_lplayed%.*}"
[[ "$_auth_lplayed" == *Z ]] && _lp+="Z"
else
_lp="$_auth_lplayed"
fi
_date_param="?DatePlayed=${_lp//[: ]/%3A}"
fi
_http=$(_api_post "${SRV_URL[$_si]}" "${SRV_KEY[$_si]}" \
"Users/${_uid}/PlayedItems/${_iid}${_date_param}")
if [[ "$_http" == "200" || "$_http" == "201" ]]; then
+2 -2
View File
@@ -24,9 +24,9 @@ unset($_master, $_h1m, $_host1_blank, $_my_hostid, $_conf_missing);
// Determine active tab
$tab = $_GET['tab'] ?? 'monitor';
$validTabs = ['monitor', 'scheduler', 'docker', 'watchdog', 'partnership', 'fallback', 'arrs'];
$validTabs = ['monitor', 'scheduler', 'docker', 'watchdog', 'partnership', 'fallback', 'arrs', 'rsync'];
if (!in_array($tab, $validTabs)) $tab = 'monitor';
$tabLabels = ['monitor' => 'Monitor', 'scheduler' => 'Scheduler', 'docker' => 'Docker', 'watchdog' => 'Watchdog', 'partnership' => 'Partnership', 'fallback' => 'FallBack', 'arrs' => 'Arrs'];
$tabLabels = ['monitor' => 'Monitor', 'scheduler' => 'Scheduler', 'docker' => 'Docker', 'watchdog' => 'Watchdog', 'partnership' => 'Partnership', 'fallback' => 'FallBack', 'arrs' => 'Arrs', 'rsync' => 'Rsync'];
?>
<link rel="stylesheet" href="/plugins/<?=$plugin?>/css/varaverk.css">
+241
View File
@@ -0,0 +1,241 @@
<?php
header('Content-Type: application/json');
header('Cache-Control: no-store, no-cache');
require_once dirname(__DIR__) . '/include/config.php';
require_once dirname(__DIR__) . '/include/arrs.php';
require_once dirname(__DIR__) . '/include/partnership.php'; // vv_pt_ts_peers, vv_pt_ssh
function _ms_caller(): array {
$host = vv_detect_host();
$id = strtoupper($host);
$raw = vv_read_conf_raw($host . '.conf');
$sshKey = vv_arr_scalar($raw, $id . '_SSH_KEY');
return ['host' => $host, 'ssh_key' => $sshKey];
}
function _ms_target(string $slot, string $sshKey): array {
$vars = vv_conf_vars();
$hostname = $vars[strtoupper($slot)] ?? '';
if (!$hostname) return ['ok' => false, 'error' => 'Unknown host: ' . $slot];
if (!$sshKey || !file_exists($sshKey))
return ['ok' => false, 'error' => 'SSH key not configured on this host'];
$ip = vv_resolve_tailscale_ip($hostname);
if (!$ip) return ['ok' => false, 'error' => 'Cannot reach ' . $hostname . ' via Tailscale'];
return ['ok' => true, 'ip' => $ip, 'hostname' => $hostname];
}
$action = trim($_GET['action'] ?? $_POST['action'] ?? '');
// ── hosts ─────────────────────────────────────────────────────────────────────
if ($action === 'hosts') {
['host' => $current, 'ssh_key' => $sshKey] = _ms_caller();
$all = vv_arr_known_hosts();
$peers = vv_pt_ts_peers();
$out = [];
foreach ($all as $slot => $hostname) {
if ($slot === $current) continue;
$label = strtolower($hostname);
$ts = $peers[$label] ?? [];
$out[] = [
'slot' => $slot,
'id' => strtoupper($slot),
'hostname' => $hostname,
'online' => $ts['online'] ?? null,
'ip' => $ts['ip'] ?? null,
];
}
echo json_encode(['ok' => true, 'hosts' => $out, 'has_key' => !empty($sshKey) && file_exists($sshKey), 'ssh_key' => $sshKey]);
exit;
}
// ── browse remote (SSH) ───────────────────────────────────────────────────────
if ($action === 'browse') {
$slot = trim($_GET['host'] ?? '');
$path = trim($_GET['path'] ?? '/mnt/user');
if (!preg_match('#^/[^\0]*$#', $path) || str_contains($path, '..')) {
echo json_encode(['ok' => false, 'error' => 'Invalid path']); exit;
}
['ssh_key' => $sshKey] = _ms_caller();
$t = _ms_target($slot, $sshKey);
if (!$t['ok']) { echo json_encode($t); exit; }
$clean = rtrim($path, '/') ?: '/';
$out = vv_pt_ssh($t['ip'], $sshKey,
'find ' . escapeshellarg($clean) . ' -maxdepth 1 -mindepth 1 -type d 2>/dev/null',
8);
$dirs = array_values(array_filter(array_map('trim', explode("\n", $out))));
sort($dirs);
$dirs = array_slice($dirs, 0, 200);
// If empty, do a quick SSH echo to distinguish "no dirs" from "SSH failed"
if (empty($dirs)) {
$ping = trim(vv_pt_ssh($t['ip'], $sshKey, 'echo ok', 4));
if ($ping !== 'ok') {
echo json_encode(['ok' => false, 'error' => 'SSH connection failed to ' . $t['hostname']]);
exit;
}
}
$parent = ($clean !== '/') ? (dirname($clean) ?: '/') : null;
echo json_encode(['ok' => true, 'path' => $clean, 'dirs' => $dirs, 'parent' => $parent]);
exit;
}
// ── browse local ──────────────────────────────────────────────────────────────
if ($action === 'browse_local') {
$path = trim($_GET['path'] ?? '/mnt/user');
if (!preg_match('#^/[^\0]*$#', $path) || str_contains($path, '..')) {
echo json_encode(['ok' => false, 'error' => 'Invalid path']); exit;
}
$clean = rtrim($path, '/') ?: '/';
if (!is_dir($clean)) {
echo json_encode(['ok' => false, 'error' => 'Not a directory: ' . $clean]); exit;
}
$out = shell_exec('find ' . escapeshellarg($clean) . ' -maxdepth 1 -mindepth 1 -type d 2>/dev/null | sort | head -200') ?: '';
$dirs = array_values(array_filter(array_map('trim', explode("\n", $out))));
$parent = ($clean !== '/') ? (dirname($clean) ?: '/') : null;
echo json_encode(['ok' => true, 'path' => $clean, 'dirs' => $dirs, 'parent' => $parent]);
exit;
}
// ── run (background, returns token) ──────────────────────────────────────────
if ($_SERVER['REQUEST_METHOD'] === 'POST' && $action === 'run') {
$local = trim($_POST['local'] ?? '');
$slot = trim($_POST['host'] ?? '');
$remotePath = trim($_POST['remote_path'] ?? '');
$user = preg_replace('/[^a-z0-9_.-]/i', '', trim($_POST['user'] ?? 'root')) ?: 'root';
$bwLimit = max(0, (int)($_POST['bw_limit'] ?? 0));
$useKey = ($_POST['use_key'] ?? '1') !== '0';
$rawFlags = trim($_POST['flags'] ?? '');
$flags = $rawFlags !== '' ? preg_replace('/[`$!|&;><(){}\[\]\\\\]/', '', $rawFlags) : '-av --stats';
foreach (['local' => $local, 'host' => $slot, 'remote_path' => $remotePath] as $f => $v) {
if (!$v) { echo json_encode(['ok' => false, 'error' => 'Missing: ' . $f]); exit; }
}
foreach ([$local, $remotePath] as $p) {
if (!str_starts_with($p, '/') || str_contains($p, '..') || preg_match('/[\x00\n\r`$]/', $p)) {
echo json_encode(['ok' => false, 'error' => 'Invalid path: ' . $p]); exit;
}
}
// ── Safeguards ────────────────────────────────────────────────────────────
// Block syncing from/to dangerous system paths
$blocked = ['/', '/proc', '/sys', '/dev', '/run', '/etc', '/bin', '/sbin',
'/usr', '/lib', '/lib64', '/boot/EFI', '/tmp'];
foreach ($blocked as $b) {
if (rtrim($local, '/') === $b) {
echo json_encode(['ok' => false, 'error' => 'Refusing to sync from system path: ' . $b]); exit;
}
if (rtrim($remotePath, '/') === $b) {
echo json_encode(['ok' => false, 'error' => 'Refusing to sync to system path: ' . $b]); exit;
}
}
// Local source must exist
if (!file_exists($local)) {
echo json_encode(['ok' => false, 'error' => 'Local source does not exist: ' . $local]); exit;
}
// Prevent concurrent manual syncs
foreach (glob('/tmp/vv_ms_*.pid') ?: [] as $pf) {
$pid = (int)trim(@file_get_contents($pf) ?: '0');
if ($pid > 0 && file_exists('/proc/' . $pid)) {
echo json_encode(['ok' => false, 'error' => 'Another manual sync is already running — stop it first.']); exit;
}
@unlink($pf); // stale
}
['ssh_key' => $sshKey] = _ms_caller();
$t = _ms_target($slot, $sshKey);
if (!$t['ok']) { echo json_encode($t); exit; }
// Remote destination must exist
$destCheck = trim(vv_pt_ssh($t['ip'], $sshKey,
'test -d ' . escapeshellarg($remotePath) . ' && echo ok || echo missing', 5));
if ($destCheck === 'missing') {
echo json_encode(['ok' => false, 'error' => 'Remote destination does not exist: ' . $remotePath]); exit;
}
if ($destCheck !== 'ok') {
// SSH check inconclusive — log warning but proceed; rsync will fail cleanly if needed
// (e.g. Tailscale not yet up, rsync error will surface in output)
}
// ── Build and launch ──────────────────────────────────────────────────────
$token = bin2hex(random_bytes(8));
$logFile = '/tmp/vv_ms_' . $token . '.log';
$pidFile = '/tmp/vv_ms_' . $token . '.pid';
if ($useKey && $sshKey && file_exists($sshKey)) {
$sshOpts = 'ssh -i ' . escapeshellarg($sshKey) . ' -o StrictHostKeyChecking=no -o BatchMode=yes -o ConnectTimeout=10';
} else {
$sshOpts = 'ssh -o StrictHostKeyChecking=no -o ConnectTimeout=10';
}
if ($bwLimit) $flags .= ' --bwlimit=' . (int)$bwLimit;
$src = escapeshellarg(rtrim($local, '/') . '/');
$dst = escapeshellarg($user . '@' . $t['ip'] . ':' . rtrim($remotePath, '/') . '/');
// Start rsync in background, capture its PID for stop support
$inner = "rsync $flags -e " . escapeshellarg($sshOpts) . " $src $dst >> " . escapeshellarg($logFile) . " 2>&1 &"
. " RSYNC_PID=\$!;"
. " echo \$RSYNC_PID > " . escapeshellarg($pidFile) . ";"
. " wait \$RSYNC_PID;"
. " echo __DONE__ >> " . escapeshellarg($logFile) . ";"
. " rm -f " . escapeshellarg($pidFile);
shell_exec('nohup bash -c ' . escapeshellarg($inner) . ' &>/dev/null &');
echo json_encode(['ok' => true, 'token' => $token]);
exit;
}
// ── stop ──────────────────────────────────────────────────────────────────────
if ($_SERVER['REQUEST_METHOD'] === 'POST' && $action === 'stop') {
$token = preg_replace('/[^a-f0-9]/', '', trim($_POST['token'] ?? ''));
if (!$token || strlen($token) !== 16) {
echo json_encode(['ok' => false, 'error' => 'Invalid token']); exit;
}
$pidFile = '/tmp/vv_ms_' . $token . '.pid';
$logFile = '/tmp/vv_ms_' . $token . '.log';
$pid = (int)trim(@file_get_contents($pidFile) ?: '0');
if ($pid > 0 && file_exists('/proc/' . $pid)) {
shell_exec('kill -TERM ' . $pid . ' 2>/dev/null');
usleep(400000); // 400ms grace
if (file_exists('/proc/' . $pid)) shell_exec('kill -KILL ' . $pid . ' 2>/dev/null');
}
@unlink($pidFile);
@file_put_contents($logFile, "\n\n--- Cancelled by user ---\n__DONE__\n", FILE_APPEND);
echo json_encode(['ok' => true]);
exit;
}
// ── poll ──────────────────────────────────────────────────────────────────────
if ($action === 'poll') {
$token = preg_replace('/[^a-f0-9]/', '', trim($_GET['token'] ?? ''));
if (!$token || strlen($token) !== 16) {
echo json_encode(['ok' => false, 'error' => 'Invalid token']); exit;
}
$logFile = '/tmp/vv_ms_' . $token . '.log';
$pidFile = '/tmp/vv_ms_' . $token . '.pid';
if (!file_exists($logFile)) {
echo json_encode(['ok' => true, 'output' => '', 'done' => false, 'started' => false]); exit;
}
$content = file_get_contents($logFile) ?: '';
$done = str_contains($content, '__DONE__');
if ($done) {
$content = str_replace(['__DONE__', "\n\n\n"], ['', "\n\n"], $content);
@unlink($logFile);
@unlink($pidFile);
}
// Check if rsync process is actually alive (catches crashes without __DONE__)
$pid = (int)trim(@file_get_contents($pidFile) ?: '0');
if (!$done && $pid > 0 && !file_exists('/proc/' . $pid)) {
$done = true;
$content .= "\n\n--- Process ended unexpectedly ---";
@unlink($pidFile);
}
echo json_encode(['ok' => true, 'output' => $content, 'done' => $done, 'started' => true]);
exit;
}
echo json_encode(['ok' => false, 'error' => 'Unknown action']);
+47
View File
@@ -0,0 +1,47 @@
<?php
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/config.php';
require_once dirname(__DIR__) . '/include/monitor.php';
$base = vv_rsync_status();
$vars = vv_conf_vars();
$base['windows']['fallback'] = ($vars['FALLBACK_RSYNC_ENABLED'] ?? 'true') !== 'false';
// Bandwidth history — last 30 days
$bwLog = DATA_DIR . '/bandwidth_history.db';
$warnGb = (float)($vars['BANDWIDTH_WARN_GB'] ?? 50);
$cutoff = date('Y-m-d', strtotime('-30 days'));
$history = [];
if (file_exists($bwLog)) {
foreach (file($bwLog, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $line) {
$p = explode('|', $line);
if (count($p) < 5) continue;
if ($p[0] < $cutoff) continue;
$history[] = [
'date' => $p[0],
'time' => $p[1],
'profile' => $p[2],
'duration' => (int)$p[3],
'status' => trim($p[4]),
'bytes' => isset($p[5]) ? (int)$p[5] : 0,
];
}
}
echo json_encode([
'enabled' => $base['enabled'],
'windows' => $base['windows'],
'active' => $base['active'],
'last_sync' => $base['last_sync'],
'bw_history' => $history,
'bw_warn_gb' => $warnGb,
'settings' => [
'bw_limit' => (int)($vars['BW_LIMIT'] ?? 0),
'retry_count' => (int)($vars['RETRY_COUNT'] ?? 3),
'sleep' => (int)($vars['SLEEP'] ?? 300),
'bw_warn_gb' => (float)($vars['BANDWIDTH_WARN_GB'] ?? 50),
],
'ts' => time(),
]);
+132
View File
@@ -0,0 +1,132 @@
<?php
header('Content-Type: application/json');
header('Cache-Control: no-store, no-cache');
require_once dirname(__DIR__) . '/include/config.php';
require_once dirname(__DIR__) . '/include/confform.php';
// All PROFILE_* assoc arrays and their UI field keys
const RP_ARRAYS = [
'PROFILE_RSYNC_OPTS' => 'rsync_opts',
'PROFILE_BW_LIMIT' => 'bw_limit',
'PROFILE_RETRY_COUNT' => 'retry_count',
'PROFILE_SLEEP' => 'sleep',
'PROFILE_CRITICAL_CONTAINER_NAMES' => 'critical_containers',
'PROFILE_DELAYED_CONTAINERS' => 'delayed_containers',
'PROFILE_CONTAINER_DELAY' => 'container_delay',
'PROFILE_EXCLUDE_DIRS' => 'exclude_dirs',
'PROFILE_REMOTE_RESTART_CONTAINERS' => 'remote_restart',
];
// Parse [key]="value" or [key]=bare entries from an assoc_array body
function _rp_parse_assoc(string $body): array {
$result = [];
preg_match_all('/\[([^\]]+)\]\s*=\s*(?:"([^"]*)"|([^\s#\n]*))/', $body, $m, PREG_SET_ORDER);
foreach ($m as $match) {
$key = $match[1];
$val = $match[2] !== '' ? $match[2] : ($match[3] ?? '');
$result[$key] = $val;
}
return $result;
}
// Rebuild assoc_array body from entries map
function _rp_build_assoc(array $entries): string {
$lines = [];
foreach ($entries as $k => $v) {
// Quote if empty, has spaces or special shell chars
if ($v === '' || preg_match('/[\s\$\!\[\]\(\)\|\'`\\\\]/', $v)) {
$lines[] = ' [' . $k . ']="' . str_replace('"', '\\"', $v) . '"';
} else {
$lines[] = ' [' . $k . ']=' . $v;
}
}
return implode("\n", $lines);
}
// Read all PROFILE_* arrays from master.conf → [profile_name => [field => value]]
function _rp_read_all(): array {
$raw = vv_read_conf_raw('master.conf');
$result = [];
foreach (RP_ARRAYS as $varName => $fieldKey) {
if (!preg_match('/declare\s+-A\s+' . preg_quote($varName, '/') . '\s*=\s*\((.*?)\)/s', $raw, $m)) continue;
foreach (_rp_parse_assoc($m[1]) as $profile => $value) {
$result[$profile][$fieldKey] = $value;
}
}
return $result;
}
$action = trim($_GET['action'] ?? $_POST['action'] ?? '');
// ── list ──────────────────────────────────────────────────────────────────────
if ($action === 'list') {
echo json_encode(['ok' => true, 'profiles' => _rp_read_all()]);
exit;
}
// ── save (create or update) ───────────────────────────────────────────────────
if ($_SERVER['REQUEST_METHOD'] === 'POST' && $action === 'save') {
$name = trim($_POST['name'] ?? '');
if (!$name || !preg_match('/^[a-zA-Z0-9_\-]+$/', $name)) {
echo json_encode(['ok' => false, 'error' => 'Invalid profile name — use letters, numbers, hyphens, underscores']); exit;
}
$raw = vv_read_conf_raw('master.conf');
$changes = [];
foreach (RP_ARRAYS as $varName => $fieldKey) {
$value = trim($_POST[$fieldKey] ?? '');
// Find and parse current array body
if (!preg_match('/declare\s+-A\s+' . preg_quote($varName, '/') . '\s*=\s*\((.*?)\)/s', $raw, $m)) continue;
$entries = _rp_parse_assoc($m[1]);
$entries[$name] = $value;
$changes[] = [
'file' => 'master.conf',
'key' => $varName,
'type' => 'assoc_array',
'value' => _rp_build_assoc($entries),
];
}
if (!$changes) { echo json_encode(['ok' => false, 'error' => 'No profile arrays found in master.conf']); exit; }
$results = vv_conf_write_changes($changes);
$ok = !in_array(false, $results, true);
if ($ok) { vv_push_master_conf(); vv_push_setup_state(); }
echo json_encode(['ok' => $ok, 'results' => $results]);
exit;
}
// ── delete ────────────────────────────────────────────────────────────────────
if ($_SERVER['REQUEST_METHOD'] === 'POST' && $action === 'delete') {
$name = trim($_POST['name'] ?? '');
if (!$name || !preg_match('/^[a-zA-Z0-9_\-]+$/', $name)) {
echo json_encode(['ok' => false, 'error' => 'Invalid profile name']); exit;
}
$raw = vv_read_conf_raw('master.conf');
$changes = [];
foreach (RP_ARRAYS as $varName => $fieldKey) {
if (!preg_match('/declare\s+-A\s+' . preg_quote($varName, '/') . '\s*=\s*\((.*?)\)/s', $raw, $m)) continue;
$entries = _rp_parse_assoc($m[1]);
if (!array_key_exists($name, $entries)) continue;
unset($entries[$name]);
$changes[] = [
'file' => 'master.conf',
'key' => $varName,
'type' => 'assoc_array',
'value' => _rp_build_assoc($entries),
];
}
if (!$changes) { echo json_encode(['ok' => false, 'error' => 'Profile not found']); exit; }
$results = vv_conf_write_changes($changes);
$ok = !in_array(false, $results, true);
if ($ok) { vv_push_master_conf(); vv_push_setup_state(); }
echo json_encode(['ok' => $ok]);
exit;
}
echo json_encode(['ok' => false, 'error' => 'Unknown action']);
+20
View File
@@ -367,10 +367,30 @@ function vv_rsync_status(): array {
];
}
// Profile activity — last 7 days, aggregated per profile
$bwLog = DATA_DIR . '/bandwidth_history.db';
$cutoff7 = date('Y-m-d', strtotime('-7 days'));
$profiles = [];
if (file_exists($bwLog)) {
foreach (file($bwLog, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $line) {
$p = explode('|', $line);
if (count($p) < 4 || ($p[0] ?? '') < $cutoff7) continue;
$name = $p[2] ?? '';
if (!$name) continue;
if (!isset($profiles[$name])) $profiles[$name] = ['runs' => 0, 'dur' => 0, 'bytes' => 0];
$profiles[$name]['runs']++;
$profiles[$name]['dur'] += (int)($p[3] ?? 0);
$profiles[$name]['bytes'] += (int)($p[5] ?? 0);
}
}
arsort($profiles); // sort by run count descending
$bwSummary = array_slice($profiles, 0, 6, true);
return [
'enabled' => $enabled,
'windows' => $windows,
'active' => $active,
'last_sync' => $lastSync,
'bw_summary' => $bwSummary,
];
}
+24
View File
@@ -30,6 +30,27 @@ function vv_pt_config(): array {
}
// ── Mirror sync health — the partnership's actual job (rsync orchestrators) ──────
function vv_pt_sync_summary(string $logFile): string {
if (!file_exists($logFile)) return '';
$tail = array_filter(array_slice(file($logFile, FILE_IGNORE_NEW_LINES), -30), 'strlen');
foreach (array_reverse(array_values($tail)) as $raw) {
// Strip emoji / Unicode decoration for regex matching
$line = trim(preg_replace('/[\x{1F000}-\x{1FFFF}\x{2600}-\x{27BF}\x{FE0F}]/u', '', $raw));
$line = preg_replace('/\s+/', ' ', $line);
// "Critical sync complete — HOST1 — 1m39s — 2 share(s)"
if (preg_match('/Critical sync complete\s*—\s*\S+\s*—\s*([\w]+)\s*—\s*(.+)/i', $line, $m))
return trim($m[2]) . ' in ' . $m[1];
// "Status: all complete — 0 share(s) synced, 11 job(s) run"
if (preg_match('/Status:\s*all complete\s*—\s*(.+)/i', $line, $m))
return trim($m[1]);
// "Status: X failure(s)" or "Failures: N"
if (preg_match('/Failures:\s*(\d+)/i', $line, $m) && (int)$m[1] > 0)
return (int)$m[1] . ' failure(s)';
}
return '';
}
function vv_pt_sync(): array {
$jobs = [
'critical' => 'Orchestrators/critical_sync_maintenance',
@@ -39,14 +60,17 @@ function vv_pt_sync(): array {
$out = ['jobs' => []];
foreach ($jobs as $key => $base) {
$statFile = LOG_DIR . '/' . $base . '.json';
$logFile = LOG_DIR . '/' . $base . '.log';
$s = file_exists($statFile) ? json_decode(@file_get_contents($statFile), true) : null;
$out['jobs'][$key] = is_array($s) ? [
'status' => $s['status'] ?? 'unknown',
'start' => isset($s['start']) ? (int)$s['start'] : null,
'end' => isset($s['end']) ? (int)$s['end'] : null,
'summary' => vv_pt_sync_summary($logFile),
] : null;
}
$v = vv_conf_vars();
$out['interval_min'] = (int)($v['PARTNERSHIP_SYNC_INTERVAL'] ?? 30);
// Rsync gate flags (master.conf) — global Tier 1 + per-tier Tier 2.
$out['gates'] = [
'global' => ['var' => 'RSYNC_ENABLED', 'on' => ($v['RSYNC_ENABLED'] ?? 'true') === 'true'],
+112 -37
View File
@@ -1,4 +1,10 @@
<?php require_once dirname(__DIR__) . '/include/monitor.php'; ?>
<style>
@keyframes vvRsPulse {
0%,100% { opacity:.5; transform:scaleX(.9); }
50% { opacity:1; transform:scaleX(1.05); }
}
</style>
<div id="vv-api-banner" style="display:none;border-radius:4px;padding:5px 10px;margin-bottom:8px;font-size:11px;"></div>
@@ -1323,69 +1329,138 @@ function vvPollMonitor() {
const windows = rs.windows ?? {};
const active = rs.active ?? [];
const lastSync = rs.last_sync ?? {};
const bwSummary = rs.bw_summary ?? {};
const now = Math.floor(Date.now() / 1000);
const el = document.getElementById('vv-rsync-body');
if (!el) return;
const gColor = enabled ? '#4caf50' : '#555';
let html = `<div style="font-size:10px;color:${gColor};margin-bottom:7px;">● ${enabled ? 'enabled' : 'disabled'}</div>`;
function _dur(s) {
if (!s) return '—';
if (s < 60) return s + 's';
if (s < 3600) return Math.floor(s/60) + 'm' + (s%60 ? String(s%60).padStart(2,'0')+'s' : '');
return Math.floor(s/3600) + 'h' + Math.floor((s%3600)/60) + 'm';
}
function _ago(diff) {
if (diff < 60) return diff + 's';
if (diff < 3600) return Math.floor(diff/60) + 'm';
if (diff < 86400) return Math.floor(diff/3600) + 'h';
return Math.floor(diff/86400) + 'd';
}
function _fmtBytes(b) {
if (!b) return '';
if (b >= 1073741824) return (b/1073741824).toFixed(1) + 'G';
if (b >= 1048576) return (b/1048576).toFixed(0) + 'M';
return (b/1024).toFixed(0) + 'K';
}
// Window badges: C D I W
html += '<div style="display:flex;gap:4px;margin-bottom:8px;">';
const gCol = enabled ? '#4caf50' : '#555';
// ── Header: gate dot + window badges ──────────────────────────────────
let html = `<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;">
<span style="font-size:10px;font-weight:600;color:${gCol};">● ${enabled ? 'ENABLED' : 'DISABLED'}</span>
<div style="display:flex;gap:3px;">`;
[['C','critical'],['D','daily'],['I','intermediate'],['W','weekly']].forEach(([s,k]) => {
const on = windows[k] ?? false;
html += `<span title="${k}" style="font-size:9px;padding:1px 6px;border-radius:3px;
background:${on?'#1a2a1a':'#1a1a1a'};border:1px solid ${on?'#2a5a2a':'#252525'};
color:${on?'#4caf50':'#444'};">${s}</span>`;
const run = active.some(a => a.profile?.includes(k));
const bg = run ? '#1a2a0a' : on ? '#0f1a0f' : '#111';
const brd = run ? '#3a6a1a' : on ? '#1a3a1a' : '#222';
const col = run ? '#8bc34a' : on ? '#4caf50' : '#333';
html += `<span title="${k}" style="font-size:9px;padding:2px 5px;border-radius:2px;
background:${bg};border:1px solid ${brd};color:${col};font-weight:600;">${s}</span>`;
});
html += '</div>';
html += `</div></div>`;
// Active sessions
// ── Profile activity bars (7 days) ────────────────────────────────────
const profEntries = Object.entries(bwSummary);
if (profEntries.length) {
const maxRuns = Math.max(...profEntries.map(([,v]) => v.runs), 1);
const colors = ['#4caf50','#4a9eff','#ffb74d','#ce93d8','#ef5350','#4dd0e1'];
html += `<div style="margin-bottom:6px;">
<div style="font-size:9px;color:#2a2a2a;text-transform:uppercase;letter-spacing:.05em;margin-bottom:5px;">7-day profile activity</div>`;
profEntries.forEach(([name, v], i) => {
const pct = Math.round(v.runs / maxRuns * 100);
const col = colors[i % colors.length];
const bytes = v.bytes > 0 ? _fmtBytes(v.bytes) : '';
const short = name.length > 18 ? name.slice(0,16) + '…' : name;
html += `<div style="margin-bottom:4px;">
<div style="display:flex;justify-content:space-between;align-items:baseline;margin-bottom:1px;">
<span style="font-size:9px;color:#555;overflow:hidden;text-overflow:ellipsis;
white-space:nowrap;max-width:110px;" title="${name}">${short}</span>
<span style="font-size:9px;color:#2a2a2a;flex-shrink:0;margin-left:4px;">
${v.runs}×${bytes ? ' · ' + bytes : ''}</span>
</div>
<div style="height:3px;background:#111;border-radius:2px;overflow:hidden;">
<div style="height:100%;width:${pct}%;background:${col};border-radius:2px;opacity:.7;"></div>
</div>
</div>`;
});
html += `</div>`;
}
// ── Active syncs ───────────────────────────────────────────────────────
if (active.length) {
active.forEach(a => {
const sec = a.elapsed ?? 0;
const dur = sec < 60 ? sec+'s' : sec < 3600 ? Math.floor(sec/60)+'m'+String(sec%60).padStart(2,'0')+'s' : Math.floor(sec/3600)+'h'+Math.floor((sec%3600)/60)+'m';
html += `<div style="display:flex;justify-content:space-between;font-size:10px;margin-bottom:3px;">
<span style="color:#ff9800;font-weight:500;">⟳ ${a.profile}</span>
<span style="color:#ff9800;">${dur}</span>
html += `<div style="background:#0d1a0a;border:1px solid #1a3a0a;border-radius:3px;
padding:4px 8px;margin-bottom:5px;">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:3px;">
<span style="font-size:10px;color:#8bc34a;font-weight:600;">⟳ ${a.profile}</span>
<span style="font-size:10px;color:#6a8a4a;">${_dur(sec)}</span>
</div>
<div style="height:3px;background:#0a0a0a;border-radius:2px;overflow:hidden;">
<div style="height:100%;background:#3a6a1a;border-radius:2px;
animation:vvRsPulse 1.4s ease-in-out infinite;width:60%;"></div>
</div>
</div>`;
});
html += `<div style="height:1px;background:#222;margin:5px 0;"></div>`;
}
// Last sync per window
const syncRows = [['critical','critical'],['daily','daily'],['intermediate','interm'],['weekly','weekly']];
let hasSyncs = false;
let syncHtml = '';
syncRows.forEach(([key, label]) => {
// ── Tier last-run rows with recency bars ───────────────────────────────
const tierMeta = {
critical: {label:'Critical', cadenceSec: 30*60},
daily: {label:'Daily', cadenceSec: 24*3600},
intermediate: {label:'Interm', cadenceSec: 4*3600},
weekly: {label:'Weekly', cadenceSec: 7*86400},
};
html += `<div style="border-top:1px solid #1a1a1a;padding-top:6px;">`;
Object.entries(tierMeta).forEach(([key, meta]) => {
const s = lastSync[key];
if (!s || !s.ts) return;
hasSyncs = true;
const diff = now - s.ts;
const ago = diff < 60 ? diff+'s' : diff < 3600 ? Math.floor(diff/60)+'m' : diff < 86400 ? Math.floor(diff/3600)+'h' : Math.floor(diff/86400)+'d';
const ok = s.status === 'ok' || s.status === 'success';
const warn = s.status === 'warn';
const run = s.status === 'running';
const col = ok ? '#4caf50' : warn ? '#ff9800' : run ? '#4fc3f7' : '#f44336';
const icon = ok ? '✓' : warn ? '!' : run ? '●' : '✗';
syncHtml += `<div style="display:flex;justify-content:space-between;align-items:center;font-size:10px;margin-bottom:3px;">
<span style="color:#555;">${label}</span>
<span style="color:#3a3a3a;">${ago}</span>
<span style="color:${col};">${icon}</span>
const ts = s?.ts ?? 0;
const ok = s?.status === 'ok' || s?.status === 'success';
const warn = s?.status === 'warn';
const run = s?.status === 'running';
const err = s && !ok && !warn && !run && ts > 0;
const col = run ? '#4a9eff' : ok ? '#4caf50' : warn ? '#ff9800' : err ? '#f44336' : '#2a2a2a';
const icon = run ? '⟳' : ok ? '✓' : warn ? '!' : err ? '✗' : '—';
const diff = ts ? now - ts : null;
const pct = ts ? Math.min(100, Math.round((now - ts) / meta.cadenceSec * 100)) : 0;
const barC = pct >= 100 ? '#3a1a1a' : pct >= 75 ? '#2a1a0a' : '#0a1a0a';
const fillC= pct >= 100 ? '#f44336' : pct >= 75 ? '#ff9800' : '#2a6a2a';
html += `<div style="margin-bottom:4px;">
<div style="display:flex;justify-content:space-between;align-items:baseline;margin-bottom:2px;">
<span style="font-size:10px;color:#555;width:46px;flex-shrink:0;">${meta.label}</span>
<span style="font-size:9px;color:#333;flex:1;text-align:right;margin-right:6px;">
${s?.duration ? _dur(s.duration) : ''}</span>
<span style="font-size:9px;color:#2a2a2a;width:28px;text-align:right;margin-right:5px;">
${diff !== null ? _ago(diff) : '—'}</span>
<span style="font-size:10px;color:${col};width:10px;text-align:right;">${icon}</span>
</div>
<div style="height:2px;background:${barC};border-radius:1px;overflow:hidden;">
<div style="height:100%;width:${pct}%;background:${fillC};border-radius:1px;"></div>
</div>
</div>`;
});
if (hasSyncs) {
html += `<div style="font-size:9px;color:#333;text-transform:uppercase;letter-spacing:.05em;margin-bottom:4px;">Last sync</div>`;
html += syncHtml;
}
html += `</div>`;
el.innerHTML = html;
const card = document.getElementById('vv-rsync-card');
if (card) {
card.classList.remove('vv-accent-ok','vv-accent-warn','vv-accent-err');
if (active.length) card.classList.add('vv-accent-ok');
if (!enabled) card.classList.add('vv-accent-err');
else if (active.length) card.classList.add('vv-accent-ok');
}
})();
+416 -128
View File
@@ -66,6 +66,16 @@ textarea.vv-set-input { resize:vertical; white-space:pre; }
<div id="vv-pt-config-body" style="color:#444;font-size:12px;">Loading…</div>
</div>
<!-- Host Settings -->
<div class="vv-card" id="vv-pt-hosts-card" style="margin-bottom:12px;display:none;">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;">
<h3 style="margin:0;">Host Settings</h3>
<button id="vv-pt-hosts-save" class="vv-pt-action-btn run"
onclick="vvPtSaveHosts(this)" style="display:none;">Save</button>
</div>
<div id="vv-pt-hosts-body"></div>
</div>
<!-- Offline countdown warning (shown only when partner has been unreachable) -->
<div id="vv-pt-offline-warn" style="display:none;margin-bottom:12px;"></div>
@@ -74,17 +84,20 @@ textarea.vv-set-input { resize:vertical; white-space:pre; }
<div style="color:#444;font-size:12px;padding:16px 0;text-align:center;">Loading…</div>
</div>
<!-- Mirror sync health -->
<div class="vv-card" id="vv-pt-sync-card" style="margin-bottom:12px;">
<h3>Mirror Sync</h3>
<div id="vv-pt-sync-body" style="color:#444;font-size:12px;">Loading…</div>
</div>
<!-- Array settings (above sync/actions row) -->
<div id="vv-pt-arrays-wrap" style="margin-bottom:12px;display:none;"></div>
<!-- Actions -->
<div class="vv-card" id="vv-pt-actions-card">
<!-- Mirror sync + Actions side by side -->
<div style="display:flex;gap:12px;margin-bottom:12px;align-items:stretch;flex-wrap:wrap;">
<div class="vv-card" id="vv-pt-sync-card" style="flex:1 1 220px;min-width:0;display:flex;flex-direction:column;">
<h3>Mirror Sync</h3>
<div id="vv-pt-sync-body" style="flex:1;color:#444;font-size:12px;">Loading…</div>
</div>
<div class="vv-card" id="vv-pt-actions-card" style="flex:2 1 300px;min-width:0;">
<h3>Actions</h3>
<div id="vv-pt-actions-body" style="color:#444;font-size:12px;">Loading…</div>
</div>
</div>
<!-- Settings -->
<div class="vv-card" id="vv-pt-settings-card" style="margin-top:12px;">
@@ -303,6 +316,165 @@ function vvPtToggleSync(el, varName, enabled) {
.catch(e => { alert('Error: ' + e); el.style.pointerEvents = ''; el.style.opacity = ''; });
}
// ── Host Settings ─────────────────────────────────────────────────────────────
function _renderHostSettings(nodes, isOwner) {
let html = '<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:10px;">';
for (const n of nodes) {
const rdonly = isOwner ? '' : 'readonly style="opacity:.5;cursor:default;"';
html += `<div>
<div class="vv-set-key" style="margin-bottom:4px;">${_vvSetEsc(n.id)}</div>
<input type="text" ${rdonly}
class="vv-set-input vv-pt-host-inp"
data-key="${_vvSetEsc(n.id)}" data-file="master.conf" data-type="scalar"
data-orig="${_vvSetEsc(n.hostname)}"
value="${_vvSetEsc(n.hostname)}"
oninput="vvPtHostChanged(this)"
placeholder="hostname">
</div>`;
}
html += '</div>';
if (!isOwner) html += '<div style="font-size:10px;color:#333;margin-top:6px;">Editing requires host1.</div>';
return html;
}
function vvPtHostChanged(el) {
el.classList.toggle('changed', el.value !== el.dataset.orig);
const any = document.querySelector('.vv-pt-host-inp.changed');
document.getElementById('vv-pt-hosts-save').style.display = any ? '' : 'none';
}
function vvPtSaveHosts(btn) {
const changedEls = document.querySelectorAll('.vv-pt-host-inp.changed');
if (!changedEls.length) return;
const changes = [];
changedEls.forEach(el => changes.push({key: el.dataset.key, file: el.dataset.file, type: el.dataset.type, value: el.value}));
if (!confirm(`Save ${changes.length} host setting(s)?`)) return;
btn.disabled = true; btn.textContent = '⟳ Saving…';
fetch('/plugins/varaverk/api/confform.php', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({
csrf_token: typeof csrf_token !== 'undefined' ? csrf_token : '',
id: '__settings__',
changes: JSON.stringify(changes)
})
})
.then(r => r.json())
.then(d => {
btn.disabled = false;
if (d.ok) {
changedEls.forEach(el => { el.dataset.orig = el.value; el.classList.remove('changed'); });
btn.textContent = '✓ Saved';
setTimeout(() => { btn.textContent = 'Save'; btn.style.display = 'none'; }, 2000);
} else {
btn.textContent = 'Save';
alert('Save failed: ' + (d.error ?? 'Unknown error'));
}
})
.catch(e => { btn.disabled = false; btn.textContent = 'Save'; alert('Error: ' + e); });
}
// ── Array settings cards ───────────────────────────────────────────────────────
let _vvArrFiles = null;
function _vvInitArrayCards() {
if (_vvArrFiles !== null) return;
_vvArrFiles = false; // loading sentinel
fetch('/plugins/varaverk/api/partnership_settings.php?_=' + Date.now())
.then(r => r.json())
.then(d => {
_vvArrFiles = (d.ok && d.files) ? d.files : [];
_vvRenderArrayCards();
})
.catch(() => { _vvArrFiles = []; document.getElementById('vv-pt-arrays-wrap').style.display = 'none'; });
}
function _vvRenderArrayCards() {
const files = _vvArrFiles;
if (!files || !files.length) { document.getElementById('vv-pt-arrays-wrap').style.display = 'none'; return; }
let html = '';
let hasArrays = false;
for (const f of files) {
for (const g of f.groups) {
for (const fld of g.fields) {
if (fld.type === 'scalar') continue;
hasArrays = true;
const rows = Math.min(16, (fld.value.match(/\n/g) || []).length + 2);
const attrs = `class="vv-set-input" data-key="${_vvSetEsc(fld.key)}" `
+ `data-file="${_vvSetEsc(fld.file)}" data-type="${_vvSetEsc(fld.type)}" `
+ `data-orig="${_vvSetEsc(fld.value)}" oninput="vvPtArrChanged(this)"`;
html += `<div class="vv-set-file">
<div class="vv-set-file-hdr" onclick="vvPtToggleNode(this)">
<span>${_vvSetEsc(fld.key)}</span>
<div style="display:flex;align-items:center;gap:6px;">
${fld.desc ? `<span style="font-size:9px;color:#444;font-weight:normal;font-family:inherit;">${_vvSetEsc(fld.desc)}</span>` : ''}
<span class="vv-set-chev">▸</span>
</div>
</div>
<div class="vv-set-file-body" style="display:none;">
<textarea ${attrs} rows="${rows}">${_vvSetEsc(fld.value)}</textarea>
</div>
</div>`;
}
}
}
const wrap = document.getElementById('vv-pt-arrays-wrap');
if (!hasArrays) { wrap.style.display = 'none'; return; }
wrap.style.display = '';
wrap.innerHTML = `<div class="vv-card">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">
<h3 style="margin:0;">Array Settings</h3>
<button id="vv-pt-arrays-save" class="vv-pt-action-btn run"
onclick="vvPtSaveArrays(this)" style="display:none;">Save Changes</button>
</div>
${html}
</div>`;
}
function vvPtArrChanged(el) {
el.classList.toggle('changed', el.value !== el.dataset.orig);
const any = document.querySelector('#vv-pt-arrays-wrap .vv-set-input.changed');
const saveBtn = document.getElementById('vv-pt-arrays-save');
if (saveBtn) saveBtn.style.display = any ? '' : 'none';
}
function vvPtSaveArrays(btn) {
const changedEls = document.querySelectorAll('#vv-pt-arrays-wrap .vv-set-input.changed');
if (!changedEls.length) return;
const changes = [];
changedEls.forEach(el => changes.push({key: el.dataset.key, file: el.dataset.file, type: el.dataset.type, value: el.value}));
if (!confirm(`Save ${changes.length} changed setting(s)?`)) return;
btn.disabled = true; btn.textContent = '⟳ Saving…';
fetch('/plugins/varaverk/api/confform.php', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({
csrf_token: typeof csrf_token !== 'undefined' ? csrf_token : '',
id: '__settings__',
changes: JSON.stringify(changes)
})
})
.then(r => r.json())
.then(d => {
btn.disabled = false;
if (d.ok) {
changedEls.forEach(el => { el.dataset.orig = el.value; el.classList.remove('changed'); });
btn.textContent = '✓ Saved';
_vvArrFiles = null; // invalidate so next init re-fetches
setTimeout(() => _vvInitArrayCards(), 600);
} else {
btn.textContent = 'Save Changes';
alert('Save failed: ' + (d.error ?? 'Unknown error'));
}
})
.catch(e => { btn.disabled = false; btn.textContent = 'Save Changes'; alert('Error: ' + e); });
}
// ── Settings panel ─────────────────────────────────────────────────────────────
let _vvSetLoaded = false;
@@ -343,33 +515,26 @@ function vvPtLoadSettings() {
function vvPtRenderSettings(files) {
let html = '';
for (const f of files) {
// One block per file; partnership scope yields a single section per file, so flatten.
const label = f.file === 'master.conf' ? 'master.conf — shared' : f.file + ' — this host';
const scalarFields = f.groups.flatMap(g => g.fields.filter(fld => fld.type === 'scalar'));
if (!scalarFields.length) continue;
html += `<div class="vv-set-file">
<div class="vv-set-file-hdr" onclick="vvPtToggleNode(this)">
<span>${_vvSetEsc(label)}</span><span class="vv-set-chev">▾</span>
</div>
<div class="vv-set-file-body">`;
for (const g of f.groups) {
for (const fld of g.fields) {
for (const fld of scalarFields) {
const attrs = `class="vv-set-input" data-key="${_vvSetEsc(fld.key)}" `
+ `data-file="${_vvSetEsc(fld.file)}" data-type="${_vvSetEsc(fld.type)}" `
+ `data-orig="${_vvSetEsc(fld.value)}" oninput="vvPtSetChanged(this)"`;
html += `<div class="vv-set-field">
<div class="vv-set-key">${_vvSetEsc(fld.key)}</div>`;
if (fld.desc) html += `<div class="vv-set-desc">${_vvSetEsc(fld.desc)}</div>`;
if (fld.type === 'scalar') {
html += `<input type="text" ${attrs} value="${_vvSetEsc(fld.value)}">`;
} else {
const rows = Math.min(16, (fld.value.match(/\n/g) || []).length + 2);
html += `<textarea ${attrs} rows="${rows}">${_vvSetEsc(fld.value)}</textarea>`;
}
html += `</div>`;
}
html += `<input type="text" ${attrs} value="${_vvSetEsc(fld.value)}"></div>`;
}
html += `</div></div>`;
}
return html;
return html || '<div style="color:#555;font-size:12px;padding:8px 0;">No scalar settings available.</div>';
}
function vvPtToggleNode(hdr) {
@@ -493,45 +658,96 @@ function _syncToggle(gate, dimmed) {
function _renderSync(sync) {
const jobs = sync.jobs || {};
const gates = sync.gates || {};
const intervalMin = sync.interval_min || 30;
const sCol = s => s === 'ok' ? '#4caf50' : s === 'running' ? '#4a9eff'
: s === 'warn' ? '#ff9800' : s === 'error' ? '#f44336' : '#555';
const sLbl = s => s === 'ok' ? '✓ ok' : s === 'running' ? '⟳ running'
: s === 'warn' ? '⚠ warn' : s === 'error' ? '✗ error' : '— never';
const sLbl = s => s === 'ok' ? '✓' : s === 'running' ? '⟳' : s === 'warn' ? '⚠' : s === 'error' ? '✗' : '—';
function _dur(sec) {
if (!sec || sec < 0) return '';
if (sec < 60) return sec + 's';
if (sec < 3600) return Math.floor(sec / 60) + 'm ' + (sec % 60) + 's';
return Math.floor(sec / 3600) + 'h ' + Math.floor((sec % 3600) / 60) + 'm';
}
function _countdown(ts) {
if (!ts) return null;
const sec = ts - Math.floor(Date.now() / 1000);
if (sec <= 0) return 'due';
if (sec < 60) return 'in ' + sec + 's';
if (sec < 3600) return 'in ' + Math.floor(sec / 60) + 'm';
return 'in ' + Math.floor(sec / 3600) + 'h ' + Math.floor((sec % 3600) / 60) + 'm';
}
const globalOff = gates.global && !gates.global.on;
// Master toggle row — controls all mirroring (RSYNC_ENABLED, Tier 1).
let html = '';
// Wrap everything in a flex-column so the bottom section anchors naturally
let html = '<div style="display:flex;flex-direction:column;gap:0;height:100%;">';
// Master toggle row
if (gates.global) {
html += `<div class="vv-pt-row" style="margin-bottom:6px;padding-bottom:6px;border-bottom:1px solid #1c1c1c;">
html += `<div class="vv-pt-row" style="margin-bottom:8px;padding-bottom:8px;border-bottom:1px solid #1c1c1c;">
<span class="vv-pt-lbl" style="font-weight:600;color:#999;">All mirroring</span>
<span class="vv-pt-val">${_syncToggle(gates.global, false)}</span>
</div>`;
}
// Per-tier rows: status + last-run + per-tier toggle (dimmed when global is off).
const labels = {critical: 'Critical (30 min)', daily: 'Daily', weekly: 'Weekly'};
// Per-tier rows
const tierMeta = {
critical: {label: 'Critical', sched: 'every 30 min', desc: 'appdata · partnership · play state'},
daily: {label: 'Daily', sched: 'nightly', desc: 'media shares · arr sync · docker'},
weekly: {label: 'Weekly', sched: 'weekly', desc: 'bulk shares · updates · cleanup'},
};
// Critical next-run estimate
const critJob = jobs.critical;
const critNextTs = critJob && critJob.end && critJob.status !== 'running'
? critJob.end + intervalMin * 60 : null;
html += '<div style="display:flex;flex-direction:column;gap:10px;flex:1;">';
for (const key of ['critical', 'daily', 'weekly']) {
const j = jobs[key];
const meta = tierMeta[key];
const status = j ? j.status : null;
const when = j && j.start
? (j.status === 'running' ? 'started ' + _relTime(j.start)
: _relTime(j.end || j.start))
: '';
html += `<div class="vv-pt-row">
<span class="vv-pt-lbl">${labels[key]}</span>
<span class="vv-pt-val">
<span style="color:${sCol(status)};">${sLbl(status)}</span>
<span style="color:#444;font-size:10px;margin-left:6px;">${when}</span>
? (j.status === 'running' ? 'started ' + _relTime(j.start) : _relTime(j.end || j.start))
: null;
const dur = j && j.start && j.end && j.status !== 'running' ? _dur(j.end - j.start) : '';
const summary = j && j.summary ? j.summary : '';
html += `<div style="background:#0f0f0f;border:1px solid #1e1e1e;border-radius:4px;padding:7px 9px;">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:3px;">
<span style="font-size:11px;font-weight:500;color:#aaa;">${meta.label}
<span style="font-size:9px;color:#333;font-weight:normal;margin-left:3px;">${meta.sched}</span>
</span>
<span style="display:flex;align-items:center;gap:5px;">
<span style="font-size:11px;color:${sCol(status)};">${sLbl(status)}</span>
${dur ? `<span style="font-size:9px;color:#3a3a3a;">${dur}</span>` : ''}
${when ? `<span style="font-size:9px;color:#2e2e2e;">${when}</span>` : ''}
${_syncToggle(gates[key], globalOff)}
</span>
</div>
<div style="font-size:9px;color:#2a2a2a;">${meta.desc}</div>
${summary ? `<div style="font-size:9px;color:#3a5a3a;margin-top:3px;font-family:monospace;">${_vvSetEsc(summary)}</div>` : ''}
</div>`;
}
html += '</div>'; // flex:1
// Bottom info strip
const nextLabel = critNextTs ? _countdown(critNextTs) : null;
html += `<div style="margin-top:10px;padding-top:8px;border-top:1px solid #1a1a1a;display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:6px;">
<span style="font-size:10px;color:#2a2a2a;">interval: ${intervalMin}min</span>
${nextLabel ? `<span style="font-size:10px;color:#2e3e2e;">next critical ${nextLabel}</span>` : ''}
</div>`;
if (globalOff) {
html += `<div style="font-size:10px;color:#f44336;margin-top:6px;">⚠ All mirroring is off — per-tier switches have no effect until re-enabled.</div>`;
}
html += '</div>'; // outer flex column
return html;
}
@@ -721,18 +937,22 @@ function _renderActions(nodes, cfg) {
let html = '';
// ── No partner ─────────────────────────────────────────────────────────────
if (!hasPartner && isOwner) {
html += `<div style="font-size:11px;color:#444;margin-bottom:12px;">
No partner configured — edit master.conf (Scheduler tab) and set HOST2.
html += `<div style="background:#0f0f0f;border:1px solid #1e1e1e;border-radius:4px;padding:14px 16px;
display:flex;flex-direction:column;align-items:center;gap:6px;text-align:center;">
<div style="font-size:11px;color:#555;">No partner host configured</div>
<div style="font-size:10px;color:#333;">Set HOST2 in master.conf (Host Settings above) to enable partnership.</div>
</div>`;
}
// ── Per-remote host section ────────────────────────────────────────────────
if (isOwner && hasPartner) {
remotes.forEach(remote => {
remotes.forEach((remote, idx) => {
const phase = remote.onboard_phase ?? 0;
const isOnboarding = !!_vvOnboarding[remote.id];
const dotCol = remote.ts_online === null ? '#555' : remote.ts_online ? '#4caf50' : '#f44336';
const isDeleting = !!_vvDeleteKeys[remote.id];
const dotCol = remote.ts_online === null ? '#444' : remote.ts_online ? '#4caf50' : '#f44336';
const dotLbl = remote.ts_online === null ? 'unknown' : remote.ts_online ? 'online' : 'offline';
const pt = remote.partnership || {};
const ptState = (pt.state || '').toUpperCase();
@@ -743,180 +963,235 @@ function _renderActions(nodes, cfg) {
const sysUptime = sys.uptime_sec ? _uptime(sys.uptime_sec) : null;
const sysVer = sys.unraid_version || null;
html += `<div style="margin-bottom:14px;padding-bottom:14px;border-bottom:1px solid #1e1e1e;">`;
// Left border colour by phase
const borderCol = phase === 2 ? '#2a5a2a'
: phase === 1 ? '#4a3000'
: isOnboarding ? '#0e2a4a' : '#222';
// Header: host + online dot + test connection
html += `<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">
<div>
<span style="font-size:10px;color:#444;">${remote.id}</span>
<span style="font-size:12px;color:#bbb;font-weight:500;margin-left:5px;">${remote.hostname}</span>
</div>
if (idx > 0) html += '<div style="height:10px;"></div>';
html += `<div style="background:#0d0d0d;border:1px solid #1e1e1e;border-left:3px solid ${borderCol};
border-radius:4px;overflow:hidden;">`;
// ── Card header ─────────────────────────────────────────────────────────
html += `<div style="display:flex;justify-content:space-between;align-items:center;
padding:8px 10px;background:#111;border-bottom:1px solid #1a1a1a;">
<div style="display:flex;align-items:center;gap:8px;">
<span id="vv-pt-ping-${remote.id}" style="font-size:10px;color:#444;"></span>
<span style="font-size:9px;color:#444;font-family:monospace;">${remote.id}</span>
<span style="font-size:12px;color:#ccc;font-weight:600;">${remote.hostname}</span>
<span style="font-size:9px;color:${dotCol};">● ${dotLbl}</span>
</div>
<div style="display:flex;align-items:center;gap:6px;">
<span id="vv-pt-ping-${remote.id}" style="font-size:9px;color:#444;"></span>
<button class="vv-pt-action-btn info" onclick="vvPtPing(this,'${remote.slot}','${remote.id}')"
style="font-size:10px;padding:2px 8px;" title="SSH echo round-trip">⇄ Test</button>
<span style="font-size:10px;color:${dotCol};">● ${dotLbl}</span>
style="font-size:9px;padding:2px 7px;" title="SSH echo round-trip">⇄ ping</button>
</div>
</div>`;
const isDeleting = !!_vvDeleteKeys[remote.id];
html += `<div style="padding:10px 12px;">`;
// ── Phase 0: not provisioned ─────────────────────────────────────────
// ── Phase 0: not provisioned ───────────────────────────────────────────
if (phase === 0) {
if (isOnboarding) {
html += `<div style="background:#0d0d0d;border:1px solid #1e1e1e;border-radius:4px;padding:10px 12px;">
<div style="display:flex;align-items:baseline;gap:8px;margin-bottom:8px;">
<span style="font-size:9px;color:#4a9eff;background:#0e1a2a;padding:2px 8px;border-radius:10px;font-weight:600;">Step 1</span>
html += `<div style="display:flex;flex-direction:column;gap:10px;">
<div>
<div style="display:flex;align-items:center;gap:6px;margin-bottom:6px;">
<span style="font-size:9px;font-weight:700;color:#4a9eff;background:#0a1828;
padding:2px 8px;border-radius:10px;border:1px solid #1a3a5a;">Step 1</span>
<span style="font-size:11px;color:#888;">Install SSH key on ${remote.hostname}</span>
</div>
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:4px;">
<a href="${termBase}" target="_blank"
style="padding:3px 10px;background:#1a2a3a;color:#7ab;border:1px solid #2e4a6b;
border-radius:3px;text-decoration:none;font-size:10px;white-space:nowrap;">🖥 Open Terminal</a>
<code onclick="navigator.clipboard.writeText('${termCmd}').then(()=>{this.style.color='#4caf50';setTimeout(()=>this.style.color='#555',1500)})"
style="font-size:9px;color:#555;background:#0a0a0a;padding:3px 8px;border-radius:3px;
border:1px solid #1a1a1a;flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;
style="padding:3px 10px;background:#0e1a2a;color:#7ab;border:1px solid #1e3a5a;
border-radius:3px;text-decoration:none;font-size:10px;white-space:nowrap;">Open Terminal</a>
<code onclick="navigator.clipboard.writeText('${termCmd}').then(()=>{this.style.color='#4caf50';setTimeout(()=>this.style.color='#444',1500)})"
style="font-size:9px;color:#444;background:#080808;padding:3px 8px;border-radius:3px;
border:1px solid #181818;flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;
white-space:nowrap;cursor:pointer;" title="Click to copy">${termCmd}</code>
</div>
<div style="font-size:9px;color:#2a2a2a;margin-bottom:14px;">Enter ${remote.hostname} root password when prompted · tab auto-updates on completion</div>
<div style="display:flex;align-items:baseline;gap:8px;margin-bottom:8px;">
<span style="font-size:9px;color:#4caf50;background:#0a1a0a;padding:2px 8px;border-radius:10px;font-weight:600;">Step 2</span>
<div style="font-size:9px;color:#2a2a2a;">Enter ${remote.hostname} root password when prompted · tab auto-updates on completion</div>
</div>
<div style="border-top:1px solid #1a1a1a;padding-top:10px;">
<div style="display:flex;align-items:center;gap:6px;margin-bottom:6px;">
<span style="font-size:9px;font-weight:700;color:#4caf50;background:#0a1a0a;
padding:2px 8px;border-radius:10px;border:1px solid #1a3a1a;">Step 2</span>
<span style="font-size:11px;color:#888;">Push conf</span>
<span style="font-size:9px;color:#2a2a2a;">if key already installed separately</span>
</div>
<button class="vv-pt-action-btn info" onclick="vvPtPushConf(this,'${remote.id}')"
title="Runs --phase1-only --skip-ssh">▶ Push Conf</button>
title="Runs --phase1-only --skip-ssh" style="font-size:11px;">▶ Push Conf</button>
</div>
<div class="vv-pt-actions" style="margin-top:8px;">
<button class="vv-pt-action-btn warn" onclick="vvPtEndOnboard('${remote.id}')" style="opacity:.6;">✕ Close</button>
</div>
<div style="margin-top:10px;padding-top:8px;border-top:1px solid #181818;">
<button class="vv-pt-action-btn warn" onclick="vvPtEndOnboard('${remote.id}')"
style="opacity:.5;font-size:11px;">✕ Close</button>
</div>`;
} else {
html += `<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
<span style="font-size:10px;color:#444;">○ Not provisioned</span>
<button class="vv-pt-action-btn run" onclick="vvPtStartOnboard('${remote.id}')">▶ Onboard</button>
html += `<div style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:8px;">
<div>
<div style="font-size:11px;color:#555;margin-bottom:2px;">Not provisioned</div>
<div style="font-size:9px;color:#2a2a2a;">SSH keys not installed · conf not pushed</div>
</div>
<div style="display:flex;gap:6px;flex-wrap:wrap;">
<button class="vv-pt-action-btn run" onclick="vvPtStartOnboard('${remote.id}')"
style="font-size:11px;">▶ Onboard</button>
<button class="vv-pt-action-btn warn" onclick="vvPtShowDeleteKeys('${remote.id}')"
style="opacity:.5;font-size:11px;">🗑 Delete Keys</button>
style="opacity:.45;font-size:11px;">🗑 Keys</button>
</div>
</div>`;
}
// ── Phase 1: SSH done, awaiting HOST2 ─────────────────────────────────
} else if (phase === 1) {
html += `<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:6px;">
<span style="font-size:10px;color:#ff9800;background:#1a1200;padding:2px 8px;border-radius:10px;border:1px solid #3a2800;">⏳ SSH ready</span>
${remote.ts_ip ? `<span style="font-size:9px;color:#2a2a2a;">${remote.ts_ip}</span>` : ''}
html += `<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:10px;flex-wrap:wrap;margin-bottom:10px;">
<div>
<div style="display:flex;align-items:center;gap:6px;margin-bottom:4px;">
<span style="font-size:10px;font-weight:600;color:#ff9800;background:#1a1200;
padding:2px 9px;border-radius:10px;border:1px solid #3a2800;">SSH ready</span>
${remote.ts_ip ? `<span style="font-size:9px;color:#333;font-family:monospace;">${remote.ts_ip}</span>` : ''}
</div>
<div style="font-size:10px;color:#444;margin-bottom:8px;">
Waiting for ${remote.id} to install Varaverk and complete its onboard. Phase 2 triggers automatically.
<div style="font-size:9px;color:#383838;line-height:1.5;">
Waiting for ${remote.id} to install Varaverk and complete onboard.<br>Phase 2 triggers automatically.
</div>
<div class="vv-pt-actions">
</div>
<div style="display:flex;flex-direction:column;gap:5px;align-items:flex-end;">
<button class="vv-pt-action-btn info" onclick="vvPtPhase2(this,'${remote.id}')"
title="Manually trigger Phase 2 if ${remote.id} auto-notification did not arrive">
▶ Run Phase 2 Manually
</button>
<button class="vv-pt-action-btn warn" onclick="vvPtCancel(this,'${remote.id}')">✕ Cancel</button>
style="font-size:11px;" title="Manually trigger Phase 2">▶ Phase 2</button>
<button class="vv-pt-action-btn warn" onclick="vvPtCancel(this,'${remote.id}')"
style="font-size:11px;">✕ Cancel</button>
<button class="vv-pt-action-btn warn" onclick="vvPtShowDeleteKeys('${remote.id}')"
style="opacity:.6;font-size:11px;">🗑 Delete Keys</button>
style="opacity:.45;font-size:10px;">🗑 Keys</button>
</div>
</div>`;
// ── Phase 2: fully onboarded ───────────────────────────────────────────
} else {
const ptColor = ptState === 'ACTIVE' ? '#4caf50' : ptState === 'INACTIVE' ? '#f44336' : '#555';
html += `<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:6px;">
<span style="font-size:10px;color:#4caf50;background:#0a1a0a;padding:2px 8px;border-radius:10px;border:1px solid #1a4a1a;">✅ Active</span>
${ptDate ? `<span style="font-size:9px;color:#333;">since ${ptDate}</span>` : ''}
html += `<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:10px;flex-wrap:wrap;">
<div>
<div style="display:flex;align-items:center;gap:6px;margin-bottom:5px;">
<span style="font-size:10px;font-weight:600;color:#4caf50;background:#0a1a0a;
padding:2px 9px;border-radius:10px;border:1px solid #1a4a1a;">Active</span>
${ptState ? `<span style="font-size:9px;color:${ptColor};font-weight:500;">${ptState}</span>` : ''}
${ptDate ? `<span style="font-size:9px;color:#2e2e2e;">since ${ptDate}</span>` : ''}
</div>
<div style="display:flex;gap:14px;flex-wrap:wrap;font-size:10px;color:#2a2a2a;margin-bottom:8px;">
<div style="display:flex;flex-wrap:wrap;gap:10px;font-size:9px;color:#333;font-family:monospace;">
${remote.ts_ip ? `<span>${remote.ts_ip}</span>` : ''}
${sysUptime ? `<span>up ${sysUptime}</span>` : ''}
${sysVer ? `<span>unRAID ${sysVer}</span>` : ''}
${ptState ? `<span style="color:${ptColor};">${ptState}</span>` : ''}
${sysUptime ? `<span>up ${sysUptime}</span>` : ''}
</div>
<div class="vv-pt-actions">
</div>
<div style="display:flex;flex-direction:column;gap:5px;align-items:flex-end;">
<button class="vv-pt-action-btn info" onclick="vvPtPhase2(this,'${remote.id}')"
style="opacity:.4;" title="Re-run Phase 2">↻ Re-run Phase 2</button>
style="opacity:.35;font-size:10px;" title="Re-run Phase 2"> Re-run</button>
<button class="vv-pt-action-btn warn" onclick="vvPtShowDeleteKeys('${remote.id}')"
style="opacity:.5;font-size:11px;">🗑 Delete Keys</button>
style="opacity:.4;font-size:10px;">🗑 Keys</button>
</div>
</div>`;
}
// ── Delete Keys panel (any phase) ──────────────────────────────────────
// ── Delete Keys panel ──────────────────────────────────────────────────
if (isDeleting) {
html += `<div style="margin-top:10px;background:#0d0d0d;border:1px solid #2a1a1a;border-radius:4px;padding:10px 12px;">
<div style="font-size:10px;color:#888;margin-bottom:10px;font-weight:600;text-transform:uppercase;letter-spacing:.05em;">Remove SSH access</div>
<div style="display:flex;flex-direction:column;gap:8px;">
html += `<div style="margin-top:10px;padding:10px;background:#0a0a0a;border:1px solid #2a1515;
border-radius:3px;">
<div style="font-size:9px;color:#5a2a2a;font-weight:700;text-transform:uppercase;
letter-spacing:.06em;margin-bottom:8px;">Remove SSH access</div>
<div style="display:flex;flex-direction:column;gap:7px;">
<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;">
<div>
<div style="font-size:11px;color:#bbb;">HOST1 ${remote.id}</div>
<div style="font-size:9px;color:#333;">HOST1's key on ${remote.hostname} · deletes local key pair</div>
<div style="font-size:10px;color:#888;">HOST1 ${remote.id}</div>
<div style="font-size:9px;color:#333;">HOST1's key on ${remote.hostname} · deletes local pair</div>
</div>
<button class="vv-pt-action-btn warn" onclick="vvPtDeleteH1(this,'${remote.id}')"
style="white-space:nowrap;font-size:11px;">✕ Remove HOST1 key</button>
style="white-space:nowrap;font-size:10px;">✕ HOST1 key</button>
</div>
<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;">
<div>
<div style="font-size:11px;color:#bbb;">${remote.id} → HOST1</div>
<div style="font-size:9px;color:#333;">${remote.hostname}'s key on HOST1 · removes from authorized_keys</div>
<div style="font-size:10px;color:#888;">${remote.id} → HOST1</div>
<div style="font-size:9px;color:#333;">${remote.hostname}'s key from authorized_keys</div>
</div>
<button class="vv-pt-action-btn warn" onclick="vvPtDeleteH2(this,'${remote.id}')"
style="white-space:nowrap;font-size:11px;"> Remove ${remote.id} key</button>
style="white-space:nowrap;font-size:10px;"> ${remote.id} key</button>
</div>
</div>
<div class="vv-pt-actions" style="margin-top:10px;">
<div style="margin-top:8px;">
<button class="vv-pt-action-btn info" onclick="vvPtHideDeleteKeys('${remote.id}')"
style="font-size:11px;opacity:.7;"> Done</button>
style="font-size:10px;opacity:.6;"> Done</button>
</div>
</div>`;
}
html += `</div>`;
html += `</div></div>`; // padding + card
});
}
// ── HOST1 local setup (when needed after phase 1+) ────────────────────────
// ── HOST1 local setup ──────────────────────────────────────────────────────
if (isOwner && selfNode && !selfNode.local_done && remotes.some(r => (r.onboard_phase ?? 0) >= 1)) {
html += `<div style="margin-bottom:12px;padding-bottom:12px;border-bottom:1px solid #1e1e1e;">
<div style="font-size:11px;color:#555;margin-bottom:6px;">
${selfNode.id} ${selfNode.hostname} <span style="color:#2a2a2a;">(this server)</span>
html += `<div style="margin-top:10px;padding:10px 12px;background:#0d0d0d;
border:1px solid #1e1e1e;border-left:3px solid #0e2a4a;border-radius:4px;
display:flex;align-items:center;justify-content:space-between;gap:10px;">
<div>
<div style="font-size:11px;color:#888;margin-bottom:2px;">${selfNode.id} ${selfNode.hostname}</div>
<div style="font-size:9px;color:#333;">FolderView3 integration · marks HOST1 ready</div>
</div>
<div class="vv-pt-actions">
<button class="vv-pt-action-btn run" onclick="vvPtLocalSetup(this)"
title="FolderView3, setup state — does not require HOST2">
Complete HOST1 Setup
</button>
style="font-size:11px;white-space:nowrap;"
title="Does not require HOST2 to be online"> Complete Setup</button>
</div>`;
}
// ── Mirror onboard (non-owner) ─────────────────────────────────────────────
if (!isOwner) {
html += `<div style="padding:10px 12px;background:#0d0d0d;border:1px solid #1e1e1e;border-radius:4px;">
<div style="display:flex;align-items:center;justify-content:space-between;gap:10px;">
<div>
<div style="font-size:11px;color:#888;margin-bottom:2px;">Join partnership</div>
<div style="font-size:9px;color:#333;">SSH key setup + notify owner</div>
</div>
<button class="vv-pt-action-btn run" onclick="vvPtOnboard(this)"
${!hasPartner ? 'disabled style="opacity:.35;cursor:default;"' : ''}
style="font-size:11px;white-space:nowrap;"> Onboard</button>
</div>
</div>`;
}
// ── Bottom row ─────────────────────────────────────────────────────────────
html += `<div class="vv-pt-actions" style="padding-top:4px;">`;
if (!isOwner) {
html += `<button class="vv-pt-action-btn run" onclick="vvPtOnboard(this)"
${!hasPartner ? 'disabled style="opacity:.35;cursor:default;"' : 'title="Mirror onboard: SSH key setup + notify owner"'}>
Onboard (Mirror)
</button>`;
}
html += `<button class="vv-pt-action-btn warn" onclick="vvPtOffboard(this)"
// ── Offboard + Transfer ────────────────────────────────────────────────────
html += `<div style="margin-top:12px;padding-top:10px;border-top:1px solid #181818;">`;
html += `<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap;">
<div>
<div style="font-size:11px;color:#666;">Offboard</div>
<div style="font-size:9px;color:#2a2a2a;">End partnership · revoke SSH · restore WebUIs</div>
</div>
<button class="vv-pt-action-btn warn" onclick="vvPtOffboard(this)"
style="font-size:11px;white-space:nowrap;"
${!cfg.enabled ? 'style="opacity:.35;cursor:default;" title="No active partnership"' : ''}>
Offboard
</button></div>`;
</button>
</div>`;
if (cfg.enabled && isOwner) {
const tok = (cfg.transfer_confirm || 'i-understand-this-transfers-ownership')
.replace(/'/g, "\\'");
html += `<div style="margin-top:12px;padding-top:10px;border-top:1px solid #1e1e1e;">
<div style="display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap;">
<span style="font-size:11px;color:#555;">Transfer ownership to the mirror promotes it to owner.</span>
const tok = (cfg.transfer_confirm || 'i-understand-this-transfers-ownership').replace(/'/g, "\\'");
html += `<div style="margin-top:10px;padding-top:10px;border-top:1px dashed #181818;
display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap;">
<div>
<div style="font-size:11px;color:#666;">Transfer ownership</div>
<div style="font-size:9px;color:#2a2a2a;">Promotes mirror to owner · requires sustained health checks</div>
</div>
<button class="vv-pt-action-btn warn" onclick="vvPtTransfer(this, '${tok}')"
style="font-size:11px;white-space:nowrap;"
title="Runs partnership_transfer.sh — requires sustained health checks before switching">
Transfer Ownership
Transfer
</button>
</div>
<div class="vv-pt-transfer-note" style="margin-top:6px;padding:6px 10px;background:#111;border-radius:4px;color:#444;">
Or run manually: bash Partnership/partnership_transfer.sh --confirm=${cfg.transfer_confirm || 'i-understand-this-transfers-ownership'}
</div>
<div style="margin-top:6px;font-size:9px;color:#252525;font-family:monospace;padding:4px 8px;
background:#080808;border-radius:3px;">
bash Partnership/partnership_transfer.sh --confirm=${cfg.transfer_confirm || 'i-understand-this-transfers-ownership'}
</div>`;
}
html += '</div>'; // offboard+transfer section
return html;
}
@@ -925,10 +1200,17 @@ function _renderActions(nodes, cfg) {
function _render(data) {
const cfg = data.config || {};
const nodes = data.nodes || [];
const isOwner = nodes.some(n => n.is_me && n.is_owner);
// Config bar
document.getElementById('vv-pt-config-body').innerHTML = _renderConfig(cfg);
// Host settings — always shown upfront
const hostsCard = document.getElementById('vv-pt-hosts-card');
hostsCard.style.display = nodes.length ? '' : 'none';
document.getElementById('vv-pt-hosts-body').innerHTML = _renderHostSettings(nodes, isOwner);
document.getElementById('vv-pt-hosts-save').style.display = 'none';
// Offline countdown warning
_renderOfflineWarn(cfg);
@@ -946,6 +1228,12 @@ function _render(data) {
// Actions
document.getElementById('vv-pt-actions-body').innerHTML = _renderActions(nodes, cfg);
// Array settings cards above Actions — load once
_vvInitArrayCards();
// Settings panel — host1 only
document.getElementById('vv-pt-settings-card').style.display = isOwner ? '' : 'none';
// Highlight Onboard button when arriving from first-run wizard
if (new URLSearchParams(location.search).get('vv_onboard') === '1') {
const onboardBtn = document.querySelector('#vv-pt-actions-body .vv-pt-action-btn.run');
File diff suppressed because it is too large Load Diff
+27 -10
View File
@@ -466,16 +466,6 @@ $runningScripts = array_unique($runningScripts);
<li><strong>Child (orch OFF)</strong> — enter a cron to run it standalone</li>
<li><strong>Rsync badge</strong> — toggle writes TIER_RSYNC_ENABLED directly to master.conf</li>
<li><strong>Rsync (orch OFF)</strong> — fill location + standalone cron + Save; both required for independent firing</li>
<li class="vv-info-sep">Editor Keyboard Shortcuts</li>
<li><strong>Ctrl+S</strong> — save · <strong>Ctrl+Z / Ctrl+Y</strong> — undo / redo (200 steps)</li>
<li><strong>Ctrl+/</strong> — toggle line comment · <strong>Tab / Shift+Tab</strong> — indent / dedent</li>
<li><strong>Ctrl+F</strong> — find · <strong>Ctrl+H</strong> — find &amp; replace · <strong>Ctrl+G</strong> — go to line</li>
<li><strong>Ctrl+D</strong> — select next occurrence · <strong>Ctrl+L</strong> — select line · <strong>Ctrl+Shift+K</strong> — delete line</li>
<li><strong>Alt+↑/↓</strong> — move line · <strong>Alt+Shift+↓</strong> — duplicate line below</li>
<li><strong>Home</strong> — smart (first non-space → col 0)</li>
<li><strong>( { [ " ` </strong> — auto-close pair; wraps selection; Backspace deletes both when between them</li>
<li><strong>A / A+</strong> in status bar — font size (922px, persists across sessions)</li>
<li><strong>Indent guides</strong> — faint vertical lines every 2 spaces in the background</li>
</ul>
</div>
</div>
@@ -909,6 +899,33 @@ Still the same two servers, two households, the same media stack running itself.
<span class="vv-goto-info" id="vv-goto-info"></span>
<span class="vv-find-x" onclick="vvGotoClose()" title="Close (Esc)">×</span>
</div>
<!-- ── Keyboard Shortcuts — expanded by default, state remembered ── -->
<div class="vv-sug-block" data-save-key="editor-shortcuts" style="margin-bottom:6px;border:1px solid #1e1e1e;border-radius:4px;">
<div class="vv-sug-header" onclick="vvToggleSug(this)" style="padding:5px 8px;">
<span class="vv-sug-chevron">▾</span>
<span style="font-size:11px;font-weight:bold;color:#555;letter-spacing:.06em;text-transform:uppercase;">Keyboard Shortcuts</span>
</div>
<div class="vv-sug-body" style="padding:4px 10px 8px;">
<ul class="vv-info-cols" style="font-size:11px;">
<li><strong>Ctrl+S</strong> — save</li>
<li><strong>Ctrl+Z / Ctrl+Y</strong> — undo / redo (200 steps)</li>
<li><strong>Ctrl+/</strong> — toggle line comment</li>
<li><strong>Tab / Shift+Tab</strong> — indent / dedent</li>
<li><strong>Ctrl+F</strong> — find</li>
<li><strong>Ctrl+H</strong> — find &amp; replace</li>
<li><strong>Ctrl+G</strong> — go to line</li>
<li><strong>Ctrl+D</strong> — select next occurrence</li>
<li><strong>Ctrl+L</strong> — select line</li>
<li><strong>Ctrl+Shift+K</strong> — delete line</li>
<li><strong>Alt+↑/↓</strong> — move line</li>
<li><strong>Alt+Shift+↓</strong> — duplicate line below</li>
<li><strong>Home</strong> — smart (first non-space → col 0)</li>
<li><strong>( { [ " `</strong> — auto-close pair; wraps selection; Backspace deletes both</li>
<li><strong>A / A+</strong> in status bar — font size (922px, persists)</li>
</ul>
</div>
</div>
<div id="vv-editor-wrap">
<pre id="vv-ln-gutter" aria-hidden="true"></pre>
<div id="vv-editor-inner">
+60 -67
View File
@@ -132,7 +132,6 @@
#
# WATCHDOG_STATE_FILE — strike counts, daemon flags (STATE_DIR — survives reboots)
# DOCKER_WATCHDOG_FAILED_FILE — container skip list (STATE_DIR — survives reboots)
# DOCKER_WATCHDOG_MANUAL_STOP_FILE — intentionally stopped containers (STATE_DIR — survives reboots)
# WATCHDOG_CONTAINER_RESTART_LOG — restart history for loop detection (DATA_DIR)
# RW_STATE_FILE — read-only: resource_watchdog RAM emergency flag
#
@@ -150,6 +149,11 @@
# HTTP health check endpoints. Format: "ContainerName:http://host:port"
# Aliased by detect_hosts() → WATCHDOG_CONTAINER_URLS
#
# HOST*_WATCHDOG_CONTAINER_API_CHECKS
# API liveness checks — deeper than HTTP. Format: "ContainerName:URL|APIKey"
# Use endpoints that require a live DB round-trip (e.g. Emby /System/Info).
# Aliased by detect_hosts() → WATCHDOG_CONTAINER_API_CHECKS
#
# HOST*_WATCHDOG_REQUIRED_CONTAINERS
# Containers that must always be running. Aliased by detect_hosts() →
# WATCHDOG_REQUIRED_CONTAINERS
@@ -258,7 +262,7 @@ validate_unraid_cmd "/usr/local/emhttp/plugins/dynamix/scripts/notify" "
# Ensure state files exist
touch "$WATCHDOG_STATE_FILE" "$WATCHDOG_CONTAINER_RESTART_LOG" \
"$DOCKER_WATCHDOG_FAILED_FILE" "$DOCKER_WATCHDOG_MANUAL_STOP_FILE" 2>/dev/null
"$DOCKER_WATCHDOG_FAILED_FILE" 2>/dev/null
# Timeout for all docker commands — configurable via WATCHDOG_DAEMON_TIMEOUT in master.conf
DOCKER_TIMEOUT="${WATCHDOG_DAEMON_TIMEOUT:-20}"
@@ -274,8 +278,6 @@ if [[ "$SHOW_STATUS" == true ]]; then
echo "$ICON_CONTAINERS Required: ${WATCHDOG_REQUIRED_CONTAINERS[*]:-none}"
echo "$ICON_WATCHDOG Scan all: $WATCHDOG_SCAN_ALL"
echo "$ICON_WATCHDOG Ignore: ${WATCHDOG_SCAN_IGNORE[*]:-none}"
_ms_list=$(cat "$DOCKER_WATCHDOG_MANUAL_STOP_FILE" 2>/dev/null | tr '\n' ' ' | xargs)
echo "$ICON_WATCHDOG Manual-stop: ${_ms_list:-none}"
echo "$ICON_WATCHDOG Schedule: every 15 min (cron via watchdog_orchestrator)"
echo "$ICON_WATCHDOG Startup grace: ${WATCHDOG_STARTUP_GRACE}s"
echo "$ICON_WATCHDOG Restart limit: $WATCHDOG_CONTAINER_RESTART_LIMIT in ${WATCHDOG_CONTAINER_RESTART_WINDOW}h"
@@ -330,28 +332,6 @@ remove_from_skip_list() {
warn "$1 recovered — removed from skip list ✅"
}
# Check if container was intentionally stopped (exit 0/143 — clean/SIGTERM)
is_manually_stopped() {
grep -q "^${1}$" "$DOCKER_WATCHDOG_MANUAL_STOP_FILE" 2>/dev/null
}
# Mark container as intentionally stopped — auto-cleared when seen running again
add_to_manual_stop() {
local container="$1" exit_code="$2"
if ! is_manually_stopped "$container"; then
echo "$container" >> "$DOCKER_WATCHDOG_MANUAL_STOP_FILE"
log "$container — stopped cleanly (exit $exit_code) — skipping until restarted"
fi
}
# Remove container from manual-stop list — called when container is seen running again
remove_from_manual_stop() {
if is_manually_stopped "$1"; then
sed -i "/^${1}$/d" "$DOCKER_WATCHDOG_MANUAL_STOP_FILE" 2>/dev/null
log "$1 — running again — removed from manual-stop list ✅"
fi
}
# Log a restart event to the rolling restart history file
log_restart() {
local container="$1"
@@ -656,10 +636,6 @@ CYCLE_START=$(date +%s)
_skip_contents=$(cat "$DOCKER_WATCHDOG_FAILED_FILE" 2>/dev/null | tr '\n' ' ' | xargs)
[[ -n "$_skip_contents" ]] && warn "$ICON_SKIP Skip list active: $_skip_contents — manual intervention needed"
local _manual_stop_contents
_manual_stop_contents=$(cat "$DOCKER_WATCHDOG_MANUAL_STOP_FILE" 2>/dev/null | tr '\n' ' ' | xargs)
[[ -n "$_manual_stop_contents" ]] && log "$ICON_SKIP Manual-stop list: $_manual_stop_contents"
# ── Docker daemon health check — first check every run ──────────────────────────────────
# If daemon is hung all container operations will fail — check first, skip run if down
if ! check_docker_daemon; then
@@ -716,18 +692,9 @@ CYCLE_START=$(date +%s)
fi
if [[ "$STATUS" == "true" ]]; then
# Running — clear any strikes and manual-stop flag
remove_from_manual_stop "$container"
# Running — clear any strikes
set_strikes "$container" 0 "$WATCHDOG_STATE_FILE"
log "$ICON_RUNNING $container — running ✅"
elif is_manually_stopped "$container"; then
log "$container — manually stopped — skipping"
else
# Check if this was a clean/intentional stop before striking
EXIT_CODE=$(timeout "$DOCKER_TIMEOUT" docker inspect -f \
'{{.State.ExitCode}}' "$container" 2>/dev/null || echo "-1")
if [[ "$EXIT_CODE" == "0" || "$EXIT_CODE" == "143" ]]; then
add_to_manual_stop "$container" "$EXIT_CODE"
else
STRIKES=$(get_strikes "$container" "$WATCHDOG_STATE_FILE")
STRIKES=$(( STRIKES + 1 ))
@@ -747,7 +714,6 @@ CYCLE_START=$(date +%s)
esac
fi
fi
fi
done
fi
@@ -843,6 +809,54 @@ CYCLE_START=$(date +%s)
done
fi
# ── API liveness checks ───────────────────────────────────────────────────────────────────
# Catches containers that serve HTTP 200 but are internally frozen (DB lock, deadlocked
# thread, etc.). Endpoint must require a live DB round-trip to respond successfully.
# Format per entry: "URL|APIKey"
if [[ ${#WATCHDOG_CONTAINER_API_CHECKS[@]} -gt 0 ]]; then
for container in "${!WATCHDOG_CONTAINER_API_CHECKS[@]}"; do
IFS='|' read -r _api_url _api_key <<< "${WATCHDOG_CONTAINER_API_CHECKS[$container]}"
# Skip entirely if key is absent or a placeholder — check is optional protection
if [[ -z "$_api_key" || "$_api_key" == "YOUR_API_KEY"* || "$_api_key" == "placeholder"* ]]; then
log "$container — API check skipped (no key configured)"
continue
fi
_api_http=$(curl -s --max-time "$CURL_TIMEOUT" \
-H "X-Emby-Token: ${_api_key}" \
-o /tmp/_varaverk_api_check \
-w "%{http_code}" \
"$_api_url" 2>/dev/null)
_resp=$(cat /tmp/_varaverk_api_check 2>/dev/null)
# 401/403 = wrong key — skip silently, don't penalise the container
if [[ "$_api_http" == "401" || "$_api_http" == "403" ]]; then
log "$container — API check skipped (HTTP $_api_http — key may be wrong or revoked)"
continue
fi
if echo "$_resp" | jq -e '.ServerName // .Id // .Version' >/dev/null 2>&1; then
set_strikes "${container}_api" 0 "$WATCHDOG_STATE_FILE"
else
API_STRIKES=$(get_strikes "${container}_api" "$WATCHDOG_STATE_FILE")
API_STRIKES=$(( API_STRIKES + 1 ))
set_strikes "${container}_api" "$API_STRIKES" "$WATCHDOG_STATE_FILE"
warn "$container — API unresponsive at $_api_url (strike $API_STRIKES/$RESP_FAIL_LIMIT)"
((T1_WARNINGS++))
if [[ "$API_STRIKES" -ge "$RESP_FAIL_LIMIT" ]]; then
result=0
safe_restart "$container" "API unresponsive at $_api_url" || result=$?
if [[ $result -eq 0 ]]; then
set_strikes "${container}_api" 0 "$WATCHDOG_STATE_FILE"
((T1_RESTARTS++))
queue_notify "$container API unresponsive at $_api_url on $(hostname) — restarted" "warning"
fi
fi
fi
done
fi
# ==========================================================================================
# ── TIER 2 — Global Health Scan ───────────────────────────────────────────────────────────
# ==========================================================================================
@@ -850,13 +864,6 @@ CYCLE_START=$(date +%s)
ALL_CONTAINERS=$(timeout "$DOCKER_TIMEOUT" docker ps --format "{{.Names}}" 2>/dev/null)
# ── Auto-clear manual-stop list ───────────────────────────────────────────────────────
# Any container now running was started intentionally — remove from manual-stop list
while IFS= read -r container; do
[[ -z "$container" ]] && continue
remove_from_manual_stop "$container"
done <<< "$ALL_CONTAINERS"
# ── Unhealthy containers ──────────────────────────────────────────────────────────────
if [[ "$WATCHDOG_RESTART_UNHEALTHY" == "true" ]]; then
UNHEALTHY=$(timeout "$DOCKER_TIMEOUT" docker ps \
@@ -961,13 +968,13 @@ CYCLE_START=$(date +%s)
fi
# ── Unexpected exits ──────────────────────────────────────────────────────────────────
# Exit 0/143 = clean/SIGTERM — intentional stop, add to manual-stop list and skip.
# All other non-zero exits = crash — restart via safe_restart.
# Only non-zero exit codes — exit 0 is a clean stop, not a crash
# Skips containers already covered by WATCHDOG_REQUIRED_CONTAINERS (handled in Tier 1)
if [[ "$WATCHDOG_RESTART_CRASHED" == "true" ]]; then
EXITED=$(timeout "$DOCKER_TIMEOUT" docker ps -a \
CRASHED=$(timeout "$DOCKER_TIMEOUT" docker ps -a \
--filter status=exited \
--format "{{.Names}}|{{.Status}}" 2>/dev/null)
--format "{{.Names}}|{{.Status}}" 2>/dev/null | \
grep -v "Exited (0)")
while IFS='|' read -r container status; do
[[ -z "$container" ]] && continue
[[ -n "${IGNORE_MAP[$container]:-}" ]] && continue
@@ -978,20 +985,6 @@ CYCLE_START=$(date +%s)
[[ "$container" == "$req" ]] && already_required=true && break
done
[[ "$already_required" == true ]] && continue
# Extract exit code from status string e.g. "Exited (143) 2 hours ago"
EXIT_CODE="-1"
[[ "$status" =~ Exited\ \(([0-9]+)\) ]] && EXIT_CODE="${BASH_REMATCH[1]}"
# Clean/intentional stop — add to manual-stop list and skip
if [[ "$EXIT_CODE" == "0" || "$EXIT_CODE" == "143" ]]; then
add_to_manual_stop "$container" "$EXIT_CODE"
continue
fi
# Already known to be manually stopped from a prior cycle
is_manually_stopped "$container" && continue
error "$container$status (unexpected exit)"
((T2_WARNINGS++))
result=0
@@ -1000,7 +993,7 @@ CYCLE_START=$(date +%s)
((T2_RESTARTS++))
queue_notify "$container crashed on $(hostname) ($status) — restarted" "warning"
fi
done <<< "$EXITED"
done <<< "$CRASHED"
fi
fi # WATCHDOG_SCAN_ALL
+1
View File
@@ -600,6 +600,7 @@ detect_hosts() {
_alias_assoc "WATCHDOG_CONTAINERS"
_alias_assoc "WATCHDOG_CONTAINER_URLS"
_alias_assoc "WATCHDOG_CONTAINER_API_CHECKS"
_alias_assoc "WATCHDOG_DEPENDENCIES"
_alias_assoc "WATCHDOG_APPDATA_SIZES"