Add Rsync page, upgrade monitor rsync card, partnership overhaul, API key periodic check, docker watchdog manual-stop detection
This commit is contained in:
+17
-18
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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']);
|
||||
@@ -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(),
|
||||
]);
|
||||
@@ -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']);
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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,16 +84,19 @@ 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 -->
|
||||
@@ -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
@@ -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 & 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 (9–22px, 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 & 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 (9–22px, persists)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="vv-editor-wrap">
|
||||
<pre id="vv-ln-gutter" aria-hidden="true"></pre>
|
||||
<div id="vv-editor-inner">
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user