audit echo vs log across all scripts — outcomes always visible, verbose for per-item loops

This commit is contained in:
Gmer4Lfe
2026-06-14 12:40:15 -04:00
parent 4c37ab16fd
commit 3964f6fb46
1010 changed files with 377767 additions and 132 deletions
@@ -0,0 +1,243 @@
<style>
.vv-fb-active { background:#1a1200;border:1px solid #5a3800;border-radius:6px;padding:12px 14px; }
.vv-fb-active-h { display:flex;align-items:baseline;gap:10px;margin-bottom:8px; }
.vv-fb-badge { font-size:11px;font-weight:bold;letter-spacing:.06em;padding:2px 7px;border-radius:3px;flex-shrink:0; }
.vv-fb-badge.fb { background:#5a3800;color:#ffb74d; }
.vv-fb-badge.norm { background:#1a2a1a;color:#4caf50; }
.vv-fb-badge.dark { background:#2a1a2a;color:#9c27b0; }
.vv-fb-badge.nonet{ background:#1a1a2a;color:#5c7cfa; }
.vv-fb-meta { display:flex;gap:18px;flex-wrap:wrap;margin-bottom:10px; }
.vv-fb-meta-item{ display:flex;flex-direction:column;gap:1px; }
.vv-fb-meta-val { font-size:17px;font-weight:bold;color:#ffb74d; }
.vv-fb-meta-lbl { font-size:10px;color:#5a4020; }
.vv-fb-ctrs { display:flex;gap:6px;flex-wrap:wrap;margin-top:6px; }
.vv-fb-ctr { font-size:11px;padding:2px 8px;border-radius:3px;background:#2a1e00;color:#ffb74d;border:1px solid #4a2e00; }
.vv-fb-ctr.running { background:#1a2a1a;color:#6fcf97;border-color:#2d4a2d; }
.vv-fb-ctr.stopped { background:#2a1a1a;color:#e57;border-color:#4a2020;opacity:.7; }
.vv-fb-node { grid-column:span 4; }
.vv-fb-node-h { display:flex;align-items:baseline;gap:8px;margin-bottom:10px; }
.vv-fb-node-id { font-size:12px;font-weight:bold;color:#666;letter-spacing:.06em;text-transform:uppercase; }
.vv-fb-node-nm { font-size:11px;color:#3a3a3a; }
.vv-fb-arrow { font-size:11px;color:#333; }
.vv-fb-covers { font-size:11px;color:#3a3a3a; }
.vv-fb-tier { margin-bottom:8px; }
.vv-fb-tier-h { display:flex;align-items:baseline;gap:6px;margin-bottom:4px; }
.vv-fb-tier-lbl { font-size:11px;font-weight:bold;color:#555;text-transform:uppercase;letter-spacing:.04em; }
.vv-fb-tier-delay { font-size:10px;color:#3a3a3a; }
.vv-fb-tier-pills { display:flex;gap:5px;flex-wrap:wrap; }
.vv-fb-pill { font-size:11px;padding:2px 7px;border-radius:3px;background:#1e1e1e;color:#777;border:1px solid #2a2a2a; }
.vv-fb-pill.active-t { background:#1a2010;color:#8bc34a;border-color:#2d3a1d; }
.vv-fb-pill.empty { color:#333;font-style:italic; }
.vv-fb-state-dot { width:6px;height:6px;border-radius:50%;flex-shrink:0;margin-top:3px; }
.vv-fb-sep { border:none;border-top:1px solid #222;margin:8px 0; }
.vv-fb-disabled { grid-column:1/-1;color:#3a3a3a;font-size:12px;padding:20px 0;text-align:center; }
</style>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;padding:0 2px;">
<span style="font-size:13px;font-weight:bold;color:#888;text-transform:uppercase;letter-spacing:.06em;">FallBack</span>
<span style="font-size:11px;color:#3a3a3a;" id="vv-fb-ts"></span>
</div>
<div id="vv-fb-grid" style="display:grid;grid-template-columns:repeat(8,1fr);gap:12px;">
<div style="grid-column:1/-1;color:#444;font-size:12px;padding:16px 0;text-align:center;">Loading…</div>
</div>
<script>
(function() {
function _dur(startTs) {
if (!startTs || startTs === 0) return '—';
const s = Math.floor(Date.now() / 1000) - startTs;
if (s < 60) return s + 's';
if (s < 3600) return Math.floor(s / 60) + 'm';
const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60);
return m ? h + 'h ' + m + 'm' : h + 'h';
}
function _fmtDelay(min) {
if (!min) return 'immediate';
if (min < 60) return min + 'min';
const h = Math.floor(min / 60), m = min % 60;
return m ? h + 'h ' + m + 'm' : h + 'h';
}
function _activeTier(state) {
if (!state) return 0;
if (state.tier4_started) return 4;
if (state.tier3_started) return 3;
if (state.tier2_started) return 2;
return 1;
}
function _stateBadge(st) {
const map = {
NORMAL: ['norm', 'NORMAL'],
FALLBACK: ['fb', 'FALLBACK'],
NO_INTERNET: ['nonet', 'NO INTERNET'],
DARK: ['dark', 'DARK'],
OFFLINE: ['dark', 'OFFLINE'],
UNREACHABLE: ['dark', 'UNREACHABLE'],
UNKNOWN: ['dark', 'UNKNOWN'],
};
const [cls, label] = map[st] || ['dark', st];
return `<span class="vv-fb-badge ${cls}">${label}</span>`;
}
function _stateDot(st) {
const col = {
NORMAL:'#4caf50', FALLBACK:'#ffb74d',
NO_INTERNET:'#5c7cfa', DARK:'#9c27b0',
OFFLINE:'#555', UNREACHABLE:'#555', UNKNOWN:'#333',
}[st] || '#333';
return `<span class="vv-fb-state-dot" style="background:${col}"></span>`;
}
function _activeCard(nodes, handbackReq) {
const active = nodes.filter(n => n.state && n.state.state === 'FALLBACK');
if (!active.length) return '';
return active.map(covering => {
const st = covering.state;
const tier = _activeTier(st);
const cov = covering.covers;
const covered = cov ? cov.hostname : '?';
// All containers that should be running at current tier
let expected = [...(cov?.tier1 || [])];
if (tier >= 2) expected = expected.concat(cov?.tier2 || []);
if (tier >= 3) expected = expected.concat(cov?.tier3 || []);
if (tier >= 4) expected = expected.concat(cov?.tier4 || []);
const runningSet = new Set(covering.running || []);
const ctrPills = expected.length
? expected.map(c => {
const cls = runningSet.has(c) ? 'running' : 'stopped';
const sym = runningSet.has(c) ? '▲' : '▼';
return `<span class="vv-fb-ctr ${cls}">${sym} ${c}</span>`;
}).join('')
: '<span style="color:#5a4020;font-size:11px;">No containers configured for this tier</span>';
return `<div class="vv-card vv-fb-active" style="grid-column:1/-1;">
<div class="vv-fb-active-h">
${_stateBadge('FALLBACK')}
<span style="font-size:12px;color:#aa7020;">${covering.id} covering ${cov?.id || '?'} (${covered})</span>
</div>
<div class="vv-fb-meta">
<div class="vv-fb-meta-item">
<span class="vv-fb-meta-val">${_dur(st.fallback_start)}</span>
<span class="vv-fb-meta-lbl">DURATION</span>
</div>
<div class="vv-fb-meta-item">
<span class="vv-fb-meta-val">Tier ${tier}</span>
<span class="vv-fb-meta-lbl">ACTIVE TIER</span>
</div>
<div class="vv-fb-meta-item">
<span class="vv-fb-meta-val">${st.handback_strikes} / ${handbackReq}</span>
<span class="vv-fb-meta-lbl">HANDBACK STRIKES</span>
</div>
<div class="vv-fb-meta-item">
<span class="vv-fb-meta-val">${expected.length}</span>
<span class="vv-fb-meta-lbl">CONTAINERS</span>
</div>
</div>
<div class="vv-fb-ctrs">${ctrPills}</div>
</div>`;
}).join('');
}
function _tierSection(tiers, activeTier, delays) {
const defs = [
{ n: 1, key: 'tier1', label: 'Tier 1', delay: 0 },
{ n: 2, key: 'tier2', label: 'Tier 2', delay: delays?.tier2 },
{ n: 3, key: 'tier3', label: 'Tier 3', delay: delays?.tier3 },
{ n: 4, key: 'tier4', label: 'Tier 4', delay: delays?.tier4 },
];
return defs.map(({ n, key, label, delay }) => {
const containers = tiers[key] || [];
const isActive = activeTier >= n;
const pills = containers.length
? containers.map(c => `<span class="vv-fb-pill${isActive ? ' active-t' : ''}">${c}</span>`).join('')
: `<span class="vv-fb-pill empty">none</span>`;
const delayStr = n === 1 ? 'immediate' : _fmtDelay(delay);
return `<div class="vv-fb-tier">
<div class="vv-fb-tier-h">
<span class="vv-fb-tier-lbl">${label}</span>
<span class="vv-fb-tier-delay">${delayStr}</span>
</div>
<div class="vv-fb-tier-pills">${pills}</div>
</div>`;
}).join('');
}
function _nodeCard(node) {
const st = node.state || {};
const state = st.state || 'UNKNOWN';
const cov = node.covers;
const active = _activeTier(state === 'FALLBACK' ? st : null);
const covTarget = cov
? `<span class="vv-fb-arrow">→</span><span class="vv-fb-covers">covers ${cov.id} (${cov.hostname})</span>`
: '';
const tierSection = cov
? _tierSection(cov, active, cov.delays)
: '<div style="color:#3a3a3a;font-size:11px;">No coverage configured</div>';
return `<div class="vv-card vv-fb-node">
<div class="vv-fb-node-h">
${_stateDot(state)}
<span class="vv-fb-node-id">${node.id}</span>
<span class="vv-fb-node-nm">${node.hostname}</span>
${covTarget}
<span style="flex:1"></span>
${_stateBadge(state)}
</div>
<hr class="vv-fb-sep">
${tierSection}
</div>`;
}
function _render(data) {
if (!data.fb_enabled) {
document.getElementById('vv-fb-grid').innerHTML =
'<div class="vv-fb-disabled">FALLBACK_ENABLED=false — fallback monitoring is disabled</div>';
return;
}
const nodes = data.nodes || [];
let html = '';
// Top: active fallback card (if any)
html += _activeCard(nodes, data.handback_req || 3);
// Per-node cards
for (const node of nodes) {
html += _nodeCard(node);
}
if (!html) {
html = '<div class="vv-fb-disabled">No nodes configured.</div>';
}
document.getElementById('vv-fb-grid').innerHTML = html;
const ts = data.ts
? new Date(data.ts * 1000).toLocaleString([], {
month:'numeric', day:'numeric', year:'numeric',
hour:'2-digit', minute:'2-digit', second:'2-digit'})
: '';
document.getElementById('vv-fb-ts').textContent = ts ? 'Updated: ' + ts : '';
}
function vvFbLoad() {
fetch('/plugins/varaverk/api/fallback.php')
.then(r => r.json())
.then(_render)
.catch(() => {});
}
vvFbLoad();
setInterval(vvFbLoad, 30000);
})();
</script>
@@ -0,0 +1,91 @@
<?php
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/scheduler.php';
$id = trim($_POST['id'] ?? '');
if (!$id || !preg_match('/^[a-zA-Z0-9_.\/\-]+\.sh$/', $id) || str_contains($id, '..')) {
echo json_encode(['ok' => false, 'error' => 'Invalid id']);
exit;
}
$statFile = vv_job_stat_path($id);
if (!file_exists($statFile)) {
echo json_encode(['ok' => false, 'error' => 'No stat file — script may not be running']);
exit;
}
$stat = json_decode(file_get_contents($statFile) ?: '{}', true) ?: [];
if (($stat['status'] ?? '') !== 'running') {
echo json_encode(['ok' => true, 'msg' => 'Not running']);
exit;
}
$pid = (int)($stat['pid'] ?? 0);
if ($pid < 2) {
echo json_encode(['ok' => false, 'error' => 'No valid PID in stat file']);
exit;
}
// Kill the whole process group so the script and all its children die together.
// pgid is usually the same as the session leader PID from run_job.sh.
$pgid = (int)trim(shell_exec("ps -o pgid= -p $pid 2>/dev/null") ?: '0');
if ($pgid > 1) {
shell_exec("kill -TERM -$pgid 2>/dev/null");
} else {
// Fallback: kill the direct PID and its children
shell_exec("pkill -TERM -P $pid 2>/dev/null");
shell_exec("kill -TERM $pid 2>/dev/null");
}
// Give it up to 3s to exit gracefully
$dead = false;
for ($i = 0; $i < 6; $i++) {
usleep(500000);
if (!file_exists("/proc/$pid")) { $dead = true; break; }
}
// Force-kill if still alive
if (!$dead) {
if ($pgid > 1) shell_exec("kill -KILL -$pgid 2>/dev/null");
shell_exec("pkill -KILL -P $pid 2>/dev/null");
shell_exec("kill -KILL $pid 2>/dev/null");
usleep(300000);
$dead = !file_exists("/proc/$pid");
}
// Clear any lock files in /tmp/unraid_locks whose content matches this PID
$lockDir = '/tmp/unraid_locks';
$cleared = [];
foreach (glob("$lockDir/*.lock") ?: [] as $lf) {
$content = trim(file_get_contents($lf) ?: '');
$lockPid = (int)explode(':', $content)[0];
if ($lockPid === $pid || !file_exists("/proc/$lockPid")) {
@unlink($lf);
$cleared[] = basename($lf);
}
}
// Also clear by script name in case PID rotated
$scriptBase = basename($id, '.sh');
$namedLock = "$lockDir/{$scriptBase}.lock";
if (file_exists($namedLock)) {
@unlink($namedLock);
if (!in_array(basename($namedLock), $cleared)) $cleared[] = basename($namedLock);
}
// Update stat file
$now = time();
$stat['status'] = 'stopped';
$stat['end'] = $now;
$stat['exit'] = -1;
unset($stat['pid']);
file_put_contents($statFile, json_encode($stat));
echo json_encode([
'ok' => true,
'killed' => $dead,
'locks' => $cleared,
]);
@@ -0,0 +1,92 @@
<?php
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/scheduler.php';
$id = trim($_POST['id'] ?? '');
if (!$id || !preg_match('/^[a-zA-Z0-9_.\/\-]+\.sh$/', $id) || str_contains($id, '..')) {
echo json_encode(['ok' => false, 'error' => 'Invalid id']);
exit;
}
$statFile = vv_job_stat_path($id);
if (!file_exists($statFile)) {
echo json_encode(['ok' => false, 'error' => 'No stat file — script may not be running']);
exit;
}
$stat = json_decode(file_get_contents($statFile) ?: '{}', true) ?: [];
if (($stat['status'] ?? '') !== 'running') {
echo json_encode(['ok' => true, 'msg' => 'Not running']);
exit;
}
$pid = (int)($stat['pid'] ?? 0);
if ($pid < 2) {
echo json_encode(['ok' => false, 'error' => 'No valid PID in stat file']);
exit;
}
// Kill the whole process group so the script and all its children die together.
// pgid is usually the same as the session leader PID from run_job.sh.
$pgid = (int)trim(shell_exec("ps -o pgid= -p $pid 2>/dev/null") ?: '0');
if ($pgid > 1) {
shell_exec("kill -TERM -$pgid 2>/dev/null");
} else {
// Fallback: kill the direct PID and its children
shell_exec("pkill -TERM -P $pid 2>/dev/null");
shell_exec("kill -TERM $pid 2>/dev/null");
}
// Give it up to 3s to exit gracefully
$dead = false;
for ($i = 0; $i < 6; $i++) {
usleep(500000);
if (!file_exists("/proc/$pid")) { $dead = true; break; }
}
// Force-kill if still alive
if (!$dead) {
if ($pgid > 1) shell_exec("kill -KILL -$pgid 2>/dev/null");
shell_exec("pkill -KILL -P $pid 2>/dev/null");
shell_exec("kill -KILL $pid 2>/dev/null");
usleep(300000);
$dead = !file_exists("/proc/$pid");
}
// Clear any lock files in /tmp/unraid_locks whose content matches this PID
$lockDir = '/tmp/unraid_locks';
$cleared = [];
foreach (glob("$lockDir/*.lock") ?: [] as $lf) {
$content = trim(file_get_contents($lf) ?: '');
$lockPid = (int)explode(':', $content)[0];
if ($lockPid === $pid || !file_exists("/proc/$lockPid")) {
@unlink($lf);
$cleared[] = basename($lf);
}
}
// Also clear by script name in case PID rotated
$scriptBase = basename($id, '.sh');
$namedLock = "$lockDir/{$scriptBase}.lock";
if (file_exists($namedLock)) {
@unlink($namedLock);
if (!in_array(basename($namedLock), $cleared)) $cleared[] = basename($namedLock);
}
// Update stat file — only clear pid if actually dead (D-state processes survive SIGKILL)
$now = time();
$stat['status'] = $dead ? 'stopped' : 'running';
$stat['end'] = $dead ? $now : ($stat['end'] ?? null);
$stat['exit'] = $dead ? -1 : ($stat['exit'] ?? null);
if ($dead) unset($stat['pid']);
file_put_contents($statFile, json_encode($stat));
echo json_encode([
'ok' => $dead,
'killed' => $dead,
'locks' => $cleared,
'error' => $dead ? null : 'Process still alive after SIGKILL (D-state) — lock may persist',
]);
@@ -0,0 +1,470 @@
<style>
/* ── Cards ───────────────────────────────────────────────── */
.vv-arr-card { background:#161616;border:1px solid #2a2a2a;border-radius:6px;padding:12px 14px;min-width:0; }
.vv-arr-sep { border:none;border-top:1px solid #1e1e1e;margin:8px 0; }
/* ── Card header ─────────────────────────────────────────── */
.vv-arr-hdr { display:flex;justify-content:space-between;align-items:center;margin-bottom:10px; }
.vv-arr-name { font-size:12px;font-weight:700;text-transform:uppercase;color:#888;letter-spacing:.06em; }
.vv-arr-dot { width:7px;height:7px;border-radius:50%;flex-shrink:0;margin-right:4px;display:inline-block; }
.vv-arr-ver { font-size:10px;color:#444; }
/* ── Big stats ───────────────────────────────────────────── */
.vv-arr-stats { display:flex;gap:0;margin-bottom:10px; }
.vv-arr-stat { flex:1;text-align:center;padding:0 4px; }
.vv-arr-stat + .vv-arr-stat { border-left:1px solid #1e1e1e; }
.vv-arr-stat-n{ font-size:18px;font-weight:700;color:#ccc;line-height:1.1; }
.vv-arr-stat-l{ font-size:9px;color:#444;text-transform:uppercase;letter-spacing:.06em;margin-top:1px; }
.vv-arr-stat-n.dim { color:#333; }
/* ── Disk bar ────────────────────────────────────────────── */
.vv-arr-disk { margin-bottom:8px; }
.vv-arr-disk-bar { height:3px;background:#1a1a1a;border-radius:2px;overflow:hidden;margin-top:3px; }
.vv-arr-disk-fill{ height:100%;border-radius:2px;transition:width .3s; }
.vv-arr-disk-lbl { display:flex;justify-content:space-between;align-items:baseline; }
/* ── Queue badges ────────────────────────────────────────── */
.vv-arr-q { display:flex;gap:6px;align-items:center;flex-wrap:wrap; }
.vv-arr-qbadge{ font-size:10px;padding:1px 7px;border-radius:3px;font-weight:600; }
.vv-arr-qbadge.dl { background:#0a1a2a;color:#4a9eff;border:1px solid #1a3a5a; }
.vv-arr-qbadge.warn { background:#1f1500;color:#ffb74d;border:1px solid #3a2800; }
.vv-arr-qbadge.err { background:#200d0d;color:#ef5350;border:1px solid #3a1a1a; }
.vv-arr-qbadge.idle { background:#111;color:#333;border:1px solid #1e1e1e; }
/* ── Health ──────────────────────────────────────────────── */
.vv-arr-health{ background:#1a0a0a;border-left:2px solid #ef5350;border-radius:0 3px 3px 0;
padding:5px 8px;margin-top:6px;font-size:10px;color:#c66;line-height:1.5; }
.vv-arr-health.warn { background:#1a1000;border-color:#ff9800;color:#c96; }
/* ── Cleanup / Discovery rows ────────────────────────────── */
.vv-arr-meta { display:flex;justify-content:space-between;align-items:baseline;gap:8px;margin:3px 0; }
.vv-arr-meta-lbl{ font-size:10px;color:#444; }
.vv-arr-meta-val{ font-size:10px;color:#666;text-align:right; }
.vv-arr-meta-sub{ font-size:9px;color:#333;margin-top:0px; }
.vv-arr-meta-sub.warn { color:#8b4; }
/* ── Root path ───────────────────────────────────────────── */
.vv-arr-root { font-size:10px;color:#2a2a2a;white-space:nowrap;overflow:hidden;
text-overflow:ellipsis;margin-top:8px;font-family:monospace; }
/* ── Node section ────────────────────────────────────────── */
.vv-arr-node { grid-column:1/-1; }
.vv-arr-node-hdr { display:flex;align-items:baseline;gap:10px;margin-bottom:10px;
padding-bottom:7px;border-bottom:1px solid #1e1e1e; }
.vv-arr-node-id { font-size:11px;font-weight:700;color:#555;text-transform:uppercase;letter-spacing:.08em; }
.vv-arr-node-host { font-size:12px;color:#888; }
.vv-arr-node-tag { font-size:9px;color:#2a3a2a;background:#0d180d;border:1px solid #1a3a1a;
border-radius:3px;padding:1px 6px;margin-left:2px; }
.vv-arr-cards { display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:10px; }
/* ── Sync + Recovery cards ───────────────────────────────── */
.vv-arr-sr-grid { grid-column:1/-1;display:grid;grid-template-columns:1fr 1fr;gap:10px; }
@media (max-width:700px) { .vv-arr-sr-grid { grid-template-columns:1fr; } }
.vv-arr-sr-num { font-size:20px;font-weight:700;color:#ccc;line-height:1; }
.vv-arr-sr-lbl { font-size:9px;color:#444;text-transform:uppercase;letter-spacing:.06em;margin-top:2px; }
.vv-arr-sr-row { display:flex;gap:16px;flex-wrap:wrap; }
.vv-arr-sr-item { display:flex;flex-direction:column;gap:1px; }
/* ── Settings card ───────────────────────────────────────── */
.vv-arr-set-card { grid-column:1/-1; }
.vv-arr-tog-wrap { display:inline-flex;align-items:center;gap:8px;cursor:pointer;user-select:none; }
.vv-arr-tog-track{ width:28px;height:16px;border-radius:8px;background:#1e1e1e;border:1px solid #2a2a2a;
position:relative;transition:background .15s,border-color .15s;flex-shrink:0; }
.vv-arr-tog-track.on { background:#1a3a1a;border-color:#2d5a2d; }
.vv-arr-tog-track::after { content:'';position:absolute;top:2px;left:2px;width:10px;height:10px;
border-radius:50%;background:#444;transition:left .15s,background .15s; }
.vv-arr-tog-track.on::after { left:14px;background:#4caf50; }
.vv-arr-tog-lbl { font-size:11px;color:#666; }
.vv-arr-set-inp { background:#0d0d0d;border:1px solid #252525;border-radius:3px;color:#888;
font-size:11px;padding:3px 7px;outline:none;width:60px; }
.vv-arr-set-inp:focus { border-color:#444; }
.vv-arr-pill { display:inline-block;font-size:9px;padding:1px 7px;border-radius:3px;font-weight:700; }
.vv-arr-pill.ok { background:#0d1f0d;color:#4caf50;border:1px solid #1a3a1a; }
.vv-arr-pill.err { background:#200d0d;color:#ef5350;border:1px solid #3a1a1a; }
.vv-arr-pill.off { background:#1e1e1e;color:#444;border:1px solid #2a2a2a; }
</style>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;padding:0 2px;">
<span style="font-size:13px;font-weight:bold;color:#888;text-transform:uppercase;letter-spacing:.06em;">Arrs</span>
<span style="font-size:11px;color:#3a3a3a;" id="vv-arrs-ts"></span>
</div>
<div id="vv-arrs-grid" style="display:grid;grid-template-columns:repeat(8,1fr);gap:12px;">
<div style="grid-column:1/-1;color:#444;font-size:12px;padding:24px 0;text-align:center;">Loading…</div>
</div>
<script>
(function() {
// ── Helpers ───────────────────────────────────────────────────────────────────
function _rel(ts) {
if (!ts) return '—';
const d = Math.floor(Date.now() / 1000) - ts;
if (d < 60) return 'just now';
if (d < 3600) return Math.floor(d/60) + 'm ago';
if (d < 86400) return Math.floor(d/3600) + 'h ago';
if (d < 172800)return 'yesterday';
return Math.floor(d/86400) + 'd ago';
}
function _n(v) { return v == null ? '—' : Number(v).toLocaleString(); }
function _gb(b) { if (!b) return '—'; return (b / 1073741824).toFixed(1) + ' GB'; }
function _tb(b) { if (!b) return '—'; return (b / 1099511627776).toFixed(2) + ' TB'; }
function _sz(b) {
if (!b) return '—';
if (b >= 1099511627776) return (b/1099511627776).toFixed(1) + ' TB';
if (b >= 1073741824) return (b/1073741824).toFixed(1) + ' GB';
return (b/1048576).toFixed(0) + ' MB';
}
// ── Arr card ──────────────────────────────────────────────────────────────────
function _arrCard(arr) {
const LABELS = { sonarr:'Sonarr', radarr:'Radarr', lidarr:'Lidarr' };
const name = LABELS[arr.type] || arr.type;
const online = !!arr.online;
const dotCol = online ? '#4caf50' : (arr.remote ? '#444' : '#c62828');
// ── Offline / remote state ────────────────────────────────────────────────
if (!online) {
const msg = arr.remote ? 'not available from this host' : 'offline';
return `<div class="vv-arr-card" style="opacity:.6;">
<div class="vv-arr-hdr">
<span class="vv-arr-name">${name}</span>
<span><span class="vv-arr-dot" style="background:${dotCol}"></span>
<span class="vv-arr-ver">${msg}</span></span>
</div>
${arr.root ? `<div class="vv-arr-root">${arr.root}</div>` : ''}
</div>`;
}
// ── Stats ─────────────────────────────────────────────────────────────────
let stats = '';
if (arr.type === 'sonarr') {
stats = `
<div class="vv-arr-stat"><div class="vv-arr-stat-n">${_n(arr.total)}</div><div class="vv-arr-stat-l">Series</div></div>
<div class="vv-arr-stat"><div class="vv-arr-stat-n">${_n(arr.monitored)}</div><div class="vv-arr-stat-l">Monitored</div></div>
<div class="vv-arr-stat"><div class="vv-arr-stat-n">${_n(arr.episodes)}</div><div class="vv-arr-stat-l">Episodes</div></div>`;
} else if (arr.type === 'radarr') {
const pct = arr.total ? Math.round((arr.files||0)/arr.total*100) : 0;
stats = `
<div class="vv-arr-stat"><div class="vv-arr-stat-n">${_n(arr.total)}</div><div class="vv-arr-stat-l">Movies</div></div>
<div class="vv-arr-stat"><div class="vv-arr-stat-n">${_n(arr.files)}</div><div class="vv-arr-stat-l">Have file</div></div>
<div class="vv-arr-stat"><div class="vv-arr-stat-n ${pct<50?'dim':''}">${pct}%</div><div class="vv-arr-stat-l">Complete</div></div>`;
} else if (arr.type === 'lidarr') {
stats = `
<div class="vv-arr-stat"><div class="vv-arr-stat-n">${_n(arr.total)}</div><div class="vv-arr-stat-l">Artists</div></div>
<div class="vv-arr-stat"><div class="vv-arr-stat-n">${_n(arr.monitored)}</div><div class="vv-arr-stat-l">Monitored</div></div>
<div class="vv-arr-stat"><div class="vv-arr-stat-n">${_n(arr.albums)}</div><div class="vv-arr-stat-l">Albums</div></div>`;
}
// ── Disk bar ──────────────────────────────────────────────────────────────
let disk = '';
if (arr.disk?.length) {
const d = arr.disk.reduce((a,b) => (b.totalSpace||0) > (a.totalSpace||0) ? b : a);
const free = d.freeSpace, tot = d.totalSpace;
if (free && tot) {
const usedPct = Math.round((1 - free/tot)*100);
const col = usedPct > 90 ? '#ef5350' : usedPct > 75 ? '#ffb74d' : '#4caf50';
disk = `<div class="vv-arr-disk">
<div class="vv-arr-disk-lbl">
<span style="font-size:10px;color:#555;">${_sz(free)} free</span>
<span style="font-size:10px;color:#333;">${_sz(tot)} · ${usedPct}%</span>
</div>
<div class="vv-arr-disk-bar">
<div class="vv-arr-disk-fill" style="width:${usedPct}%;background:${col};"></div>
</div>
</div>`;
}
}
// ── Queue ─────────────────────────────────────────────────────────────────
const q = arr.queue || {};
const qDl = q.dl || 0, qWrn = q.warn || 0, qErr = q.err || 0;
const qTot = qDl + qWrn + qErr;
let qHtml;
if (qTot === 0) {
qHtml = `<span class="vv-arr-qbadge idle">idle</span>`;
} else {
qHtml = '';
if (qDl) qHtml += `<span class="vv-arr-qbadge dl">${qDl} downloading</span>`;
if (qWrn) qHtml += `<span class="vv-arr-qbadge warn">${qWrn} warning</span>`;
if (qErr) qHtml += `<span class="vv-arr-qbadge err">${qErr} error</span>`;
}
// ── Health ────────────────────────────────────────────────────────────────
let health = '';
if (arr.health?.length) {
const hasErr = arr.health.some(h => h.type === 'error');
const cls = hasErr ? 'vv-arr-health' : 'vv-arr-health warn';
const msgs = arr.health.slice(0,3).map(h => h.message || h.type).join('<br>');
health = `<div class="${cls}">${msgs}</div>`;
}
// ── Cleanup ───────────────────────────────────────────────────────────────
let cleanup = '';
const cl = arr.cleanup || {};
if (cl.last_run) {
const st = cl.status === 'ok' ? '<span style="color:#4caf50">✓</span>' : '<span style="color:#ef5350">✗</span>';
const orph = cl.orphans || 0, junk = cl.junk || 0;
const orCol = (orph || junk) ? 'color:#8b4a2a' : 'color:#333';
cleanup = `
<div class="vv-arr-meta">
<span class="vv-arr-meta-lbl">Cleanup</span>
<span class="vv-arr-meta-val">${_rel(cl.last_run)} ${st}</span>
</div>
${cl.tracked != null ? `<div class="vv-arr-meta-sub" style="${orCol}">
${_n(cl.tracked)} files · ${orph} orphans${junk ? ' · ' + junk + ' junk' : ''}
</div>` : ''}`;
}
// ── Discovery ─────────────────────────────────────────────────────────────
let disc = '';
const dsc = arr.discovery || {};
if (dsc.last_run) {
const added = dsc.added != null ? `&thinsp;<span style="color:#6fcf97">+${dsc.added}</span>` : '';
disc = `<div class="vv-arr-meta">
<span class="vv-arr-meta-lbl">Discovery</span>
<span class="vv-arr-meta-val">${_rel(dsc.last_run)}${added}</span>
</div>`;
}
const hasMeta = cleanup || disc;
return `<div class="vv-arr-card">
<div class="vv-arr-hdr">
<span class="vv-arr-name">${name}</span>
<span>
<span class="vv-arr-dot" style="background:${dotCol}"></span>
<span class="vv-arr-ver">${arr.version || 'online'}</span>
</span>
</div>
<div class="vv-arr-stats">${stats}</div>
${disk}
<hr class="vv-arr-sep" style="margin:6px 0 8px;">
<div class="vv-arr-q">${qHtml}</div>
${health}
${hasMeta ? `<hr class="vv-arr-sep" style="margin:8px 0 4px;">${cleanup}${disc}` : ''}
<div class="vv-arr-root">${arr.root || ''}</div>
</div>`;
}
// ── Node section ──────────────────────────────────────────────────────────────
function _nodeSection(node) {
const isLocal = node.local;
const isMiss = node.cache_miss;
let tag, body;
if (isLocal) {
tag = '<span class="vv-arr-node-tag">local</span>';
body = `<div class="vv-arr-cards">${node.arrs.map(_arrCard).join('')}</div>`;
} else if (isMiss) {
tag = `<span class="vv-arr-node-tag" style="background:#111;border-color:#222;color:#333;">no data</span>
<button id="vv-arr-rfsh-${node.host}" onclick="vvArrsRefreshRemote('${node.host}')"
style="font-size:9px;color:#4a9eff;background:#0a1a2a;border:1px solid #1a3a5a;
border-radius:3px;padding:1px 8px;cursor:pointer;margin-left:6px;">↻ Fetch now</button>`;
body = `<div style="color:#333;font-size:11px;padding:10px 0;">
No remote data yet — first fetch runs within 2 hours, or click ↻ Fetch now.
</div>`;
} else {
const age = node.cache_age || 0;
const ageStr = age < 3600 ? Math.floor(age/60) + 'm ago'
: age < 86400 ? Math.floor(age/3600) + 'h ago'
: Math.floor(age/86400) + 'd ago';
tag = `<span class="vv-arr-node-tag" style="background:#1a1000;border-color:#3a2800;color:#b87;">cached ${ageStr}</span>
<button id="vv-arr-rfsh-${node.host}" onclick="vvArrsRefreshRemote('${node.host}')"
style="font-size:9px;color:#666;background:none;border:1px solid #2a2a2a;
border-radius:3px;padding:1px 8px;cursor:pointer;margin-left:6px;">↻</button>`;
body = `<div class="vv-arr-cards" style="opacity:.85;">${node.arrs.map(_arrCard).join('')}</div>`;
}
return `<div class="vv-arr-node">
<div class="vv-arr-node-hdr">
<span class="vv-arr-node-id">${node.host.toUpperCase()}</span>
<span class="vv-arr-node-host">${node.name}</span>${tag}
</div>
${body}
</div>`;
}
// ── Sync + Recovery row ───────────────────────────────────────────────────────
function _syncSection(sync, recovery, settings) {
const _card = (title, data, items, extra) => {
const ok = data.last_run && data.status === 'ok';
const bad = data.last_run && data.status !== 'ok';
const pillCls = !data.last_run ? 'off' : ok ? 'ok' : 'err';
const pillTxt = !data.last_run ? 'never run' : ok ? 'ok' : data.status || 'error';
let body = '';
if (data.last_run) {
body += `<div style="font-size:11px;color:#555;margin-bottom:10px;">${_rel(data.last_run)}</div>`;
}
body += `<div class="vv-arr-sr-row">${items}</div>`;
if (extra) body += extra;
return `<div class="vv-arr-card">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">
<span style="font-size:11px;font-weight:700;color:#555;text-transform:uppercase;letter-spacing:.07em;">${title}</span>
<span class="vv-arr-pill ${pillCls}">${pillTxt}</span>
</div>
${body}
</div>`;
};
const _item = (val, lbl) => `<div class="vv-arr-sr-item">
<div class="vv-arr-sr-num">${val}</div>
<div class="vv-arr-sr-lbl">${lbl}</div>
</div>`;
const syncEnabled = settings?.arr_sync_enabled !== false;
const syncExtra = !syncEnabled
? `<div style="font-size:10px;color:#8b4a2a;margin-top:8px;">ARR_SYNC_ENABLED=false — sync disabled</div>` : '';
const syncItems = sync.last_run
? _item(`<span style="color:#6fcf97">+${_n(sync.added)}</span>`, 'Added') +
(sync.nodes != null ? _item(_n(sync.nodes), 'Nodes') : '') +
(sync.blocklist_count != null ? _item(_n(sync.blocklist_count), 'Blocklist') : '')
: `<span style="color:#333;font-size:11px;">No run history</span>`;
const recItems = recovery.last_run
? _item(_n(recovery.fixed), 'Fixed') + _item(_n(recovery.searched), 'Re-searched')
: `<span style="color:#333;font-size:11px;">No run history</span>`;
return `<div class="vv-arr-sr-grid">
${_card('Arr Sync', sync, syncItems, syncExtra)}
${_card('Failed / Stalled Recovery', recovery, recItems, '')}
</div>`;
}
// ── Settings card ─────────────────────────────────────────────────────────────
function _settingsCard(s) {
if (!s) return '';
const myHost = s.my_host || 'host1';
const myId = s.my_id || 'HOST1';
const _tog = (id, on, key, file, label) =>
`<label class="vv-arr-tog-wrap">
<div class="vv-arr-tog-track${on?' on':''}" id="vv-arr-tog-${id}"
onclick="event.stopPropagation();vvArrToggle(this,'${key}','${file}')"></div>
<span class="vv-arr-tog-lbl">${label}</span>
</label>`;
const syncTog = _tog('sync', s.arr_sync_enabled,
'ARR_SYNC_ENABLED', 'master.conf', 'Arr sync enabled');
const recTogs = [
s.sonarr_recovery !== undefined
? _tog('sonarr-rec', s.sonarr_recovery, `${myId}_SONARR_RECOVERY`, `${myHost}.conf`, 'Sonarr') : '',
s.radarr_recovery !== undefined
? _tog('radarr-rec', s.radarr_recovery, `${myId}_RADARR_RECOVERY`, `${myHost}.conf`, 'Radarr') : '',
s.lidarr_recovery !== undefined
? _tog('lidarr-rec', s.lidarr_recovery, `${myId}_LIDARR_RECOVERY`, `${myHost}.conf`, 'Lidarr') : '',
].filter(Boolean).join('');
return `<div class="vv-arr-card vv-arr-set-card">
<div style="font-size:11px;font-weight:700;color:#555;text-transform:uppercase;
letter-spacing:.07em;margin-bottom:12px;">Settings</div>
<div style="display:flex;flex-wrap:wrap;gap:20px;margin-bottom:12px;">
${syncTog}
</div>
<hr class="vv-arr-sep">
<div style="font-size:10px;color:#3a3a3a;text-transform:uppercase;letter-spacing:.07em;margin-bottom:8px;">
Recovery — ${myId}
</div>
<div style="display:flex;flex-wrap:wrap;gap:16px;margin-bottom:10px;">${recTogs}</div>
<div style="display:flex;align-items:center;gap:8px;">
<span style="font-size:11px;color:#555;">Import recovery age</span>
<input class="vv-arr-set-inp" id="vv-arr-rec-age" type="number" min="1" max="72"
value="${s.recovery_age_hours ?? 6}">
<span style="font-size:10px;color:#3a3a3a;">hours — skip items newer than this</span>
<button onclick="vvArrSaveAge()" id="vv-arr-age-btn"
style="background:#1a1a1a;border:1px solid #333;color:#666;font-size:10px;
padding:3px 10px;border-radius:3px;cursor:pointer;margin-left:4px;">Save</button>
<span id="vv-arr-age-fb" style="font-size:10px;"></span>
</div>
</div>`;
}
// ── Main render ───────────────────────────────────────────────────────────────
function _render(data) {
const nodes = data.nodes || [];
if (!nodes.length && !(data.sync?.last_run) && !(data.recovery?.last_run)) {
document.getElementById('vv-arrs-grid').innerHTML =
`<div style="grid-column:1/-1;color:#444;font-size:12px;padding:24px 0;text-align:center;">
No arrs configured — add Sonarr/Radarr/Lidarr URLs and API keys to ${data.settings?.my_host ?? 'host'}.conf
</div>`;
return;
}
let html = '';
for (const node of nodes) html += _nodeSection(node);
html += _syncSection(data.sync || {}, data.recovery || {}, data.settings);
html += _settingsCard(data.settings);
document.getElementById('vv-arrs-grid').innerHTML = html;
const ts = data.ts ? new Date(data.ts * 1000).toLocaleString([],
{month:'numeric',day:'numeric',year:'numeric',hour:'2-digit',minute:'2-digit',second:'2-digit'}) : '';
document.getElementById('vv-arrs-ts').textContent = ts ? 'Updated: ' + ts : '';
}
function vvArrsLoad() {
fetch('/plugins/varaverk/api/arrs.php')
.then(r => r.json()).then(_render).catch(() => {});
}
vvArrsLoad();
setInterval(vvArrsLoad, 60000);
})();
// ── Remote node refresh ───────────────────────────────────────────────────────
function vvArrsRefreshRemote(host) {
const btn = document.getElementById('vv-arr-rfsh-' + host);
if (btn) { btn.disabled = true; btn.textContent = '↻…'; }
fetch(`/plugins/varaverk/api/arrs.php?action=refresh_remote&host=${host}&_=` + Date.now())
.then(r => r.json())
.then(d => {
if (btn) { btn.disabled = false; btn.textContent = d.ok ? '↻' : '✗'; }
// Main cache was busted — reload to show fresh data
setTimeout(vvArrsLoad, 500);
})
.catch(() => { if (btn) { btn.disabled = false; btn.textContent = '↻'; } });
}
// ── Toggle handler ────────────────────────────────────────────────────────────
function vvArrToggle(track, key, file) {
const on = !track.classList.contains('on');
track.classList.toggle('on', on);
const fd = new FormData();
fd.append('id', 'arrs');
fd.append('changes', JSON.stringify([{ file, key, value: on ? 'true' : 'false', type: 'scalar' }]));
fetch('/plugins/varaverk/api/confform.php', { method: 'POST', body: fd })
.then(r => r.json())
.then(d => { if (!d.ok) track.classList.toggle('on', !on); })
.catch(() => track.classList.toggle('on', !on));
}
// ── Recovery age save ─────────────────────────────────────────────────────────
function vvArrSaveAge() {
const val = document.getElementById('vv-arr-rec-age')?.value;
const btn = document.getElementById('vv-arr-age-btn');
const fb = document.getElementById('vv-arr-age-fb');
if (!val) return;
btn.disabled = true; btn.textContent = 'Saving…'; fb.textContent = '';
const fd = new FormData();
fd.append('id', 'arrs');
fd.append('changes', JSON.stringify([{
file: 'master.conf', key: 'ARR_IMPORT_RECOVERY_AGE', value: val, type: 'scalar'
}]));
fetch('/plugins/varaverk/api/confform.php', { method: 'POST', body: fd })
.then(r => r.json())
.then(d => {
btn.disabled = false; btn.textContent = 'Save';
fb.style.color = d.ok ? '#4caf50' : '#ef5350';
fb.textContent = d.ok ? 'Saved ✓' : (d.error || 'Failed');
if (d.ok) setTimeout(() => { fb.textContent = ''; }, 3000);
})
.catch(() => { btn.disabled = false; btn.textContent = 'Save'; fb.style.color='#ef5350'; fb.textContent='Failed'; });
}
</script>
@@ -0,0 +1,147 @@
#!/bin/bash
# ==============================================================================================
# ============================= Conf Cache Sync ================================================
# ==============================================================================================
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# Maintains a RAM-resident conf cache at /tmp/.vv/config/cached/.confs/.
# Credentials and partner keys live in RAM only — never on disk across hosts.
#
# On array start (default / --array-start):
# 1. Copy own conf to local cache
# 2. Pull each available partner's conf from their disk → local cache
# 3. Push own conf to each available partner's /tmp/.vv/ cache
#
# On conf save (--push-only):
# Fast path — push updated own conf to all partners' /tmp/.vv/ cache only.
# No pulls, no local cache rebuild.
#
# Cache is /tmp (tmpfs) — cleared every reboot, repopulated by this script
# on next array start. Scripts source from cache for partner vars; own vars
# always come from disk (load_config.sh skips cached copy of own conf).
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
# conf_sync.sh Full sync: pull from all partners + push to all partners
# conf_sync.sh --push-only Push own conf to all partners (fast, for conf-save hook)
# conf_sync.sh --pull-only Pull partner confs into local cache only (for intermediate orch)
# conf_sync.sh --dry-run Show what would happen, no changes
# conf_sync.sh --log Verbose output
#
# ==============================================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../load_config.sh"
PUSH_ONLY=false
PULL_ONLY=false
FILTERED_ARGS=()
for arg in "$@"; do
case "$arg" in
--push-only) PUSH_ONLY=true ;;
--pull-only) PULL_ONLY=true ;;
*) FILTERED_ARGS+=("$arg") ;;
esac
done
parse_args "${FILTERED_ARGS[@]}"
detect_hosts
CACHE_DIR="/tmp/.vv/config/cached/.confs"
MY_CONF="$SCRIPTS_ROOT/Configurations/${MY_ID,,}.conf"
SSH_TIMEOUT=10
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no changes will be made"
# ── Ensure cache dir exists ───────────────────────────────────────────────────
if [[ "$DRY_RUN" == false ]]; then
mkdir -p "$CACHE_DIR"
fi
# ── Copy own conf into local cache ───────────────────────────────────────────
if [[ "$PUSH_ONLY" == false ]] && [[ "$PULL_ONLY" == false ]]; then
if [[ -f "$MY_CONF" ]]; then
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would copy $(basename "$MY_CONF") → $CACHE_DIR/"
else
cp "$MY_CONF" "$CACHE_DIR/${MY_ID,,}.conf" && \
log "Own conf cached ✅" || warn "Failed to cache own conf"
fi
else
warn "Own conf not found: $MY_CONF"
fi
fi
# ── Per-partner sync ──────────────────────────────────────────────────────────
PUSHED=0
PULLED=0
FAILED=0
for host_var in $(compgen -v | grep -E '^HOST[0-9]+$' | sort); do
partner_host="${!host_var}"
[[ -z "$partner_host" ]] && continue
[[ "${host_var,,}" == "${MY_ID,,}" ]] && continue
partner_slot="${host_var,,}" # e.g. host2
partner_ip=$(resolve_tailscale_ip "$partner_host" 2>/dev/null || true)
if [[ -z "$partner_ip" ]]; then
warn "$partner_host — cannot resolve Tailscale IP, skipping"
(( FAILED++ ))
continue
fi
# ── Pull: grab partner's conf from their disk → our local cache ──────────
if [[ "$PUSH_ONLY" == false ]]; then
remote_conf="/boot/config/plugins/varaverk/Configurations/${partner_slot}.conf"
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would pull $partner_host:$remote_conf → $CACHE_DIR/${partner_slot}.conf"
elif timeout "$SSH_TIMEOUT" scp -i "$SSH_KEY" \
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes -o StrictHostKeyChecking=no \
"root@${partner_ip}:${remote_conf}" \
"$CACHE_DIR/${partner_slot}.conf" 2>/dev/null; then
log "Pulled ${partner_slot}.conf from $partner_host ✅"
(( PULLED++ ))
else
warn "Could not pull ${partner_slot}.conf from $partner_host"
(( FAILED++ ))
fi
fi
# ── Push: send own conf to partner's /tmp/.vv/ cache ────────────────────
if [[ "$PULL_ONLY" == true ]]; then
continue
fi
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would push ${MY_ID,,}.conf → $partner_host:/tmp/.vv/config/cached/.confs/"
continue
fi
# Ensure partner's cache dir exists, then SCP own conf into it
timeout "$SSH_TIMEOUT" ssh -i "$SSH_KEY" \
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes -o StrictHostKeyChecking=no \
"root@${partner_ip}" "mkdir -p '$CACHE_DIR'" 2>/dev/null
if timeout "$SSH_TIMEOUT" scp -i "$SSH_KEY" \
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes -o StrictHostKeyChecking=no \
"$MY_CONF" \
"root@${partner_ip}:${CACHE_DIR}/${MY_ID,,}.conf" 2>/dev/null; then
log "Pushed ${MY_ID,,}.conf to $partner_host ✅"
(( PUSHED++ ))
else
warn "Could not push to $partner_host"
(( FAILED++ ))
fi
done
# ── Summary ───────────────────────────────────────────────────────────────────
if [[ "$PUSH_ONLY" == true ]]; then
info "Conf push complete — pushed to $PUSHED host(s)${FAILED:+, $FAILED failed}"
elif [[ "$PULL_ONLY" == true ]]; then
info "Conf pull complete — pulled $PULLED partner conf(s)${FAILED:+, $FAILED failed}"
else
info "Conf sync complete — pulled $PULLED, pushed $PUSHED${FAILED:+, $FAILED failed}"
fi
@@ -0,0 +1,153 @@
#!/bin/bash
# ==============================================================================================
# ============================= Conf Cache Sync ================================================
# ==============================================================================================
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# Maintains a RAM-resident conf cache at /tmp/.vv/config/cached/.confs/.
# Credentials and partner keys live in RAM only — never on disk across hosts.
#
# On array start (default / --array-start):
# 1. Copy own conf to local cache
# 2. Pull each available partner's conf from their disk → local cache
# 3. Push own conf to each available partner's /tmp/.vv/ cache
#
# On conf save (--push-only):
# Fast path — push updated own conf to all partners' /tmp/.vv/ cache only.
# No pulls, no local cache rebuild.
#
# Cache is /tmp (tmpfs) — cleared every reboot, repopulated by this script
# on next array start. Scripts source from cache for partner vars; own vars
# always come from disk (load_config.sh skips cached copy of own conf).
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
# conf_sync.sh Full sync: pull from all partners + push to all partners
# conf_sync.sh --push-only Push own conf to all partners (fast, for conf-save hook)
# conf_sync.sh --pull-only Pull partner confs into local cache only (for intermediate orch)
# conf_sync.sh --dry-run Show what would happen, no changes
# conf_sync.sh --log Verbose output
#
# ==============================================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../load_config.sh"
PUSH_ONLY=false
PULL_ONLY=false
FILTERED_ARGS=()
for arg in "$@"; do
case "$arg" in
--push-only) PUSH_ONLY=true ;;
--pull-only) PULL_ONLY=true ;;
*) FILTERED_ARGS+=("$arg") ;;
esac
done
parse_args "${FILTERED_ARGS[@]}"
detect_hosts
CACHE_DIR="/tmp/.vv/config/cached/.confs"
MY_CONF="$SCRIPTS_ROOT/Configurations/${MY_ID,,}.conf"
SSH_TIMEOUT=10
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no changes will be made"
# ── Ensure cache dir exists ───────────────────────────────────────────────────
if [[ "$DRY_RUN" == false ]]; then
mkdir -p "$CACHE_DIR"
fi
# ── Copy own conf into local cache ───────────────────────────────────────────
if [[ "$PUSH_ONLY" == false ]] && [[ "$PULL_ONLY" == false ]]; then
if [[ -f "$MY_CONF" ]]; then
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would copy $(basename "$MY_CONF") → $CACHE_DIR/"
else
cp "$MY_CONF" "$CACHE_DIR/${MY_ID,,}.conf" && \
log "Own conf cached ✅" || warn "Failed to cache own conf"
fi
else
warn "Own conf not found: $MY_CONF"
fi
fi
# ── Per-partner sync ──────────────────────────────────────────────────────────
PUSHED=0
PULLED=0
FAILED=0
for host_var in $(compgen -v | grep -E '^HOST[0-9]+$' | sort); do
partner_host="${!host_var}"
[[ -z "$partner_host" ]] && continue
[[ "${host_var,,}" == "${MY_ID,,}" ]] && continue
partner_slot="${host_var,,}" # e.g. host2
partner_ip=$(resolve_tailscale_ip "$partner_host" 2>/dev/null || true)
if [[ -z "$partner_ip" ]]; then
warn "$partner_host — cannot resolve Tailscale IP, skipping"
(( FAILED++ ))
continue
fi
# ── Pull: grab partner's conf from their disk → our local cache ──────────
if [[ "$PUSH_ONLY" == false ]]; then
remote_conf="/boot/config/plugins/varaverk/Configurations/${partner_slot}.conf"
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would pull $partner_host:$remote_conf → $CACHE_DIR/${partner_slot}.conf"
elif timeout "$SSH_TIMEOUT" scp -i "$SSH_KEY" \
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes -o StrictHostKeyChecking=no \
"root@${partner_ip}:${remote_conf}" \
"$CACHE_DIR/${partner_slot}.conf" 2>/dev/null; then
log "Pulled ${partner_slot}.conf from $partner_host ✅"
(( PULLED++ ))
else
warn "Could not pull ${partner_slot}.conf from $partner_host"
(( FAILED++ ))
fi
fi
# ── Push: send own conf to partner's /tmp/.vv/ cache ────────────────────
if [[ "$PULL_ONLY" == true ]]; then
continue
fi
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would push ${MY_ID,,}.conf → $partner_host:/tmp/.vv/config/cached/.confs/"
continue
fi
# Ensure partner's cache dir exists, then SCP own conf into it
timeout "$SSH_TIMEOUT" ssh -i "$SSH_KEY" \
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes -o StrictHostKeyChecking=no \
"root@${partner_ip}" "mkdir -p '$CACHE_DIR'" 2>/dev/null
if timeout "$SSH_TIMEOUT" scp -i "$SSH_KEY" \
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes -o StrictHostKeyChecking=no \
"$MY_CONF" \
"root@${partner_ip}:${CACHE_DIR}/${MY_ID,,}.conf" 2>/dev/null; then
log "Pushed ${MY_ID,,}.conf to $partner_host ✅"
(( PUSHED++ ))
else
warn "Could not push to $partner_host"
(( FAILED++ ))
fi
done
# ── Summary ───────────────────────────────────────────────────────────────────
if [[ "$PUSH_ONLY" == true ]]; then
info "Conf push complete — pushed to $PUSHED host(s)${FAILED:+, $FAILED failed}"
elif [[ "$PULL_ONLY" == true ]]; then
info "Conf pull complete — pulled $PULLED partner conf(s)${FAILED:+, $FAILED failed}"
else
info "Conf sync complete — pulled $PULLED, pushed $PUSHED${FAILED:+, $FAILED failed}"
fi
if [[ "$FAILED" -gt 0 ]]; then
notify "Conf sync on $LOCAL_SERVER_NAME ($MY_ID) — $FAILED partner(s) failed. Partner config cache may be stale." \
"Conf Sync" "warning"
exit 1
fi
@@ -0,0 +1,30 @@
// Varaverk — shared JS utilities
// Page-specific JS lives inline in each page partial.
// Flash a status element briefly then fade
function vvFlashStatus(el, msg, ok) {
el.textContent = msg;
el.style.color = ok ? '#4caf50' : '#f44336';
setTimeout(() => { el.textContent = ''; }, 3000);
}
// ── Fullscreen toggle — hides Unraid header + menu ────────────────────────────
function vvToggleExpand() {
const on = document.body.classList.toggle('vv-fullscreen');
const btn = document.getElementById('vv-expand-btn');
if (btn) { btn.classList.toggle('active', on); btn.title = on ? 'Collapse' : 'Expand'; }
localStorage.setItem('vv-fullscreen', on ? '1' : '');
}
// Restore state on every page load
(function() {
if (localStorage.getItem('vv-fullscreen') !== '1') return;
document.body.classList.add('vv-fullscreen');
// Button may not exist yet if script runs before DOM — wait for it
const apply = () => {
const btn = document.getElementById('vv-expand-btn');
if (btn) { btn.classList.add('active'); btn.title = 'Collapse'; }
};
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', apply);
else apply();
})();
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,153 @@
#!/bin/bash
# ==============================================================================================
# ================================= Unraid API Key Renewal ====================================
# ==============================================================================================
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# Creates/overwrites the Varaverk API key in the unraid-api service registry at
# array start. The registry is ephemeral — OS updates and service restarts clear
# it. This script re-registers the key every boot so Varaverk's enhanced
# monitoring self-heals without manual intervention.
#
# Also updates HOST*_UNRAID_API_KEY in the local host conf so the partnership
# page always reflects the live key value.
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
# unraid_api_key_renew.sh
# Renew the key. Silent on success.
#
# unraid_api_key_renew.sh --dry-run
# Show what would happen — no changes made.
#
# unraid_api_key_renew.sh --log
# Verbose output.
#
# ==============================================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../../../load_config.sh"
parse_args "$@"
acquire_lock
detect_hosts
# ──────────────────────────────────────────────────────────────────────────────
CONF_FILE="$SCRIPT_DIR/../Configurations/${MY_ID,,}.conf"
VAR_NAME="${MY_ID}_UNRAID_API_KEY"
# Key name: "Varaverk <hostname>" stripping any unraid- prefix
# Space separator — unRAID API only allows letters, numbers, and spaces
HOSTNAME_SUFFIX=$(hostname -s 2>/dev/null | sed 's/^[Uu][Nn][Rr][Aa][Ii][Dd]-//' || hostname -s)
KEY_NAME="Varaverk ${HOSTNAME_SUFFIX}"
log "$ICON_GEAR Conf file: $CONF_FILE"
log "$ICON_GEAR Key var: $VAR_NAME"
log "$ICON_GEAR Key name: $KEY_NAME"
if [[ ! -f "$CONF_FILE" ]]; then
error "Conf file not found: $CONF_FILE"
exit 1
fi
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would check registry for $KEY_NAME, renew only if missing"
exit 0
fi
# ──────────────────────────────────────────────────────────────────────────────
# Check if key already exists in the unraid-api registry before creating.
# --overwrite generates a new key value every time, invalidating the old one.
# Only renew if the registry has lost it.
log "Checking unraid-api registry for $KEY_NAME..."
EXISTING=$(timeout 5 /usr/local/sbin/unraid-api apikey --name "$KEY_NAME" --json </dev/null 2>/dev/null)
KEY=$(echo "$EXISTING" | jq -r '.key // empty' 2>/dev/null)
if [[ -n "$KEY" ]]; then
PREVIEW="${KEY:0:8}...${KEY: -4}"
echo "API key valid ✅ — $VAR_NAME = $PREVIEW"
log "Key found in registry — no renewal needed"
exit 0
fi
log "Key not found in registry — creating new key..."
RAW=$(timeout 10 /usr/local/sbin/unraid-api apikey \
--name "$KEY_NAME" --create --overwrite \
--description "Varaverk plugin" --roles ADMIN --json </dev/null 2>&1)
if [[ -z "$RAW" ]]; then
error "unraid-api returned no output"
exit 1
fi
KEY=$(echo "$RAW" | jq -r '.key // empty' 2>/dev/null)
if [[ -z "$KEY" ]]; then
error "No key in unraid-api response: ${RAW:0:200}"
exit 1
fi
# ──────────────────────────────────────────────────────────────────────────────
if grep -q "^\s*${VAR_NAME}\s*=" "$CONF_FILE"; then
sed -i "s|^\(\s*${VAR_NAME}\s*=\s*\)\"[^\"]*\"|\1\"${KEY}\"|" "$CONF_FILE"
else
echo " ${VAR_NAME}=\"${KEY}\"" >> "$CONF_FILE"
fi
PREVIEW="${KEY:0:8}...${KEY: -4}"
log "Writing new key to: $CONF_FILE"
warn "API key renewed ✅ — $VAR_NAME = $PREVIEW (registry had lost it)"
# ── Push renewed key into each partner's OWN conf ─────────────────────────────
# Each host's conf is its complete keychest — no cross-host conf files needed.
# SSH_KEY is set by detect_hosts() — this server's outbound private key.
if [[ -z "$SSH_KEY" ]]; then
log "No SSH key configured — skipping partner push"
exit 0
fi
for host_var in $(compgen -v | grep -E '^HOST[0-9]+$'); do
partner_host="${!host_var}"
[[ -z "$partner_host" ]] && continue
[[ "${host_var,,}" == "${MY_ID,,}" ]] && continue
partner_slot="${host_var,,}" # e.g. host2
partner_ip=$(resolve_tailscale_ip "$partner_host" 2>/dev/null || true)
[[ -z "$partner_ip" ]] && { log "Cannot resolve IP for $partner_host — skipping"; continue; }
# Target is the partner's OWN conf on their machine
partner_conf="/boot/config/plugins/varaverk/Configurations/${partner_slot}.conf"
tmp=$(mktemp /tmp/vv_kp_XXXXXX.sh)
remote="/tmp/vv_kp_${RANDOM}.sh"
chmod 700 "$tmp"
# Key stays in the temp file — never appears in SSH command args
cat > "$tmp" <<PUSHSCRIPT
#!/bin/sh
target='${partner_conf}'
if grep -q "\b${VAR_NAME}\b" "\$target" 2>/dev/null; then
sed -i 's|^\(\\s*${VAR_NAME}\\s*=\\s*\)"[^"]*"|\1"${KEY}"|' "\$target"
else
printf ' ${VAR_NAME}="%s"\n' '${KEY}' >> "\$target"
fi
echo ok
PUSHSCRIPT
if timeout 10 scp -i "$SSH_KEY" -o ConnectTimeout=10 -o BatchMode=yes \
-o StrictHostKeyChecking=no "$tmp" "root@${partner_ip}:${remote}" 2>/dev/null; then
if timeout 10 ssh -i "$SSH_KEY" -o ConnectTimeout=10 -o BatchMode=yes \
-o StrictHostKeyChecking=no "root@${partner_ip}" \
"bash '${remote}'; rc=\$?; rm -f '${remote}'; exit \$rc" 2>/dev/null | grep -q ok; then
log "Key pushed to $partner_host ✅"
else
warn "Key push to $partner_host failed — they can create their own copy"
fi
else
warn "SCP to $partner_host failed — skipping"
fi
rm -f "$tmp"
done
@@ -0,0 +1,166 @@
#!/bin/bash
# ==============================================================================================
# ================================= Unraid API Key Renewal ====================================
# ==============================================================================================
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# Creates/overwrites the Varaverk API key in the unraid-api service registry at
# array start. The registry is ephemeral — OS updates and service restarts clear
# it. This script re-registers the key every boot so Varaverk's enhanced
# monitoring self-heals without manual intervention.
#
# Also updates HOST*_UNRAID_API_KEY in the local host conf so the partnership
# page always reflects the live key value.
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
# unraid_api_key_renew.sh
# Renew the key. Silent on success.
#
# unraid_api_key_renew.sh --dry-run
# Show what would happen — no changes made.
#
# unraid_api_key_renew.sh --log
# Verbose output.
#
# ==============================================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../../../load_config.sh"
parse_args "$@"
acquire_lock
detect_hosts
# ──────────────────────────────────────────────────────────────────────────────
CONF_FILE="$SCRIPT_DIR/../../../Configurations/${MY_ID,,}.conf"
VAR_NAME="${MY_ID}_UNRAID_API_KEY"
# Key name: "Varaverk <hostname>" stripping any unraid- prefix
# Space separator — unRAID API only allows letters, numbers, and spaces
HOSTNAME_SUFFIX=$(hostname -s 2>/dev/null | sed 's/^[Uu][Nn][Rr][Aa][Ii][Dd]-//' || hostname -s)
KEY_NAME="Varaverk ${HOSTNAME_SUFFIX}"
log "$ICON_GEAR Conf file: $CONF_FILE"
log "$ICON_GEAR Key var: $VAR_NAME"
log "$ICON_GEAR Key name: $KEY_NAME"
if [[ ! -f "$CONF_FILE" ]]; then
error "Conf file not found: $CONF_FILE"
exit 1
fi
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would check registry for $KEY_NAME, renew only if missing"
exit 0
fi
# ──────────────────────────────────────────────────────────────────────────────
# Check if key already exists in the unraid-api registry before creating.
# --overwrite generates a new key value every time, invalidating the old one.
# Only renew if the registry has lost it.
log "Checking unraid-api registry for $KEY_NAME..."
EXISTING=$(timeout 5 /usr/local/sbin/unraid-api apikey --name "$KEY_NAME" --json </dev/null 2>/dev/null)
KEY=$(echo "$EXISTING" | jq -r '.key // empty' 2>/dev/null)
if [[ -n "$KEY" ]]; then
PREVIEW="${KEY:0:8}...${KEY: -4}"
# Always sync registry key → conf, even if the key was already there.
# Conf gets wiped on git pull / conf regeneration without touching the registry.
CONF_HAS_KEY=$(grep -oP "(?<=^\s*${VAR_NAME}=\")[^\"]*" "$CONF_FILE" 2>/dev/null || true)
if [[ "$CONF_HAS_KEY" == "$KEY" ]]; then
echo "API key valid ✅ — $VAR_NAME = $PREVIEW"
log "Key in registry and conf — no action needed"
exit 0
fi
log "Key in registry but conf is stale — syncing..."
if grep -q "^\s*${VAR_NAME}\s*=" "$CONF_FILE"; then
sed -i "s|^\(\s*${VAR_NAME}\s*=\s*\)\"[^\"]*\"|\1\"${KEY}\"|" "$CONF_FILE"
else
echo " ${VAR_NAME}=\"${KEY}\"" >> "$CONF_FILE"
fi
echo "API key synced to conf ✅ — $VAR_NAME = $PREVIEW"
exit 0
fi
log "Key not found in registry — creating new key..."
RAW=$(timeout 10 /usr/local/sbin/unraid-api apikey \
--name "$KEY_NAME" --create --overwrite \
--description "Varaverk plugin" --roles ADMIN --json </dev/null 2>&1)
if [[ -z "$RAW" ]]; then
error "unraid-api returned no output"
exit 1
fi
KEY=$(echo "$RAW" | jq -r '.key // empty' 2>/dev/null)
if [[ -z "$KEY" ]]; then
error "No key in unraid-api response: ${RAW:0:200}"
exit 1
fi
# ──────────────────────────────────────────────────────────────────────────────
if grep -q "^\s*${VAR_NAME}\s*=" "$CONF_FILE"; then
sed -i "s|^\(\s*${VAR_NAME}\s*=\s*\)\"[^\"]*\"|\1\"${KEY}\"|" "$CONF_FILE"
else
echo " ${VAR_NAME}=\"${KEY}\"" >> "$CONF_FILE"
fi
PREVIEW="${KEY:0:8}...${KEY: -4}"
log "Writing new key to: $CONF_FILE"
warn "API key renewed ✅ — $VAR_NAME = $PREVIEW (registry had lost it)"
# ── Push renewed key into each partner's OWN conf ─────────────────────────────
# Each host's conf is its complete keychest — no cross-host conf files needed.
# SSH_KEY is set by detect_hosts() — this server's outbound private key.
if [[ -z "$SSH_KEY" ]]; then
log "No SSH key configured — skipping partner push"
exit 0
fi
for host_var in $(compgen -v | grep -E '^HOST[0-9]+$'); do
partner_host="${!host_var}"
[[ -z "$partner_host" ]] && continue
[[ "${host_var,,}" == "${MY_ID,,}" ]] && continue
partner_slot="${host_var,,}" # e.g. host2
partner_ip=$(resolve_tailscale_ip "$partner_host" 2>/dev/null || true)
[[ -z "$partner_ip" ]] && { log "Cannot resolve IP for $partner_host — skipping"; continue; }
# Target is the partner's OWN conf on their machine
partner_conf="/boot/config/plugins/varaverk/Configurations/${partner_slot}.conf"
tmp=$(mktemp /tmp/vv_kp_XXXXXX.sh)
remote="/tmp/vv_kp_${RANDOM}.sh"
chmod 700 "$tmp"
# Key stays in the temp file — never appears in SSH command args
cat > "$tmp" <<PUSHSCRIPT
#!/bin/sh
target='${partner_conf}'
if grep -q "\b${VAR_NAME}\b" "\$target" 2>/dev/null; then
sed -i 's|^\(\\s*${VAR_NAME}\\s*=\\s*\)"[^"]*"|\1"${KEY}"|' "\$target"
else
printf ' ${VAR_NAME}="%s"\n' '${KEY}' >> "\$target"
fi
echo ok
PUSHSCRIPT
if timeout 10 scp -i "$SSH_KEY" -o ConnectTimeout=10 -o BatchMode=yes \
-o StrictHostKeyChecking=no "$tmp" "root@${partner_ip}:${remote}" 2>/dev/null; then
if timeout 10 ssh -i "$SSH_KEY" -o ConnectTimeout=10 -o BatchMode=yes \
-o StrictHostKeyChecking=no "root@${partner_ip}" \
"bash '${remote}'; rc=\$?; rm -f '${remote}'; exit \$rc" 2>/dev/null | grep -q ok; then
log "Key pushed to $partner_host ✅"
else
warn "Key push to $partner_host failed — they can create their own copy"
fi
else
warn "SCP to $partner_host failed — skipping"
fi
rm -f "$tmp"
done
@@ -0,0 +1,219 @@
#!/bin/bash
# ==============================================================================================
# ================================= Array Start Orchestrator ===================================
# ==============================================================================================
# Single entry point for array start — fired by the Varaverk plugin's
# disks_mounted event hook (Plugin/unraid/event/disks_mounted/array_start_jobs).
# Launches everything configured in ARRAY_START_SCRIPTS in master.conf.
# This script exits after launching all scripts — the event hook sees it complete normally.
#
# ── WHAT IT LAUNCHES ──────────────────────────────────────────────────────────────────────────
# Configured in master.conf ARRAY_START_SCRIPTS — no changes to this script ever needed.
# Current order (order matters — see below):
#
# ONE-SHOT (run and exit naturally):
# System_Essentials/unraid_api_key_renew.sh — re-register Varaverk API key at boot
# System_Essentials/inotify_tuning.sh — raise inotify limits before containers start
# System_Essentials/docker_syslog_filter.sh — suppress veth log noise before logs fill
# System_Essentials/php_fpm_max_children.sh — WebGUI performance tuning
# Transcodes/ramdisk_setup.sh — create tmpfs + symlink before Emby starts
# Docker_Essentials/docker_network_connect.sh — ensure networks + container connections
#
# CONTINUOUS (run until array stops):
# Fallback/fallback.sh — mutual fallback monitor
#
# NOTE: watchdogs (docker, system, stability) are NOT launched here.
# They run via watchdog_orchestrator.sh every 15 min (cron), not as daemons.
#
# ── WHY ORDER MATTERS ─────────────────────────────────────────────────────────────────────────
# unraid_api_key_renew.sh — before anything else — self-heals API registry at boot
# inotify_tuning.sh — must run BEFORE Code-Server and other containers start
# containers that start with low inotify limits keep them ✅
# docker_syslog_filter — must run BEFORE any container starts creating veth interfaces
# ramdisk_setup.sh — must run BEFORE Emby starts transcoding
# docker_network_connect — must run BEFORE watchdogs check container states
# fallback.sh — last — needs everything else stable to make decisions
#
# ── ONE-SHOT vs CONTINUOUS DETECTION ─────────────────────────────────────────────────────────
# Script is launched in background with bash script.sh &
# After 1 second: if PID still alive → continuous (running in background)
# if PID dead + exit 0 → one-shot completed successfully
# if PID dead + exit N → failure
#
# ── SAFEGUARDS ────────────────────────────────────────────────────────────────────────────────
# Root check — all launched scripts require root
# acquire_lock — prevents duplicate array start launches
# detect_hosts() — MY_ID in notifications
# platform_require_cmd — notify validated before use
# chmod +x auto-fix — non-executable scripts fixed before launch
# Full path on failure — shows exact path for debugging
# notify on failures — alert if any script fails to launch
#
# ── CONFIGURATION (master.conf) ───────────────────────────────────────────────────────────────
# ARRAY_START_SCRIPTS — ordered list of scripts to launch at array start
#
# ── USAGE ─────────────────────────────────────────────────────────────────────────────────────
# array_started.sh — normal launch (called by Varaverk disks_mounted event hook)
# array_started.sh --dry-run — show what would be launched without launching
# array_started.sh --status — show configured scripts and their current state
# array_started.sh --log — verbose output per script
# ==============================================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ECOSYSTEM_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
source "$ECOSYSTEM_ROOT/load_config.sh"
parse_args "$@"
# ==============================================================================================
# ━━━ Setup ━━━
# ==============================================================================================
if [[ "$EUID" -ne 0 ]]; then
error "Must be run as root"
exit 1
fi
platform_require_cmd \
"/usr/local/emhttp/plugins/dynamix/scripts/notify" \
"" "" \
"unRAID notify script" || warn "unRAID notify script not found — native notifications disabled"
acquire_lock
detect_hosts
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — scripts will not be launched"
# ==============================================================================================
# ━━━ Status ━━━
# ==============================================================================================
if [[ "$SHOW_STATUS" == true ]]; then
echo ""
echo "━━━━━ $ICON_SUMMARY ARRAY START STATUS ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_GEAR Scripts: ${#ARRAY_START_SCRIPTS[@]} configured"
echo ""
for relative_path in "${ARRAY_START_SCRIPTS[@]}"; do
[[ -z "$relative_path" ]] && continue
script_path="$ECOSYSTEM_ROOT/$relative_path"
script_name=$(basename "$script_path")
if [[ ! -f "$script_path" ]]; then
echo " $ICON_ERROR $script_name — FILE NOT FOUND"
echo " $script_path"
continue
fi
[[ ! -x "$script_path" ]] && flag=" (not executable — will auto-fix)" || flag=""
# Check if currently running
if pgrep -f "$script_path" >/dev/null 2>&1; then
RUN_PID=$(pgrep -f "$script_path" | head -1)
echo " $ICON_RUNNING $script_name — RUNNING (PID $RUN_PID)${flag}"
else
echo " $ICON_NOT_RUNNING $script_name — not running${flag}"
fi
done
echo "━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
fi
# ==============================================================================================
# ━━━ Launch Scripts ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_GEAR Array Start — $MY_ID — $(date '+%Y-%m-%d %H:%M:%S') ━━━"
log "Ecosystem root: $ECOSYSTEM_ROOT"
log "Launching ${#ARRAY_START_SCRIPTS[@]} script(s)..."
echo ""
START=$(date +%s)
LAUNCHED=0
FAILED=0
FAILED_SCRIPTS=()
for relative_path in "${ARRAY_START_SCRIPTS[@]}"; do
[[ -z "$relative_path" ]] && continue
SCRIPT_PATH="$ECOSYSTEM_ROOT/$relative_path"
SCRIPT_NAME=$(basename "$SCRIPT_PATH")
# File existence check
if [[ ! -f "$SCRIPT_PATH" ]]; then
error "$SCRIPT_NAME — not found"
error " Expected: $SCRIPT_PATH"
(( FAILED++ ))
FAILED_SCRIPTS+=("$SCRIPT_NAME")
continue
fi
# Auto-fix permissions — chmod +x if needed
if [[ ! -x "$SCRIPT_PATH" ]]; then
warn "$SCRIPT_NAME — not executable, fixing..."
chmod +x "$SCRIPT_PATH" || {
error "$SCRIPT_NAME — chmod +x failed"
(( FAILED++ ))
FAILED_SCRIPTS+=("$SCRIPT_NAME")
continue
}
fi
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would launch: $SCRIPT_NAME"
(( LAUNCHED++ ))
continue
fi
log "$ICON_START Launching $SCRIPT_NAME..."
bash "$SCRIPT_PATH" &
PID=$!
# Brief settle — 1s enough to detect immediate failures
sleep 1
if kill -0 "$PID" 2>/dev/null; then
# Still running → continuous script
warn "$SCRIPT_NAME — running (PID $PID) ✅"
(( LAUNCHED++ ))
else
# Exited — check if one-shot success or failure
wait "$PID"
EXIT_CODE=$?
if [[ "$EXIT_CODE" -eq 0 ]]; then
log "$SCRIPT_NAME — completed (one-shot) ✅"
(( LAUNCHED++ ))
else
error "$SCRIPT_NAME — exited with code $EXIT_CODE"
error " Path: $SCRIPT_PATH"
(( FAILED++ ))
FAILED_SCRIPTS+=("$SCRIPT_NAME")
fi
fi
done
END=$(date +%s)
# ==============================================================================================
# ━━━ Summary ━━━
# ==============================================================================================
echo ""
echo "━━━━━ $ICON_SUMMARY ARRAY START SUMMARY ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_SUCCESS Launched: $LAUNCHED"
[[ "$FAILED" -gt 0 ]] && echo "$ICON_ERROR Failed: $FAILED — ${FAILED_SCRIPTS[*]}"
echo "$ICON_TIME Duration: $(format_duration $(( END - START )))"
echo ""
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — no scripts launched"
elif [[ "$FAILED" -gt 0 ]]; then
warn "Status: $FAILED script(s) failed — ${FAILED_SCRIPTS[*]}"
notify "Array start on $(hostname) ($MY_ID) — $FAILED script(s) failed: ${FAILED_SCRIPTS[*]}" \
"Array Start" "warning"
else
echo "$ICON_DONE Status: all $LAUNCHED script(s) launched ✅"
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
@@ -0,0 +1,226 @@
#!/bin/bash
# ==============================================================================================
# ================================= Array Start Orchestrator ===================================
# ==============================================================================================
# Single entry point for array start — fired by the Varaverk plugin's
# disks_mounted event hook (Plugin/unraid/event/disks_mounted/array_start_jobs).
# Launches everything configured in ARRAY_START_SCRIPTS in master.conf.
# This script exits after launching all scripts — the event hook sees it complete normally.
#
# ── WHAT IT LAUNCHES ──────────────────────────────────────────────────────────────────────────
# Configured in master.conf ARRAY_START_SCRIPTS — no changes to this script ever needed.
# Current order (order matters — see below):
#
# ONE-SHOT (run and exit naturally):
# System_Essentials/unraid_api_key_renew.sh — re-register Varaverk API key at boot
# System_Essentials/inotify_tuning.sh — raise inotify limits before containers start
# System_Essentials/docker_syslog_filter.sh — suppress veth log noise before logs fill
# System_Essentials/php_fpm_max_children.sh — WebGUI performance tuning
# Transcodes/ramdisk_setup.sh — create tmpfs + symlink before Emby starts
# Docker_Essentials/docker_network_connect.sh — ensure networks + container connections
#
# CONTINUOUS (run until array stops):
# Fallback/fallback.sh — mutual fallback monitor
#
# NOTE: watchdogs (docker, system, stability) are NOT launched here.
# They run via watchdog_orchestrator.sh every 15 min (cron), not as daemons.
#
# ── WHY ORDER MATTERS ─────────────────────────────────────────────────────────────────────────
# unraid_api_key_renew.sh — before anything else — self-heals API registry at boot
# inotify_tuning.sh — must run BEFORE Code-Server and other containers start
# containers that start with low inotify limits keep them ✅
# docker_syslog_filter — must run BEFORE any container starts creating veth interfaces
# ramdisk_setup.sh — must run BEFORE Emby starts transcoding
# docker_network_connect — must run BEFORE watchdogs check container states
# fallback.sh — last — needs everything else stable to make decisions
#
# ── ONE-SHOT vs CONTINUOUS DETECTION ─────────────────────────────────────────────────────────
# Script is launched in background with bash script.sh &
# After 1 second: if PID still alive → continuous (running in background)
# if PID dead + exit 0 → one-shot completed successfully
# if PID dead + exit N → failure
#
# ── SAFEGUARDS ────────────────────────────────────────────────────────────────────────────────
# Root check — all launched scripts require root
# acquire_lock — prevents duplicate array start launches
# detect_hosts() — MY_ID in notifications
# platform_require_cmd — notify validated before use
# chmod +x auto-fix — non-executable scripts fixed before launch
# Full path on failure — shows exact path for debugging
# notify on failures — alert if any script fails to launch
#
# ── CONFIGURATION (master.conf) ───────────────────────────────────────────────────────────────
# ARRAY_START_SCRIPTS — ordered list of scripts to launch at array start
#
# ── USAGE ─────────────────────────────────────────────────────────────────────────────────────
# array_started.sh — normal launch (called by Varaverk disks_mounted event hook)
# array_started.sh --dry-run — show what would be launched without launching
# array_started.sh --status — show configured scripts and their current state
# array_started.sh --log — verbose output per script
# ==============================================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ECOSYSTEM_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
source "$ECOSYSTEM_ROOT/load_config.sh"
parse_args "$@"
# ==============================================================================================
# ━━━ Setup ━━━
# ==============================================================================================
if [[ "$EUID" -ne 0 ]]; then
error "Must be run as root"
exit 1
fi
platform_require_cmd \
"/usr/local/emhttp/plugins/dynamix/scripts/notify" \
"" "" \
"unRAID notify script" || warn "unRAID notify script not found — native notifications disabled"
acquire_lock
detect_hosts
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — scripts will not be launched"
# ==============================================================================================
# ━━━ Status ━━━
# ==============================================================================================
if [[ "$SHOW_STATUS" == true ]]; then
echo ""
echo "━━━━━ $ICON_SUMMARY ARRAY START STATUS ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_GEAR Scripts: ${#ARRAY_START_SCRIPTS[@]} configured"
echo ""
for relative_path in "${ARRAY_START_SCRIPTS[@]}"; do
[[ -z "$relative_path" ]] && continue
script_path="$ECOSYSTEM_ROOT/$relative_path"
script_name=$(basename "$script_path")
if [[ ! -f "$script_path" ]]; then
echo " $ICON_ERROR $script_name — FILE NOT FOUND"
echo " $script_path"
continue
fi
[[ ! -x "$script_path" ]] && flag=" (not executable — will auto-fix)" || flag=""
# Check if currently running
if pgrep -f "$script_path" >/dev/null 2>&1; then
RUN_PID=$(pgrep -f "$script_path" | head -1)
echo " $ICON_RUNNING $script_name — RUNNING (PID $RUN_PID)${flag}"
else
echo " $ICON_NOT_RUNNING $script_name — not running${flag}"
fi
done
echo "━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
fi
# ==============================================================================================
# ━━━ Launch Scripts ━━━
# ==============================================================================================
if [[ ${#ARRAY_START_SCRIPTS[@]} -eq 0 ]]; then
notify "Array started on $LOCAL_SERVER_NAME ($MY_ID) but ARRAY_START_SCRIPTS is empty — boot sequence skipped. Check master.conf." \
"Array Start" "alert"
error "ARRAY_START_SCRIPTS is empty — check master.conf"
exit 1
fi
echo ""
echo "━━━ $ICON_GEAR Array Start — $MY_ID — $(date '+%Y-%m-%d %H:%M:%S') ━━━"
log "Ecosystem root: $ECOSYSTEM_ROOT"
log "Launching ${#ARRAY_START_SCRIPTS[@]} script(s)..."
echo ""
START=$(date +%s)
LAUNCHED=0
FAILED=0
FAILED_SCRIPTS=()
for relative_path in "${ARRAY_START_SCRIPTS[@]}"; do
[[ -z "$relative_path" ]] && continue
SCRIPT_PATH="$ECOSYSTEM_ROOT/$relative_path"
SCRIPT_NAME=$(basename "$SCRIPT_PATH")
# File existence check
if [[ ! -f "$SCRIPT_PATH" ]]; then
error "$SCRIPT_NAME — not found"
error " Expected: $SCRIPT_PATH"
(( FAILED++ ))
FAILED_SCRIPTS+=("$SCRIPT_NAME")
continue
fi
# Auto-fix permissions — chmod +x if needed
if [[ ! -x "$SCRIPT_PATH" ]]; then
warn "$SCRIPT_NAME — not executable, fixing..."
chmod +x "$SCRIPT_PATH" || {
error "$SCRIPT_NAME — chmod +x failed"
(( FAILED++ ))
FAILED_SCRIPTS+=("$SCRIPT_NAME")
continue
}
fi
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would launch: $SCRIPT_NAME"
(( LAUNCHED++ ))
continue
fi
log "$ICON_START Launching $SCRIPT_NAME..."
bash "$SCRIPT_PATH" &
PID=$!
# Brief settle — 1s enough to detect immediate failures
sleep 1
if kill -0 "$PID" 2>/dev/null; then
# Still running → continuous script
warn "$SCRIPT_NAME — running (PID $PID) ✅"
(( LAUNCHED++ ))
else
# Exited — check if one-shot success or failure
wait "$PID"
EXIT_CODE=$?
if [[ "$EXIT_CODE" -eq 0 ]]; then
log "$SCRIPT_NAME — completed (one-shot) ✅"
(( LAUNCHED++ ))
else
error "$SCRIPT_NAME — exited with code $EXIT_CODE"
error " Path: $SCRIPT_PATH"
(( FAILED++ ))
FAILED_SCRIPTS+=("$SCRIPT_NAME")
fi
fi
done
END=$(date +%s)
# ==============================================================================================
# ━━━ Summary ━━━
# ==============================================================================================
echo ""
echo "━━━━━ $ICON_SUMMARY ARRAY START SUMMARY ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_SUCCESS Launched: $LAUNCHED"
[[ "$FAILED" -gt 0 ]] && echo "$ICON_ERROR Failed: $FAILED — ${FAILED_SCRIPTS[*]}"
echo "$ICON_TIME Duration: $(format_duration $(( END - START )))"
echo ""
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — no scripts launched"
elif [[ "$FAILED" -gt 0 ]]; then
warn "Status: $FAILED script(s) failed — ${FAILED_SCRIPTS[*]}"
notify "Array start on $(hostname) ($MY_ID) — $FAILED script(s) failed: ${FAILED_SCRIPTS[*]}" \
"Array Start" "warning"
else
echo "$ICON_DONE Status: all $LAUNCHED script(s) launched ✅"
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,503 @@
<style>
.vv-wd-card { background:#161616;border:1px solid #2a2a2a;border-radius:6px;padding:10px;min-width:0; }
.vv-wd-sec { font-size:10px;font-weight:bold;color:#444;letter-spacing:.07em;text-transform:uppercase;margin-bottom:6px; }
.vv-wd-row { display:flex;justify-content:space-between;align-items:baseline;gap:6px;margin:2px 0; }
.vv-wd-lbl { font-size:11px;color:#444;white-space:nowrap; }
.vv-wd-val { font-size:12px;color:#bbb;text-align:right; }
.vv-wd-sep { border:none;border-top:1px solid #1e1e1e;margin:6px 0; }
.vv-wd-pill { font-size:10px;padding:1px 6px;border-radius:2px;background:#1e1e1e;color:#666;border:1px solid #272727; }
.vv-wd-pill.ok { background:#0d1f0d;color:#4caf50;border-color:#1a3a1a; }
.vv-wd-pill.warn { background:#1f1500;color:#ffb74d;border-color:#3a2800; }
.vv-wd-pill.err { background:#200d0d;color:#ef5350;border-color:#3a1a1a; }
.vv-wd-pill-row { display:flex;flex-wrap:wrap;gap:4px;margin-top:4px; }
.vv-wd-bar { height:5px;border-radius:2px;background:#1e1e1e;margin-top:3px;overflow:hidden; }
.vv-wd-bar-fill{ height:100%;border-radius:2px;transition:width .3s; }
.vv-wd-badge { font-size:11px;font-weight:bold;padding:2px 8px;border-radius:3px; }
.vv-wd-badge.ok { background:#0d1f0d;color:#4caf50; }
.vv-wd-badge.soft { background:#1f1f00;color:#cddc39; }
.vv-wd-badge.med { background:#1f1000;color:#ffb74d; }
.vv-wd-badge.hard { background:#200d0d;color:#ef5350; }
.vv-wd-node-h { display:flex;align-items:center;gap:8px;margin-bottom:10px; }
.vv-wd-node-id { font-size:12px;font-weight:bold;color:#666;letter-spacing:.06em;text-transform:uppercase; }
.vv-wd-dot { width:6px;height:6px;border-radius:50%;flex-shrink:0; }
.vv-wd-strike-row { display:flex;align-items:baseline;gap:6px;margin:2px 0; }
.vv-wd-strike-name{ font-size:11px;color:#777;flex:1; }
.vv-wd-strike-cnt { font-size:11px;font-weight:bold;color:#ffb74d; }
.vv-wd-reboot-ts { font-size:11px;color:#555;margin:1px 0; }
.vv-wd-ctr-row { display:flex;justify-content:space-between;align-items:baseline;margin:2px 0; }
.vv-wd-ctr-name{ font-size:11px;color:#888; }
.vv-wd-ctr-lim { font-size:11px;color:#555; }
.vv-wd-pressure{ grid-column:1/-1;border-color:#3a2000;background:#1a1000; }
/* One host per row — inner grid sizes all cards equally */
.vv-wd-host-row {
grid-column: 1 / -1;
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 12px;
}
@media (max-width: 900px) {
.vv-wd-host-row { grid-template-columns: 1fr; }
}
</style>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;padding:0 2px;">
<span style="font-size:13px;font-weight:bold;color:#888;text-transform:uppercase;letter-spacing:.06em;">Watchdog</span>
<span style="font-size:11px;color:#3a3a3a;" id="vv-wd-ts"></span>
</div>
<div id="vv-wd-grid" style="display:grid;grid-template-columns:repeat(8,1fr);gap:12px;">
<div style="grid-column:1/-1;color:#444;font-size:12px;padding:16px 0;text-align:center;">Loading…</div>
</div>
<script>
(function() {
const GB = 1073741824;
function _relTime(ts) {
if (!ts) return '—';
const d = Math.floor(Date.now() / 1000) - ts;
if (d < 60) return 'just now';
if (d < 3600) return Math.floor(d / 60) + 'm ago';
if (d < 86400)return Math.floor(d / 3600) + 'h ' + Math.floor((d % 3600) / 60) + 'm ago';
return Math.floor(d / 86400) + 'd ago';
}
function _fmtBytes(b) {
if (b >= GB) return (b / GB).toFixed(1) + ' GB';
if (b >= 1048576) return (b / 1048576).toFixed(0) + ' MB';
return (b / 1024).toFixed(0) + ' KB';
}
function _dur(s) {
const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60);
if (d) return d + 'd ' + h + 'h';
if (h) return h + 'h ' + m + 'm';
return m + 'm';
}
function _row(lbl, val) {
return `<div class="vv-wd-row"><span class="vv-wd-lbl">${lbl}</span><span class="vv-wd-val">${val}</span></div>`;
}
function _bar(pct, col) {
return `<div class="vv-wd-bar"><div class="vv-wd-bar-fill" style="width:${Math.min(pct,100)}%;background:${col}"></div></div>`;
}
function _pill(label, cls) {
return `<span class="vv-wd-pill ${cls}">${label}</span>`;
}
function _levelLabel(level) {
return ['OK', 'SOFT', 'MEDIUM', 'HARD'][level] || '?';
}
function _levelCls(level) {
return ['ok', 'soft', 'med', 'hard'][level] || 'ok';
}
// ── Pressure alert card ───────────────────────────────────────────────────────
function _pressureCard(node) {
const st = node.states;
if (!st || st.rw_level === 0) return '';
const level = st.rw_level;
const cls = _levelCls(level);
const label = _levelLabel(level);
const paused = (st.rw_paused || []).filter(Boolean);
const stopped = (st.rw_stopped || []).filter(Boolean);
const pausedHtml = paused.length ? paused.map(c => _pill(c, 'warn')).join('') : '';
const stoppedHtml = stopped.length ? stopped.map(c => _pill(c, 'err')).join('') : '';
return `<div class="vv-wd-card vv-wd-pressure" style="grid-column:1/-1">
<div style="display:flex;align-items:center;gap:10px;margin-bottom:8px;">
<span class="vv-wd-badge ${cls}">PRESSURE ${label}</span>
<span style="font-size:11px;color:#7a5020;">${node.id} (${node.hostname})</span>
${st.mem_shutdown ? `<span class="vv-wd-badge hard" style="margin-left:auto;">MEM SHUTDOWN ACTIVE</span>` : ''}
</div>
${paused.length ? `<div style="margin-bottom:4px;"><span class="vv-wd-lbl">Paused:</span> <span class="vv-wd-pill-row" style="display:inline-flex;">${pausedHtml}</span></div>` : ''}
${stopped.length ? `<div><span class="vv-wd-lbl">Stopped:</span> <span class="vv-wd-pill-row" style="display:inline-flex;">${stoppedHtml}</span></div>` : ''}
</div>`;
}
// ── System health card ────────────────────────────────────────────────────────
function _systemCard(node, cfg) {
const sys = node.system;
if (!sys) {
return `<div class="vv-wd-card">
<div class="vv-wd-node-h">
<span class="vv-wd-dot" style="background:#444"></span>
<span class="vv-wd-node-id">${node.id}</span>
<span style="font-size:11px;color:#3a3a3a;">${node.hostname}</span>
<span class="vv-wd-badge" style="margin-left:auto;background:#1a1a1a;color:#444">UNREACHABLE</span>
</div>
</div>`;
}
const memGb = sys.mem_avail / GB;
const memTotGb = sys.mem_total / GB;
const usedPct = memTotGb > 0 ? ((memTotGb - memGb) / memTotGb) * 100 : 0;
const memCol = memGb < cfg.sys_mem_gb ? '#ef5350'
: memGb < cfg.rw_hard_gb ? '#ef5350'
: memGb < cfg.rw_medium_gb ? '#ffb74d'
: memGb < cfg.rw_soft_gb ? '#cddc39' : '#4caf50';
const loadPct = sys.cores > 0 ? (sys.load1 / (sys.cores * cfg.rw_load_med)) * 100 : 0;
const loadCol = sys.load1 > sys.cores * cfg.rw_load_med ? '#ef5350'
: sys.load1 > sys.cores * cfg.rw_load_soft ? '#ffb74d'
: '#4caf50';
const apiOnly = sys.api_only === true;
const daemonDot = sys.daemon_ok === null ? '#555' : sys.daemon_ok ? '#4caf50' : '#ef5350';
const daemonTxt = sys.daemon_ok === null ? '—' : sys.daemon_ok ? 'daemon ok' : 'daemon err';
const st = node.states || {};
const level = st.rw_level || 0;
const dotCol = level >= 3 ? '#ef5350' : level >= 2 ? '#ffb74d' : level >= 1 ? '#cddc39'
: apiOnly ? '#4a7a9b' // blue-grey: API-only, no watchdog state
: '#4caf50';
return `<div class="vv-wd-card">
<div class="vv-wd-node-h">
<span class="vv-wd-dot" style="background:${dotCol}"></span>
<span class="vv-wd-node-id">${node.id}</span>
<span style="font-size:11px;color:#3a3a3a;">${node.hostname}</span>
<span class="vv-wd-badge ${apiOnly ? '' : _levelCls(level)}"
style="margin-left:auto;${apiOnly ? 'background:#0d1f2a;color:#4a9eff;' : ''}"
>${apiOnly ? 'API ONLY' : _levelLabel(level)}</span>
</div>
<div class="vv-wd-sec">System</div>
${_row('RAM free', `<span style="color:${memCol}">${_fmtBytes(sys.mem_avail)}</span> / ${_fmtBytes(sys.mem_total)}`)}
${_bar(usedPct, memCol)}
<div style="display:flex;justify-content:space-between;margin-top:1px;font-size:10px;color:#333">
<span>free</span>
<span>${cfg.rw_soft_gb}G soft · ${cfg.rw_hard_gb}G hard · ${cfg.sys_mem_gb}G reboot</span>
</div>
<div style="height:5px"></div>
${!apiOnly ? `${_row('Load avg', `<span style="color:${loadCol}">${sys.load1.toFixed(2)}</span> / ${sys.cores} cores`)}
${_bar(loadPct, loadCol)}
<div style="height:5px"></div>` : ''}
${_row('Uptime', sys.uptime ? _dur(sys.uptime) : '—')}
${_row('Docker', `<span style="color:${daemonDot}">${daemonTxt}</span>`)}
${!apiOnly ? (sys.oom_count > 0 ? _row('OOM kills', `<span style="color:#ef5350">${sys.oom_count}</span>`) : _row('OOM kills', '<span style="color:#333">0</span>')) : ''}
${apiOnly ? `<div style="font-size:10px;color:#333;margin-top:6px;">Watchdog state unavailable — SSH not configured</div>` : ''}
</div>`;
}
// ── Docker watchdog state card ────────────────────────────────────────────────
function _dockerCard(node, cfg) {
const st = node.states;
if (!st) return `<div class="vv-wd-card"><div class="vv-wd-sec">Docker Watchdog</div><div style="color:#3a3a3a;font-size:11px;padding:8px 0;">No data</div></div>`;
const strikes = Object.entries(st.ctr_strikes || {});
const skiplist = st.skiplist || [];
const restarts = (st.restarts || []).slice(0, 10);
const daemonCls = st.daemon_strikes > 0 ? 'err' : 'ok';
const allOk = strikes.length === 0 && skiplist.length === 0 && st.daemon_strikes === 0;
// Group restarts by container for last-24h summary
const rCounts = {};
for (const r of restarts) {
rCounts[r.name] = (rCounts[r.name] || 0) + 1;
}
let strikesHtml = '';
if (strikes.length === 0 && st.daemon_strikes === 0) {
strikesHtml = '<div style="color:#333;font-size:11px;">0 active strikes</div>';
} else {
if (st.daemon_strikes > 0) {
strikesHtml += `<div class="vv-wd-strike-row"><span class="vv-wd-strike-name">daemon</span><span class="vv-wd-strike-cnt">${st.daemon_strikes}</span></div>`;
}
for (const [name, cnt] of strikes) {
strikesHtml += `<div class="vv-wd-strike-row"><span class="vv-wd-strike-name">${name}</span><span class="vv-wd-strike-cnt">${cnt} / ${cfg.cpu_fail_lim}</span></div>`;
}
}
let skipHtml = '';
if (skiplist.length === 0) {
skipHtml = '<div style="color:#333;font-size:11px;">empty</div>';
} else {
skipHtml = `<div class="vv-wd-pill-row">${skiplist.map(c => _pill(c, 'err')).join('')}</div>`;
}
let restartHtml = '';
const rcEntries = Object.entries(rCounts);
if (rcEntries.length === 0) {
restartHtml = '<div style="color:#333;font-size:11px;">none (24h)</div>';
} else {
restartHtml = rcEntries.map(([n, c]) =>
`<div class="vv-wd-row"><span class="vv-wd-lbl">${n}</span><span class="vv-wd-val" style="color:${c >= cfg.restart_limit ? '#ef5350' : '#ffb74d'}">${c}×</span></div>`
).join('');
}
return `<div class="vv-wd-card">
<div class="vv-wd-sec">Docker Watchdog</div>
<div style="display:flex;gap:6px;margin-bottom:8px;">
${_pill(st.daemon_restart ? 'daemon restarted' : 'daemon ok', daemonCls)}
${allOk ? _pill('all clear', 'ok') : ''}
</div>
<div class="vv-wd-sec">Strikes</div>
${strikesHtml}
<hr class="vv-wd-sep">
<div class="vv-wd-sec">Skip list</div>
${skipHtml}
<hr class="vv-wd-sep">
<div class="vv-wd-sec">Restarts (24h)</div>
${restartHtml}
</div>`;
}
// ── Stability / reboot card ───────────────────────────────────────────────────
function _stabilityCard(node, cfg) {
const st = node.states;
if (!st) return `<div class="vv-wd-card"><div class="vv-wd-sec">Stability</div><div style="color:#3a3a3a;font-size:11px;padding:8px 0;">No data</div></div>`;
const reboots = st.reboots || [];
const sysStr = Object.entries(st.sys_strikes || {});
const rebootCls = reboots.length >= cfg.reboot_limit ? 'err' : reboots.length > 0 ? 'warn' : 'ok';
let sysHtml = '';
if (sysStr.length === 0) {
sysHtml = '<div style="color:#333;font-size:11px;">0 active strikes</div>';
} else {
sysHtml = sysStr.map(([k, v]) =>
`<div class="vv-wd-strike-row"><span class="vv-wd-strike-name" style="font-size:10px;">${k.replace(/_/g,' ')}</span><span class="vv-wd-strike-cnt">${v}</span></div>`
).join('');
}
const rebootHtml = reboots.length === 0
? '<div style="color:#333;font-size:11px;">none (12h)</div>'
: reboots.map(ts => `<div class="vv-wd-reboot-ts">${_relTime(ts)}</div>`).join('');
return `<div class="vv-wd-card">
<div class="vv-wd-sec">Stability</div>
<div style="display:flex;gap:6px;margin-bottom:8px;">
${_pill(`${reboots.length} / ${cfg.reboot_limit} reboots`, rebootCls)}
${_pill(`${cfg.reboot_window}h window`, '')}
</div>
<div class="vv-wd-sec">Strikes</div>
${sysHtml}
<hr class="vv-wd-sep">
<div class="vv-wd-sec">Reboot history</div>
${rebootHtml}
</div>`;
}
// ── Config inventory card ─────────────────────────────────────────────────────
function _configCard(node) {
const cfg = node.config || {};
const mon = Object.entries(cfg.monitored || {});
const req = cfg.required || [];
const ign = cfg.ignore || [];
const paus = cfg.pause_list|| [];
const stop = cfg.stop_list || [];
const crit = cfg.critical || [];
const monHtml = mon.length === 0
? '<div style="color:#333;font-size:11px;">none</div>'
: mon.map(([name, mb]) => {
const gb = (mb / 1024).toFixed(0);
return `<div class="vv-wd-ctr-row"><span class="vv-wd-ctr-name">${name}</span><span class="vv-wd-ctr-lim">${gb} GB</span></div>`;
}).join('');
const reqHtml = req.length === 0
? '<div style="color:#333;font-size:11px;">none</div>'
: `<div class="vv-wd-pill-row">${req.map(c => _pill(c, crit.includes(c) ? '' : '')).join('')}</div>`;
const ignHtml = ign.length === 0
? '<div style="color:#3a3a3a;font-size:11px;">none</div>'
: `<div class="vv-wd-pill-row">${ign.map(c => _pill(c, '')).join('')}</div>`;
const pausHtml = paus.length === 0
? '<div style="color:#3a3a3a;font-size:11px;">none</div>'
: `<div class="vv-wd-pill-row">${paus.map(c => _pill(c, 'warn')).join('')}</div>`;
const stopHtml = stop.length === 0
? '<div style="color:#3a3a3a;font-size:11px;">none</div>'
: `<div class="vv-wd-pill-row">${stop.map(c => _pill(c, 'err')).join('')}</div>`;
return `<div class="vv-wd-card" style="grid-column:span 4;">
<div style="display:flex;gap:6px;align-items:center;margin-bottom:8px;">
<span class="vv-wd-node-id">${node.id}</span>
<span style="font-size:11px;color:#3a3a3a;">${node.hostname}</span>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;">
<div>
<div class="vv-wd-sec">Mem limits (Tier 1)</div>
${monHtml}
<div style="height:8px"></div>
<div class="vv-wd-sec">Required</div>
${reqHtml}
</div>
<div>
<div class="vv-wd-sec">Pause at medium pressure</div>
${pausHtml}
<div style="height:8px"></div>
<div class="vv-wd-sec">Stop at hard pressure</div>
${stopHtml}
<div style="height:8px"></div>
<div class="vv-wd-sec">Scan ignore</div>
${ignHtml}
</div>
</div>
</div>`;
}
// ── Storage watchdog card ─────────────────────────────────────────────────────
function _storageCard(node, cfg) {
const st = node.states;
const nodeCfg = node.config || {};
if (!st) return `<div class="vv-wd-card"><div class="vv-wd-sec">Storage Watchdog</div><div style="color:#3a3a3a;font-size:11px;padding:8px 0;">No data</div></div>`;
const storWd = st.storage_wd || {};
const growthStr = Object.entries(storWd.growth_strikes || {});
const logStr = Object.entries(storWd.log_strikes || {});
const totalIssues = growthStr.length + logStr.length;
const allClear = totalIssues === 0;
// Baseline info
const bCount = storWd.baseline_count ?? 0;
const bAge = storWd.baseline_age_sec;
let baselineNote = bCount > 0
? `${bCount} containers tracked`
: 'no baseline yet (builds after first cycle)';
if (bAge != null && bCount > 0) {
const bAgeStr = bAge < 120 ? bAge + 's ago' : bAge < 3600 ? Math.floor(bAge/60) + 'm ago' : Math.floor(bAge/3600) + 'h ago';
baselineNote += ` · updated ${bAgeStr}`;
}
// Suppress ceilings configured for this host
const sizes = Object.entries(nodeCfg.appdata_sizes || {});
const sizesHtml = sizes.length
? sizes.map(([c, mb]) => _pill(`${c} <${Math.round(mb/1024)}GB`, '')).join('')
: '';
let growthHtml = '';
if (growthStr.length === 0) {
growthHtml = '<div style="color:#333;font-size:11px;">no active strikes</div>';
} else {
growthHtml = growthStr.map(([name, cnt]) =>
`<div class="vv-wd-strike-row">
<span class="vv-wd-strike-name">${name}</span>
<span class="vv-wd-strike-cnt">${cnt} / ${cfg.stor_strike_lim}</span>
</div>`
).join('');
}
let logHtml = '';
if (logStr.length === 0) {
logHtml = '<div style="color:#333;font-size:11px;">no active strikes</div>';
} else {
logHtml = logStr.map(([key, cnt]) => {
const display = key.length > 36 ? '…' + key.slice(-36) : key;
return `<div class="vv-wd-strike-row">
<span class="vv-wd-strike-name" style="font-size:10px;" title="${key}">${display}</span>
<span class="vv-wd-strike-cnt">${cnt} / ${cfg.stor_strike_lim}</span>
</div>`;
}).join('');
}
return `<div class="vv-wd-card">
<div class="vv-wd-sec">Storage Watchdog</div>
<div style="display:flex;gap:5px;flex-wrap:wrap;margin-bottom:6px;">
${allClear ? _pill('all clear', 'ok') : _pill(totalIssues + ' active strike' + (totalIssues !== 1 ? 's' : ''), 'warn')}
${_pill('growth >' + cfg.growth_gb + 'GB/cycle', '')}
${_pill('log max ' + cfg.log_max_gb + 'GB', '')}
${cfg.truncate_logs ? _pill('auto-truncate on', 'ok') : _pill('auto-truncate off', '')}
</div>
<div style="font-size:10px;color:#3a3a3a;margin-bottom:8px;">${baselineNote}</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
<div>
<div class="vv-wd-sec">Growth strikes</div>
${growthHtml}
</div>
<div>
<div class="vv-wd-sec">Log size strikes</div>
${logHtml}
</div>
</div>
${sizes.length ? `<hr class="vv-wd-sep"><div class="vv-wd-sec">Suppress ceilings (this host)</div><div class="vv-wd-pill-row">${sizesHtml}</div>` : ''}
</div>`;
}
// ── Network watchdog card ─────────────────────────────────────────────────────
function _networkCard(node, cfg) {
const st = node.states;
const nodeCfg = node.config || {};
if (!st) return `<div class="vv-wd-card"><div class="vv-wd-sec">Network Watchdog</div><div style="color:#3a3a3a;font-size:11px;padding:8px 0;">No data</div></div>`;
const netWd = st.network_wd || {};
const npmStr = netWd.npm_strikes ?? 0;
const npmCls = npmStr >= cfg.npm_strike_lim ? 'err' : npmStr > 0 ? 'warn' : 'ok';
const ddnsDomain = nodeCfg.ddns_domain || '';
const ddnsCtr = nodeCfg.ddns_container || '';
const npmUrl = nodeCfg.npm_url || '';
const ddnsHtml = ddnsDomain
? `${_row('DDNS domain', `<span style="color:#888;">${ddnsDomain}</span>`)}
${ddnsCtr ? _row('DDNS container', `<span style="color:#888;">${ddnsCtr}</span>`) : ''}`
: _row('DDNS', '<span style="color:#444;">not configured for this host</span>');
const npmHtml = npmUrl
? `${_row('NPM URL', `<span style="color:#888;font-size:10px;">${npmUrl}</span>`)}
${_row('NPM strikes', `<span class="vv-wd-pill ${npmCls}" style="font-size:10px;">${npmStr} / ${cfg.npm_strike_lim}</span>`)}`
: _row('NPM check', '<span style="color:#444;">not configured for this host</span>');
return `<div class="vv-wd-card">
<div class="vv-wd-sec">Network Watchdog</div>
<div style="display:flex;gap:5px;flex-wrap:wrap;margin-bottom:8px;">
${cfg.net_wd_enabled ? _pill('enabled', 'ok') : _pill('disabled', '')}
${_pill('Tailscale check ' + (cfg.ts_check ? 'on' : 'off'), cfg.ts_check ? '' : '')}
${npmUrl ? _pill('NPM ' + npmStr + '/' + cfg.npm_strike_lim + ' strikes', npmCls) : ''}
</div>
${ddnsHtml}
<hr class="vv-wd-sep">
${npmHtml}
</div>`;
}
// ── Main render ───────────────────────────────────────────────────────────────
function _render(data) {
const nodes = data.nodes || [];
const cfg = data.cfg || {};
let html = '';
// Per-host: pressure alert (if active) then all 5 watchdog cards in one equal-spaced row
for (const node of nodes) {
html += _pressureCard(node);
html += `<div class="vv-wd-host-row">
${_systemCard(node, cfg)}
${_dockerCard(node, cfg)}
${_stabilityCard(node, cfg)}
${_storageCard(node, cfg)}
${_networkCard(node, cfg)}
</div>`;
}
// Config inventory — separate row, each node span 4 (2 nodes = full row)
for (const node of nodes) html += _configCard(node);
if (!html) html = '<div style="grid-column:1/-1;color:#444;font-size:12px;padding:16px 0;text-align:center;">No nodes configured.</div>';
document.getElementById('vv-wd-grid').innerHTML = html;
const ts = data.ts
? new Date(data.ts * 1000).toLocaleString([], {
month:'numeric', day:'numeric', year:'numeric',
hour:'2-digit', minute:'2-digit', second:'2-digit'})
: '';
document.getElementById('vv-wd-ts').textContent = ts ? 'Updated: ' + ts : '';
}
function vvWdLoad() {
fetch('/plugins/varaverk/api/watchdog.php')
.then(r => r.json())
.then(_render)
.catch(() => {});
}
vvWdLoad();
setInterval(vvWdLoad, 30000);
})();
</script>
@@ -0,0 +1,7 @@
# Memory Index
- [Workspace](workspace.md) — primary workspace is /root; plugin lives at /boot/config/plugins/varaverk
- [Feedback: Commits](feedback_commits.md) — no Co-Authored-By unless explicitly asked
- [Feedback: Dev vs Prod](feedback_dev_vs_prod.md) — only ever work in /boot/config/plugins/varaverk; dev folder is stale, ignore it
- [Project: Varaverk](project_varaverk.md) — self-healing two-server Unraid home media ecosystem
- [User Profile](user_profile.md) — user context and preferences
@@ -0,0 +1,8 @@
# Memory Index
- [Workspace](workspace.md) — primary workspace is /root; plugin lives at /boot/config/plugins/varaverk
- [Feedback: Commits](feedback_commits.md) — no Co-Authored-By unless explicitly asked
- [Feedback: Dev vs Prod](feedback_dev_vs_prod.md) — only ever work in /boot/config/plugins/varaverk; dev folder is stale, ignore it
- [Project: Varaverk](project_varaverk.md) — self-healing two-server Unraid home media ecosystem
- [User Profile](user_profile.md) — user context and preferences
- [Idea: Watchdog Health URLs](project_watchdog_health_urls.md) — add HTTP health checks for NPM/Authelia/Lldap; NPM admin port is 7818
@@ -0,0 +1,19 @@
---
name: project-watchdog-health-urls
description: Planned improvement — add HTTP health check URLs for auth stack containers in HOST1_WATCHDOG_CONTAINER_URLS
metadata:
node_type: memory
type: project
originSessionId: 6cd2156e-fb44-400a-86c7-5844179fe511
---
Add health check URLs for critical containers that are currently only checked via `docker ps` (alive but hung won't be caught).
**Why:** A hung-but-not-crashed NPM/Authelia fails all traffic silently with no watchdog trigger. Currently only Emby has a health URL.
**How to apply:** When user is ready, add to host1.conf:
- Define `HOST1_NPM_ADMIN_URL="http://localhost:7818"` near the NPM identity section (no existing URL var for admin panel — `HOST1_NETWORK_WATCHDOG_NPM_URL` is the external domain, different thing)
- Add to `HOST1_WATCHDOG_CONTAINER_URLS`: NPM, Authelia (9091), Authelia-Secondary (9092), Lldap-Gmer4Lfe (17170) — confirm ports before adding
- Pattern: define URL var, reference it in the array (same as HOST1_EMBY_URL pattern)
NPM admin port confirmed by user: **7818** (not 81 — that's the partnership WebUI port in HOST1_PARTNERSHIP_AUTH_WEBUIS)
@@ -0,0 +1,389 @@
<style>
.vv-dk-toolbar { display:flex;align-items:center;gap:8px;margin-bottom:12px;padding:0 2px;flex-wrap:wrap; }
.vv-dk-title { font-size:13px;font-weight:bold;color:#888;text-transform:uppercase;letter-spacing:.06em;flex:1; }
.vv-dk-ts { font-size:11px;color:#3a3a3a; }
.vv-dk-btn { font-size:11px;padding:3px 10px;border-radius:3px;border:1px solid #2a2a2a;background:#1a1a1a;color:#888;cursor:pointer;white-space:nowrap; }
.vv-dk-btn:hover { background:#222;color:#bbb; }
.vv-dk-btn.active { background:#1a2a1a;border-color:#2d4a2d;color:#6fcf97; }
.vv-dk-btn.warn { border-color:#3a2800;background:#1f1500;color:#ffb74d; }
.vv-dk-btn.prim { border-color:#1a3a5a;background:#0d1f2a;color:#5c9fd4; }
/* Folder card */
.vv-dk-folder { background:#161616;border:1px solid #222;border-radius:6px;min-width:0;grid-column:span 4;overflow:hidden; }
.vv-dk-folder.wide{ grid-column:span 8; }
.vv-dk-folder.edit{ border-color:#2a3a2a; }
.vv-dk-folder-h { display:flex;align-items:center;gap:8px;padding:9px 12px;border-bottom:1px solid #1e1e1e;background:#111; }
.vv-dk-folder-name{ font-size:12px;font-weight:bold;color:#777;flex:1;min-width:0; }
.vv-dk-folder-name input { background:#0d0d0d;border:1px solid #333;border-radius:3px;color:#bbb;font-size:12px;padding:1px 6px;width:100%;box-sizing:border-box; }
.vv-dk-folder-count{ font-size:10px;color:#333;white-space:nowrap; }
.vv-dk-folder-del { font-size:13px;color:#444;cursor:pointer;padding:0 2px;line-height:1; }
.vv-dk-folder-del:hover { color:#ef5350; }
/* Container rows */
.vv-dk-ctr-list { max-height:480px;overflow-y:auto; }
.vv-dk-ctr { padding:8px 12px;border-bottom:1px solid #1a1a1a;display:grid;grid-template-columns:auto 1fr;gap:0 10px;align-items:start; }
.vv-dk-ctr:last-child { border-bottom:none; }
.vv-dk-ctr:hover { background:#191919; }
.vv-dk-ctr.stopped { opacity:.6; }
.vv-dk-ctr.edit-mode { cursor:pointer; }
.vv-dk-ctr.edit-mode:hover { background:#141f14; }
.vv-dk-ctr-icon { width:28px;height:28px;border-radius:4px;object-fit:contain;background:#111;grid-row:span 3;align-self:center; }
.vv-dk-ctr-icon-ph{ width:28px;height:28px;border-radius:4px;background:#1a1a1a;border:1px solid #222;grid-row:span 3;align-self:center;display:flex;align-items:center;justify-content:center;font-size:11px;color:#333; }
.vv-dk-ctr-name-row{ display:flex;align-items:baseline;gap:8px;flex-wrap:wrap; }
.vv-dk-ctr-name { font-size:12px;font-weight:bold;color:#bbb; }
.vv-dk-ctr-name a { color:#bbb;text-decoration:none; }
.vv-dk-ctr-name a:hover { color:#6fcf97; }
.vv-dk-ctr-image { font-size:10px;color:#3a3a3a;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:220px; }
.vv-dk-ctr-status { font-size:10px;font-weight:bold;letter-spacing:.04em; }
.vv-dk-ctr-status.running { color:#4caf50; }
.vv-dk-ctr-status.stopped { color:#555; }
.vv-dk-ctr-status.paused { color:#ffb74d; }
.vv-dk-ctr-status.exited { color:#ef5350; }
.vv-dk-meta-row { display:flex;flex-wrap:wrap;align-items:center;gap:5px;margin-top:3px; }
.vv-dk-net-badge { font-size:10px;padding:1px 6px;border-radius:2px;background:#0d1a2a;color:#5c7cfa;border:1px solid #1a2a3a;white-space:nowrap; }
.vv-dk-port-badge { font-size:10px;padding:1px 6px;border-radius:2px;background:#1a1a0a;color:#cddc39;border:1px solid #2a2a1a;white-space:nowrap; }
.vv-dk-paths { margin-top:4px;display:flex;flex-direction:column;gap:1px; }
.vv-dk-path { font-size:10px;color:#333;overflow:hidden;text-overflow:ellipsis;white-space:nowrap; }
.vv-dk-path .src { color:#3a3a3a; }
.vv-dk-path .arr { color:#2a2a2a;margin:0 3px; }
.vv-dk-path .dst { color:#2e3e2e; }
.vv-dk-paths-more { font-size:10px;color:#2a2a2a;cursor:pointer;margin-top:1px; }
.vv-dk-paths-more:hover { color:#555; }
/* Ungrouped */
.vv-dk-ungroup { grid-column:1/-1;background:#111;border:1px solid #1e1e1e;border-radius:6px;overflow:hidden; }
.vv-dk-ungroup-h { padding:8px 12px;border-bottom:1px solid #1a1a1a;background:#0d0d0d; }
.vv-dk-ungroup-ht { font-size:11px;color:#3a3a3a;font-weight:bold;text-transform:uppercase;letter-spacing:.05em; }
/* New folder placeholder */
.vv-dk-new-card { grid-column:span 4;background:#0d0d0d;border:1px dashed #222;border-radius:6px;min-height:80px;display:flex;align-items:center;justify-content:center;cursor:pointer;color:#2a2a2a;font-size:12px; }
.vv-dk-new-card:hover { border-color:#333;color:#555; }
/* Drift */
.vv-dk-drift { grid-column:1/-1;border:1px solid #3a2800;background:#1a1200;border-radius:6px;padding:10px;margin-bottom:12px; }
.vv-dk-drift-h { font-size:11px;color:#aa7020;font-weight:bold;margin-bottom:6px; }
.vv-dk-drift-row { font-size:11px;color:#7a5020;margin:2px 0; }
/* Popover */
.vv-dk-popover { position:fixed;z-index:9999;background:#1c1c1c;border:1px solid #333;border-radius:5px;padding:5px 0;min-width:170px;box-shadow:0 4px 20px #000c; }
.vv-dk-pop-item { padding:5px 14px;font-size:12px;color:#888;cursor:pointer;white-space:nowrap; }
.vv-dk-pop-item:hover { background:#252525;color:#ccc; }
.vv-dk-pop-item.current { color:#4caf50; }
.vv-dk-pop-item.sep { border-top:1px solid #222;margin-top:4px;padding-top:8px; }
.vv-dk-pop-item.blue { color:#5c9fd4; }
</style>
<div class="vv-dk-toolbar">
<span class="vv-dk-title">Docker</span>
<button class="vv-dk-btn warn" id="vv-dk-sync-c2j" title="Apply conf desired state to JSON">Sync conf → JSON</button>
<button class="vv-dk-btn prim" id="vv-dk-sync-j2c" title="Capture JSON state into conf">Sync JSON → conf</button>
<button class="vv-dk-btn" id="vv-dk-edit-toggle">Edit folders</button>
<span style="font-size:10px;color:#1a3a1a;" id="vv-dk-fv3"></span>
<span class="vv-dk-ts" id="vv-dk-ts"></span>
</div>
<div id="vv-dk-drift-banner"></div>
<div id="vv-dk-grid" style="display:grid;grid-template-columns:repeat(8,1fr);gap:12px;">
<div style="grid-column:1/-1;color:#444;font-size:12px;padding:16px 0;text-align:center;">Loading…</div>
</div>
<div class="vv-dk-popover" id="vv-dk-popover" style="display:none;"></div>
<script>
(function() {
let _data = null;
let _editMode = false;
let _popTarget = null;
// ── API ───────────────────────────────────────────────────────────────────────
function _api(params, cb) {
const fd = new FormData();
for (const [k,v] of Object.entries(params)) fd.append(k, v);
fetch('/plugins/varaverk/api/docker.php', {method:'POST', body:fd})
.then(r => r.json()).then(cb)
.catch(e => console.error('docker api', e));
}
// ── Helpers ───────────────────────────────────────────────────────────────────
function _esc(s) {
return (s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function _shortImage(img) {
// strip registry host if present (e.g. lscr.io/linuxserver/sonarr → linuxserver/sonarr)
return img.replace(/^[a-z0-9.-]+\.[a-z]{2,}\//i, '');
}
// ── Container row ─────────────────────────────────────────────────────────────
function _ctrRow(c, folderId) {
const statusCls = c.running ? 'running' : (c.status || 'stopped');
const statusLbl = c.running ? 'RUNNING' : (c.status || 'STOPPED').toUpperCase();
const rowCls = 'vv-dk-ctr' + (c.running ? '' : ' stopped') + (_editMode ? ' edit-mode' : '');
const editAttr = _editMode ? `data-ctr="${_esc(c.name)}" data-folder="${folderId||''}" title="Move ${c.name}"` : '';
// Icon or placeholder
const iconHtml = c.icon
? `<img class="vv-dk-ctr-icon" src="${_esc(c.icon)}" alt="" onerror="this.style.display='none'">`
: `<div class="vv-dk-ctr-icon-ph">◻</div>`;
// Name — link to WebUI if available
const nameHtml = c.webui
? `<a href="${_esc(c.webui)}" target="_blank">${_esc(c.name)}</a>`
: _esc(c.name);
// Networks + IPs
const nets = Object.entries(c.networks || {});
const netBadges = nets.map(([net, ip]) =>
`<span class="vv-dk-net-badge" title="${_esc(net)}">${_esc(ip)}</span>`).join('');
// Ports (cap at 4)
const ports = c.ports || [];
const portBadges = ports.slice(0,4).map(p =>
`<span class="vv-dk-port-badge">${_esc(p)}</span>`).join('');
const morePorts = ports.length > 4 ? `<span class="vv-dk-port-badge" style="color:#555">+${ports.length-4}</span>` : '';
// Paths (bind mounts, show 3 with expand)
const mounts = c.mounts || [];
const visibleMounts = mounts.slice(0, 3);
const hiddenCount = mounts.length - visibleMounts.length;
let pathsHtml = '';
if (visibleMounts.length) {
pathsHtml = `<div class="vv-dk-paths">` +
visibleMounts.map(m =>
`<div class="vv-dk-path"><span class="vv-dk-path src">${_esc(m.src)}</span><span class="vv-dk-path arr">→</span><span class="vv-dk-path dst">${_esc(m.dst)}</span></div>`
).join('') +
(hiddenCount > 0 ? `<div class="vv-dk-paths-more" data-expand="${_esc(c.name)}">+ ${hiddenCount} more path${hiddenCount>1?'s':''}</div>` : '') +
`</div>`;
}
return `<div class="${rowCls}" ${editAttr}>
${iconHtml}
<div>
<div class="vv-dk-ctr-name-row">
<span class="vv-dk-ctr-name">${nameHtml}</span>
<span class="vv-dk-ctr-status ${statusCls}">${statusLbl}</span>
</div>
<div class="vv-dk-ctr-image">${_esc(_shortImage(c.image||''))}</div>
${(netBadges || portBadges) ? `<div class="vv-dk-meta-row">${netBadges}${portBadges}${morePorts}</div>` : ''}
${pathsHtml}
</div>
</div>`;
}
// ── Folder card ───────────────────────────────────────────────────────────────
function _folderCard(f) {
const wide = f.total > 7;
const cls = 'vv-dk-folder' + (wide ? ' wide' : '') + (_editMode ? ' edit' : '');
const running = f.running, total = f.total;
let nameHtml;
if (_editMode) {
nameHtml = `<span class="vv-dk-folder-name"><input type="text" value="${_esc(f.name)}" data-folder-id="${f.id}" class="vv-dk-rename-input"></span>`;
} else {
nameHtml = `<span class="vv-dk-folder-name">${_esc(f.name)}</span>`;
}
const delBtn = _editMode
? `<span class="vv-dk-folder-del" data-delete-folder="${f.id}" title="Delete folder">×</span>` : '';
const rows = (f.containers || []).map(c => _ctrRow(c, f.id)).join('');
return `<div class="${cls}" data-folder-id="${f.id}">
<div class="vv-dk-folder-h">
${nameHtml}
<span class="vv-dk-folder-count">${running}/${total} running</span>
${delBtn}
</div>
<div class="vv-dk-ctr-list">${rows || '<div style="padding:10px 12px;color:#2a2a2a;font-size:11px;">Empty</div>'}</div>
</div>`;
}
// ── Ungrouped section ─────────────────────────────────────────────────────────
function _ungroupedCard(containers) {
if (!containers.length) return '';
const rows = containers.map(c => _ctrRow(c, '')).join('');
return `<div class="vv-dk-ungroup">
<div class="vv-dk-ungroup-h">
<span class="vv-dk-ungroup-ht">Ungrouped (${containers.length})</span>
</div>
<div class="vv-dk-ctr-list">${rows}</div>
</div>`;
}
// ── Drift banner ──────────────────────────────────────────────────────────────
function _driftBanner(drift) {
const el = document.getElementById('vv-dk-drift-banner');
if (!drift || !drift.length) { el.innerHTML = ''; return; }
const rows = drift.map(d => {
const actual = d.actual ? `in <strong>${d.actual}</strong>` : 'ungrouped';
return `<div class="vv-dk-drift-row"><strong>${d.container}</strong> — conf wants <strong>${d.conf}</strong>, currently ${actual}</div>`;
}).join('');
el.innerHTML = `<div class="vv-dk-drift">
<div class="vv-dk-drift-h">⚠ Conf / JSON drift — ${drift.length} container${drift.length>1?'s':''} out of sync</div>
${rows}
<button class="vv-dk-btn warn" style="margin-top:8px;" id="vv-dk-fix-drift">Apply conf → JSON now</button>
</div>`;
document.getElementById('vv-dk-fix-drift')?.addEventListener('click', _syncC2J);
}
// ── Main render ───────────────────────────────────────────────────────────────
function _render(data) {
_data = data;
let html = '';
for (const f of data.folders || []) html += _folderCard(f);
if (_editMode) html += `<div class="vv-dk-new-card" id="vv-dk-add-folder">+ New folder</div>`;
html += _ungroupedCard(data.ungrouped || []);
if (!html) html = '<div style="grid-column:1/-1;color:#444;font-size:12px;padding:16px 0;text-align:center;">No containers found.</div>';
document.getElementById('vv-dk-grid').innerHTML = html;
_driftBanner(data.drift);
const ts = data.ts ? new Date(data.ts*1000).toLocaleString([],{month:'numeric',day:'numeric',year:'numeric',hour:'2-digit',minute:'2-digit',second:'2-digit'}) : '';
document.getElementById('vv-dk-ts').textContent = ts ? 'Updated: '+ts : '';
const fv3El = document.getElementById('vv-dk-fv3');
if (fv3El) fv3El.textContent = data.fv3_synced ? '⇄ folder.view3' : '';
_bindEvents();
}
// ── Events ────────────────────────────────────────────────────────────────────
function _bindEvents() {
// Container click in edit mode → folder picker
document.querySelectorAll('.vv-dk-ctr.edit-mode').forEach(el => {
el.addEventListener('click', e => {
e.stopPropagation();
_popTarget = { container: el.dataset.ctr, currentFolderId: el.dataset.folder };
_showPopover(e.clientX, e.clientY);
});
});
// "show more paths" expand
document.querySelectorAll('[data-expand]').forEach(el => {
el.addEventListener('click', e => {
e.stopPropagation();
const cname = el.dataset.expand;
const allCtrs = [...(_data.folders||[]).flatMap(f=>f.containers), ...(_data.ungrouped||[])];
const c = allCtrs.find(x => x.name === cname);
if (!c) return;
const paths = document.querySelector(`.vv-dk-paths-more[data-expand="${CSS.escape(cname)}"]`)?.closest('.vv-dk-paths');
if (!paths || !c.mounts) return;
paths.innerHTML = c.mounts.map(m =>
`<div class="vv-dk-path"><span class="src">${_esc(m.src)}</span><span class="arr">→</span><span class="dst">${_esc(m.dst)}</span></div>`
).join('');
});
});
// Rename inputs
document.querySelectorAll('.vv-dk-rename-input').forEach(el => {
el.addEventListener('keydown', e => { if (e.key==='Enter') el.blur(); });
el.addEventListener('blur', () => {
const fid = el.dataset.folderId, name = el.value.trim();
if (!fid || !name) return;
const f = (_data.folders||[]).find(x=>x.id===fid);
if (f && name===f.name) return;
_api({action:'rename_folder',folder_id:fid,name}, r => { if(r.ok) _reload(); else alert('Rename failed: '+(r.error||'?')); });
});
});
// Delete folder
document.querySelectorAll('[data-delete-folder]').forEach(el => {
el.addEventListener('click', e => {
e.stopPropagation();
const fid = el.dataset.deleteFolder;
const f = (_data.folders||[]).find(x=>x.id===fid);
if (!f || !confirm(`Delete "${f.name}"? Containers will be ungrouped.`)) return;
_api({action:'delete_folder',folder_id:fid}, r => { if(r.ok) _reload(); else alert('Delete failed: '+(r.error||'?')); });
});
});
// New folder
document.getElementById('vv-dk-add-folder')?.addEventListener('click', () => {
const name = prompt('New folder name:');
if (name?.trim()) _api({action:'create_folder',name:name.trim()}, r => { if(r.ok) _reload(); else alert(r.error); });
});
}
// ── Popover ───────────────────────────────────────────────────────────────────
function _showPopover(x, y) {
if (!_data || !_popTarget) return;
const pop = document.getElementById('vv-dk-popover');
const folders = _data.folders || [];
let html = folders.map(f => {
const cur = f.id === _popTarget.currentFolderId;
return `<div class="vv-dk-pop-item${cur?' current':''}" data-fid="${f.id}">${_esc(f.name)}${cur?' ✓':''}</div>`;
}).join('');
html += `<div class="vv-dk-pop-item sep" data-fid="">Ungrouped</div>`;
html += `<div class="vv-dk-pop-item blue sep" data-fid="__new__">+ New folder…</div>`;
pop.innerHTML = html;
pop.style.display = 'block';
const vw=window.innerWidth, vh=window.innerHeight;
pop.style.left = Math.min(x, vw-180)+'px';
pop.style.top = Math.min(y+8, vh-180)+'px';
pop.querySelectorAll('[data-fid]').forEach(el => {
el.addEventListener('click', () => {
const fid = el.dataset.fid;
_hidePopover();
if (fid==='__new__') {
const name = prompt('New folder name:');
if (!name?.trim()) return;
_api({action:'create_folder',name:name.trim()}, r => {
if (!r.ok) { alert(r.error); return; }
_api({action:'move_container',container:_popTarget.container,folder_id:r.id}, r2 => { if(r2.ok) _reload(); });
});
} else {
_api({action:'move_container',container:_popTarget.container,folder_id:fid}, r => { if(r.ok) _reload(); else alert(r.error); });
}
});
});
}
function _hidePopover() {
document.getElementById('vv-dk-popover').style.display='none';
_popTarget=null;
}
document.addEventListener('click', e => {
if (!document.getElementById('vv-dk-popover').contains(e.target)) _hidePopover();
});
// ── Toolbar ───────────────────────────────────────────────────────────────────
document.getElementById('vv-dk-edit-toggle').addEventListener('click', function() {
_editMode = !_editMode;
this.textContent = _editMode ? 'Done editing' : 'Edit folders';
this.classList.toggle('active', _editMode);
if (_data) _render(_data);
});
function _syncC2J() {
_api({action:'sync_conf_to_json'}, r => { if(r.ok) _reload(); else alert(r.error); });
}
document.getElementById('vv-dk-sync-c2j').addEventListener('click', _syncC2J);
document.getElementById('vv-dk-sync-j2c').addEventListener('click', () => {
if (!confirm('Overwrite conf map with current JSON state?')) return;
_api({action:'sync_json_to_conf'}, r => { if(r.ok) _reload(); else alert(r.error); });
});
// ── Load ──────────────────────────────────────────────────────────────────────
function _reload() {
fetch('/plugins/varaverk/api/docker.php')
.then(r=>r.json()).then(_render).catch(()=>{});
}
_reload();
setInterval(_reload, 60000);
})();
</script>
@@ -0,0 +1,384 @@
<?php
// First-run setup wizard — uniform flow for all hosts.
// Step 1: auto-detect environment + server identity form.
// Step 2: auto-populate + guide + checklist.
// master.conf pull (for partner servers) lives in the checklist, not here.
$detectedHostname = vv_get_hostname();
?>
<link rel="stylesheet" href="/plugins/varaverk/css/varaverk.css">
<style>
#vv-setup {
max-width: 580px; margin: 40px auto 0;
background: #141414; border: 1px solid #2a2a2a;
border-radius: 6px; padding: 36px 40px 40px;
font-family: monospace; color: #ccc;
}
#vv-setup h1 { margin: 0 0 4px; font-size: 17px; color: #e0e0e0; font-weight: normal; letter-spacing: .04em; }
.vv-sub { font-size: 12px; color: #555; margin-bottom: 28px; }
.vv-field { margin-bottom: 18px; }
.vv-field label { display: block; font-size: 11px; color: #888; margin-bottom: 5px; text-transform: uppercase; letter-spacing: .06em; }
.vv-field input[type=text],
.vv-field select {
width: 100%; box-sizing: border-box; background: #0d0d0d;
border: 1px solid #333; color: #ddd; padding: 7px 10px;
border-radius: 3px; font-family: monospace; font-size: 13px;
}
.vv-field input:focus, .vv-field select:focus { outline: none; border-color: #555; }
.vv-hint { font-size: 11px; color: #555; margin-top: 4px; }
.vv-role-row { display: flex; gap: 10px; margin-bottom: 22px; }
.vv-role-btn { flex: 1; padding: 9px 0; background: #1a1a1a; border: 1px solid #333;
border-radius: 3px; color: #777; font-family: monospace; font-size: 12px;
cursor: pointer; text-align: center; transition: border-color .15s, color .15s; }
.vv-role-btn.active { border-color: #555; color: #ccc; background: #1e1e1e; }
.vv-cond { display: none; }
.vv-cond.show { display: block; }
hr.vv-hr { border: none; border-top: 1px solid #1e1e1e; margin: 22px 0; }
.vv-btn { width: 100%; padding: 10px; background: #1e1e1e; border: 1px solid #444;
color: #ccc; font-family: monospace; font-size: 13px; border-radius: 3px;
cursor: pointer; letter-spacing: .03em; }
.vv-btn:hover { border-color: #666; color: #eee; }
.vv-btn:disabled { opacity: .4; cursor: default; }
#vv-status { margin-top: 10px; font-size: 12px; color: #666; text-align: center; min-height: 16px; }
#vv-status.ok { color: #4a8; }
#vv-status.err { color: #a44; }
/* Detection banner */
#vv-detect-banner {
background: #0d0d0d; border: 1px solid #2a2a2a; border-radius: 3px;
padding: 11px 14px; margin-bottom: 22px; font-size: 12px; line-height: 1.8; color: #666;
}
#vv-detect-banner .vv-det-row { display: flex; gap: 8px; }
#vv-detect-banner .vv-det-lbl { color: #555; min-width: 100px; }
#vv-detect-banner .vv-det-val { color: #999; }
#vv-detect-banner .loading { color: #444; font-style: italic; }
/* Step 2 */
#vv-step2 { display: none; }
.vv-guide {
background: #0d0d0d; border: 1px solid #2a2a2a; border-radius: 3px;
padding: 13px 16px; margin-bottom: 20px; font-size: 12px; color: #666; line-height: 1.9;
}
.vv-guide ol { margin: 8px 0 0 16px; padding: 0; }
.vv-guide li { margin-bottom: 3px; }
.vv-cl-title { font-size: 11px; color: #555; text-transform: uppercase; letter-spacing: .06em; margin-bottom: 10px; }
.vv-cl-item { display: flex; align-items: flex-start; gap: 10px; padding: 7px 0;
border-bottom: 1px solid #1a1a1a; font-size: 12px; }
.vv-cl-item:last-child { border-bottom: none; }
.vv-cl-icon { font-size: 13px; min-width: 16px; margin-top: 1px; }
.vv-cl-body { flex: 1; }
.vv-cl-label { color: #bbb; }
.vv-cl-detail{ color: #555; font-size: 11px; margin-top: 2px; }
.vv-cl-act { margin-top: 5px; }
.vv-cl-act button { padding: 4px 10px; background: #1a1a1a; border: 1px solid #333; color: #888;
font-family: monospace; font-size: 11px; border-radius: 2px; cursor: pointer; }
.vv-cl-act button:hover { border-color: #555; color: #bbb; }
.vv-cl-err { font-size: 11px; color: #a44; margin-top: 4px; }
</style>
<div id="vv-setup">
<h1>⬡ Varaverk — First Run</h1>
<div class="vv-sub">Set up this server before the plugin can start.</div>
<!-- ── Step 1: Detection + identity ──────────────────────────────────────── -->
<div id="vv-step1">
<div id="vv-detect-banner"><div class="loading">Detecting environment…</div></div>
<div class="vv-field">
<label>This server's hostname</label>
<input type="text" id="vv-hostname" value="<?= htmlspecialchars($detectedHostname) ?>" autocomplete="off" spellcheck="false">
<div class="vv-hint">Must match Unraid Settings → Identification exactly (case-sensitive)</div>
</div>
<hr class="vv-hr">
<label style="display:block;font-size:11px;color:#888;text-transform:uppercase;letter-spacing:.06em;margin-bottom:10px;">Server role</label>
<div class="vv-role-row">
<div class="vv-role-btn active" id="vv-role-primary" onclick="vvSetRole('primary')">
Primary<br><span style="color:#555;font-size:10px;">HOST1 · first server</span>
</div>
<div class="vv-role-btn" id="vv-role-partner" onclick="vvSetRole('partner')">
Partner<br><span style="color:#555;font-size:10px;">HOST2+ · joining primary</span>
</div>
</div>
<div class="vv-cond" id="vv-cond-primary">
<div class="vv-field">
<label>Partner's hostname <span style="color:#444;font-size:10px;">(optional — can fill in later)</span></label>
<input type="text" id="vv-partner-hostname" value="" placeholder="unRAID-PartnerServer" autocomplete="off" spellcheck="false">
</div>
</div>
<div class="vv-cond" id="vv-cond-partner">
<div class="vv-field">
<label>Primary server's hostname <span style="color:#a44;font-size:10px;">required</span></label>
<input type="text" id="vv-primary-hostname" value="" placeholder="unRAID-PrimaryServer" autocomplete="off" spellcheck="false">
</div>
<div class="vv-field">
<label>Your slot</label>
<select id="vv-partner-slot">
<option value="host2">HOST2</option>
<option value="host3">HOST3</option>
<option value="host4">HOST4</option>
</select>
</div>
<div style="font-size:11px;color:#555;margin-bottom:4px;">
SSH key and master.conf pull are handled automatically after save.
</div>
</div>
<button class="vv-btn" id="vv-main-btn" onclick="vvDoSave()">Save and continue →</button>
<div id="vv-status"></div>
</div>
<!-- ── Step 2: Populate + guide + checklist ───────────────────────────────── -->
<div id="vv-step2">
<hr class="vv-hr">
<div style="font-size:10px;color:#555;text-transform:uppercase;letter-spacing:.06em;margin-bottom:14px;">Step 2 of 2</div>
<div id="vv-populate-status" style="font-size:12px;color:#555;margin-bottom:14px;">⟳ Running auto-populate…</div>
<div class="vv-guide">
<strong style="color:#888;">Quick start</strong>
<ol>
<li>Create your Unraid API key below — needed for live monitor stats</li>
<li>Open <strong>Scheduler → Edit host.conf</strong> — only three things need manual entry:<br>
<span style="color:#444;">
<code>EMBY_API_KEY</code> — Emby Dashboard → API Keys → + New Key<br>
<code>DISCORD_WEBHOOK</code> — for notifications (optional)<br>
<code>DAILY_SYNC_SHARES</code> — media paths to rsync nightly<br>
Everything else was auto-populated or has working defaults
</span></li>
<li>If partnering: the checklist below will guide you through pulling HOST1's config and running onboard</li>
</ol>
</div>
<div style="display:flex;gap:10px;align-items:center;margin-bottom:14px;">
<button id="vv-key-btn" onclick="vvCreateKey(this)" class="vv-btn" style="flex:1;background:#1a3a1a;border-color:#2e6b2e;color:#6fcf97;">
Create API Key
</button>
<a href="#" onclick="vvGoScheduler(event)" style="font-size:11px;color:#444;text-decoration:none;white-space:nowrap;">Skip →</a>
</div>
<div id="vv-key-status" style="font-size:12px;min-height:14px;margin-bottom:18px;"></div>
<hr class="vv-hr">
<div class="vv-cl-title">Setup checklist</div>
<div id="vv-checklist"><div style="font-size:12px;color:#444;">Loading…</div></div>
<div style="margin-top:18px;text-align:right;">
<a href="#" onclick="vvGoScheduler(event)" style="font-size:12px;color:#444;text-decoration:none;">Go to Scheduler →</a>
</div>
</div>
</div>
<script>
let _vvRedirect = '?tab=scheduler';
// ── Detection banner ──────────────────────────────────────────────────────────
(function() {
fetch('/plugins/varaverk/api/setup.php?action=detect&_=' + Date.now())
.then(r => r.json()).then(d => {
const b = document.getElementById('vv-detect-banner');
if (!d.ok) { b.innerHTML = '<span style="color:#555">Detection unavailable</span>'; return; }
const modeLabel = d.mode === 'internal'
? '<span style="color:#4a8">internal (NVMe/SSD)</span>'
: '<span style="color:#a84">flash mode (USB boot)</span>';
b.innerHTML =
'<div class="vv-det-row"><span class="vv-det-lbl">OS</span><span class="vv-det-val">Unraid ' + (d.unraid_ver||'') + '</span></div>' +
'<div class="vv-det-row"><span class="vv-det-lbl">Boot device</span><span class="vv-det-val">' + d.boot_device + ' (' + d.transport + ')</span></div>' +
'<div class="vv-det-row"><span class="vv-det-lbl">Storage mode</span><span class="vv-det-val">' + modeLabel + '</span></div>' +
'<div class="vv-det-row"><span class="vv-det-lbl">Scripts dir</span><span class="vv-det-val" style="color:#666">' + d.scripts_dir + '</span></div>';
const hf = document.getElementById('vv-hostname');
if (hf && !hf.value.trim()) hf.value = d.hostname;
}).catch(() => {
document.getElementById('vv-detect-banner').innerHTML = '<span style="color:#444">Detection unavailable</span>';
});
})();
// ── Role toggle ───────────────────────────────────────────────────────────────
let vvRole = 'primary';
function vvSetRole(role) {
vvRole = role;
document.getElementById('vv-role-primary')?.classList.toggle('active', role === 'primary');
document.getElementById('vv-role-partner')?.classList.toggle('active', role === 'partner');
document.getElementById('vv-cond-primary')?.classList.toggle('show', role === 'primary');
document.getElementById('vv-cond-partner')?.classList.toggle('show', role === 'partner');
}
// ── Helpers ───────────────────────────────────────────────────────────────────
function vvSetStatus(msg, cls) {
const s = document.getElementById('vv-status');
s.textContent = msg; s.className = cls || '';
}
function vvSetBtn(text, disabled) {
const b = document.getElementById('vv-main-btn');
if (b) { b.textContent = text; b.disabled = disabled; }
}
function vvGoScheduler(e) {
if (e) e.preventDefault();
window.location.href = _vvRedirect || '?tab=scheduler';
}
// ── Step 2 ────────────────────────────────────────────────────────────────────
function vvShowStep2(redirect, apiKey) {
_vvRedirect = redirect || '?tab=scheduler';
document.getElementById('vv-step1').style.display = 'none';
document.getElementById('vv-step2').style.display = 'block';
if (apiKey && apiKey.ok) {
const btn = document.getElementById('vv-key-btn');
const status = document.getElementById('vv-key-status');
if (btn) { btn.textContent = 'Created ✓'; btn.disabled = true; btn.style.opacity = '.6'; }
if (status) { status.textContent = '✓ API key created automatically'; status.style.color = '#4a8'; }
}
vvRunPopulate();
vvLoadChecklist();
}
// ── Populate ──────────────────────────────────────────────────────────────────
function vvRunPopulate() {
const el = document.getElementById('vv-populate-status');
fetch('/plugins/varaverk/api/setup.php', {
method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({action: 'populate'})
}).then(r => r.json()).then(d => {
if (d.ok) {
const found = (d.lines || []).filter(l => /✅|found|detected/i.test(l));
el.textContent = found.length
? '✓ Auto-populate: ' + found.length + ' field' + (found.length > 1 ? 's' : '') + ' detected'
: '✓ Auto-populate ran — arr keys will fill once services are running';
el.style.color = '#4a8';
} else {
el.textContent = 'Auto-populate skipped — run Tools/conf_populate.sh once your arr containers are up';
el.style.color = '#555';
}
vvLoadChecklist();
}).catch(() => {
el.textContent = 'Auto-populate unavailable — run manually from Scheduler';
el.style.color = '#555';
});
}
// ── Checklist ─────────────────────────────────────────────────────────────────
const vvActionLabels = {
create_key: 'Create API key',
ssh_setup: 'SSH guide →',
run_populate: 'Run now',
pull_master: 'Pull from HOST1',
onboard: 'Partnership tab →',
};
const vvActionHref = {
ssh_setup: '?tab=partnership',
onboard: '?tab=partnership',
};
function vvLoadChecklist() {
fetch('/plugins/varaverk/api/checklist.php?_=' + Date.now())
.then(r => r.json()).then(d => {
const el = document.getElementById('vv-checklist');
if (!d.ok || !d.items) { el.innerHTML = '<span style="color:#555">Unable to load checklist</span>'; return; }
el.innerHTML = d.items.map(item => {
const icon = item.ok === null ? '○' : (item.ok ? '✓' : '✗');
const iclr = item.ok === null ? '#444' : (item.ok ? '#4a8' : '#a66');
let act = '';
if (item.action) {
const lbl = vvActionLabels[item.action] || item.action;
const href = vvActionHref[item.action];
if (href) {
act = `<div class="vv-cl-act"><a href="${href}" style="font-size:11px;color:#556;">${lbl}</a></div>`;
} else if (item.action === 'create_key') {
act = `<div class="vv-cl-act"><button onclick="vvCreateKey(this)">${lbl}</button></div>`;
} else if (item.action === 'run_populate') {
act = `<div class="vv-cl-act"><button onclick="vvRunPopulateBtn(this)">${lbl}</button></div>`;
} else if (item.action === 'pull_master') {
act = `<div class="vv-cl-act"><button onclick="vvPullMaster(this)">${lbl}</button><div id="vv-pull-err" class="vv-cl-err"></div></div>`;
}
}
return `<div class="vv-cl-item">
<div class="vv-cl-icon" style="color:${iclr}">${icon}</div>
<div class="vv-cl-body">
<div class="vv-cl-label">${item.label}</div>
<div class="vv-cl-detail">${item.detail || ''}</div>
${act}
</div>
</div>`;
}).join('');
}).catch(() => {});
}
function vvRunPopulateBtn(btn) {
btn.disabled = true; btn.textContent = '…';
fetch('/plugins/varaverk/api/setup.php', {
method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({action: 'populate'})
}).then(() => { btn.textContent = 'Done'; vvLoadChecklist(); })
.catch(() => { btn.disabled = false; btn.textContent = 'Retry'; });
}
function vvPullMaster(btn) {
btn.disabled = true; btn.textContent = '⟳ Pulling…';
const errEl = document.getElementById('vv-pull-err');
if (errEl) errEl.textContent = '';
fetch('/plugins/varaverk/api/setup.php', {
method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({action: 'pull'})
}).then(r => r.json()).then(d => {
if (d.ok) {
btn.textContent = '✓ Done';
setTimeout(vvLoadChecklist, 600);
} else {
if (errEl) errEl.textContent = d.error || 'Pull failed';
btn.disabled = false; btn.textContent = 'Retry';
}
}).catch(() => { btn.disabled = false; btn.textContent = 'Retry'; });
}
// ── API key ───────────────────────────────────────────────────────────────────
function vvCreateKey(btn) {
const status = document.getElementById('vv-key-status');
btn.disabled = true; btn.textContent = '⟳ Creating…';
fetch('/plugins/varaverk/api/create_api_key.php?_=' + Date.now())
.then(r => r.json()).then(d => {
if (d.ok) {
status.textContent = '✓ Key created — ' + d.key_preview;
status.style.color = '#4a8';
btn.textContent = 'Created ✓'; btn.style.opacity = '.6';
vvLoadChecklist();
} else {
status.textContent = '✗ ' + (d.error || 'Failed');
status.style.color = '#a44';
btn.disabled = false; btn.textContent = 'Retry';
}
}).catch(e => {
status.textContent = '✗ ' + e; status.style.color = '#a44';
btn.disabled = false; btn.textContent = 'Retry';
});
}
// ── Save ──────────────────────────────────────────────────────────────────────
function vvDoSave() {
const hostname = document.getElementById('vv-hostname')?.value.trim();
if (!hostname) { vvSetStatus('✗ Hostname is required', 'err'); return; }
let host1 = '', host2 = '', mySlot = 'host1';
if (vvRole === 'primary') {
host1 = hostname;
host2 = document.getElementById('vv-partner-hostname')?.value.trim() || '';
mySlot = 'host1';
} else {
const primary = document.getElementById('vv-primary-hostname')?.value.trim();
if (!primary) { vvSetStatus('✗ Primary hostname required', 'err'); return; }
mySlot = document.getElementById('vv-partner-slot')?.value || 'host2';
host1 = primary;
if (mySlot === 'host2') host2 = hostname;
}
vvSetBtn('Saving…', true);
fetch('/plugins/varaverk/api/setup.php', {
method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({action:'save', host1, host2, my_slot:mySlot, my_hostname:hostname})
}).then(r => r.json()).then(d => {
if (d.ok) { vvShowStep2(d.redirect || '?tab=scheduler', d.api_key); }
else { vvSetBtn('Save and continue →', false); vvSetStatus('✗ ' + (d.error||'Error'), 'err'); }
}).catch(() => { vvSetBtn('Save and continue →', false); vvSetStatus('✗ Request failed', 'err'); });
}
</script>
@@ -0,0 +1,399 @@
<?php
// Config file parser and writer.
// Reads master.conf and the appropriate host*.conf based on running host.
define('PLUGIN_CFG', '/boot/config/plugins/varaverk/varaverk.cfg');
$_vv_cfg = @parse_ini_file(PLUGIN_CFG) ?: [];
define('SCRIPTS_DIR', $_vv_cfg['SCRIPTS_DIR'] ?? '/boot/config/plugins/varaverk');
define('CONF_DIR', SCRIPTS_DIR . '/Configurations');
define('DATA_DIR', SCRIPTS_DIR . '/data');
define('STATE_DIR', SCRIPTS_DIR . '/State_Files');
define('LOG_DIR', '/var/log/varaverk');
unset($_vv_cfg);
define('VV_SETUP_STATE_FILE', STATE_DIR . '/varaverk_setup.db');
define('VV_CACHE_DIR', '/tmp/vv_cache');
// Read the setup state file into a key=>value array.
function vv_setup_state_read(): array {
$out = [];
foreach (file(VV_SETUP_STATE_FILE) ?: [] as $line) {
[$k, $v] = explode('=', trim($line), 2) + ['', ''];
if ($k !== '') $out[$k] = $v;
}
return $out;
}
// Write the setup state file (creates or overwrites).
function vv_setup_state_write(array $data): void {
$content = '';
foreach ($data as $k => $v) $content .= "$k=$v\n";
file_put_contents(VV_SETUP_STATE_FILE, $content);
}
// Push the setup state file to all remote hosts via scp.
// Unlike master.conf push, this does NOT require the plugin to be installed on the remote —
// it only needs SSH to be reachable, and pushes to /boot/config/ (always available).
function vv_push_setup_state(): void {
if (!file_exists(VV_SETUP_STATE_FILE)) return;
$myHostId = vv_detect_host();
$vars = vv_conf_vars();
$sshKey = $vars[strtoupper($myHostId) . '_SSH_KEY'] ?? '';
if (!$sshKey || !file_exists($sshKey)) return;
$master = vv_read_conf_raw('master.conf');
preg_match_all('/^\s*(HOST\d+)(?:_NAME)?\s*=\s*["\']?(\S+?)["\']?\s*$/m', $master, $m);
$seen = [];
foreach ($m[1] as $i => $hostKey) {
$hostId = strtolower($hostKey);
if ($hostId === $myHostId || isset($seen[$hostId])) continue;
$seen[$hostId] = true;
$hostname = trim($m[2][$i]);
if (!$hostname) continue;
$ip = vv_resolve_tailscale_ip($hostname);
if (!$ip) continue;
$sshBase = 'ssh -i ' . escapeshellarg($sshKey)
. ' -o ConnectTimeout=10 -o StrictHostKeyChecking=no root@' . $ip;
// Ensure the target dir exists (it always should on Unraid, but be safe)
shell_exec($sshBase . ' "mkdir -p /boot/config" 2>/dev/null');
$dest = escapeshellarg('root@' . $ip . ':/boot/config/varaverk_setup.db');
exec('scp -i ' . escapeshellarg($sshKey)
. ' -o ConnectTimeout=10 -o StrictHostKeyChecking=no'
. ' ' . escapeshellarg(VV_SETUP_STATE_FILE) . ' ' . $dest . ' 2>&1');
}
}
// Push master.conf to all remote hosts via scp after a local save.
// Returns one result entry per remote found in master.conf.
// Silently returns [] on non-owner hosts (no SSH key, no remote access).
function vv_push_master_conf(): array {
$myHostId = vv_detect_host();
$vars = vv_conf_vars();
$sshKey = $vars[strtoupper($myHostId) . '_SSH_KEY'] ?? '';
if (!$sshKey || !file_exists($sshKey)) return [];
$localPath = CONF_DIR . '/master.conf';
$master = vv_read_conf_raw('master.conf');
preg_match_all('/^\s*(HOST\d+)(?:_NAME)?\s*=\s*["\']?(\S+?)["\']?\s*$/m', $master, $m);
$results = [];
$seen = [];
foreach ($m[1] as $i => $hostKey) {
$hostId = strtolower($hostKey);
if ($hostId === $myHostId || isset($seen[$hostId])) continue;
$seen[$hostId] = true;
$hostname = trim($m[2][$i]);
$ip = vv_resolve_tailscale_ip($hostname);
if (!$ip) {
$results[] = ['host' => $hostKey, 'ok' => false, 'ready' => false, 'error' => 'Tailscale IP not found'];
continue;
}
// Single SSH call: get remote SCRIPTS_DIR and verify plugin is installed,
// Configurations/ exists, and master.conf is already present.
// Any missing piece means the remote isn't ready — skip rather than push blind.
$sshBase = 'ssh -i ' . escapeshellarg($sshKey)
. ' -o ConnectTimeout=10 -o StrictHostKeyChecking=no root@' . $ip;
$probe = trim(shell_exec(
$sshBase . ' "cfg=$(grep SCRIPTS_DIR /boot/config/plugins/varaverk/varaverk.cfg 2>/dev/null)'
. ' && sd=$(echo \"$cfg\" | grep -oP \'(?<=SCRIPTS_DIR=\")[^\"]+\')'
. ' && test -d \"${sd}/Configurations\"'
. ' && test -f \"${sd}/Configurations/master.conf\"'
. ' && echo \"$sd\""'
) ?: '');
if ($probe === '') {
$results[] = ['host' => $hostKey, 'ok' => false, 'ready' => false,
'error' => 'plugin not installed, dir missing, or master.conf absent — skipped'];
continue;
}
$remoteConf = rtrim($probe, '/') . '/Configurations';
$dest = escapeshellarg('root@' . $ip . ':' . $remoteConf . '/master.conf');
$cmd = 'scp -i ' . escapeshellarg($sshKey)
. ' -o ConnectTimeout=10 -o StrictHostKeyChecking=no'
. ' ' . escapeshellarg($localPath) . ' ' . $dest . ' 2>&1';
exec($cmd, $out, $rc);
$results[] = [
'host' => $hostKey,
'ok' => $rc === 0,
'error' => $rc !== 0 ? implode('; ', $out) : '',
];
}
return $results;
}
function vv_get_hostname(): string {
return trim(shell_exec('hostname -s') ?: '');
}
// Mirror of common.sh resolve_tailscale_ip(): tries `tailscale ip -4` first (Tailscale manages
// the mapping so this survives IP changes), falls back to parsing `tailscale status` text.
function vv_resolve_tailscale_ip(string $hostname): string {
$h = strtolower($hostname);
$ip = trim(shell_exec('tailscale ip -4 ' . escapeshellarg($h) . ' 2>/dev/null') ?: '');
if ($ip) return $ip;
$out = shell_exec('tailscale status 2>/dev/null') ?: '';
foreach (explode("\n", $out) as $line) {
$cols = preg_split('/\s+/', trim($line));
if (isset($cols[1]) && stripos($cols[1], $h . '.') === 0) return $cols[0];
}
return '';
}
function vv_detect_host(): string {
// Reads master.conf for HOST1="name" (or HOST1_NAME="name") and matches running hostname.
// Returns 'host1', 'host2', 'host3', ... or 'unknown'. Works for any number of hosts.
$master = vv_read_conf_raw('master.conf');
preg_match_all('/^\s*(HOST\d+)(?:_NAME)?\s*=\s*["\']?(\S+?)["\']?\s*$/m', $master, $m);
$hostname = vv_get_hostname();
foreach ($m[1] as $i => $key) {
if (strcasecmp($hostname, trim($m[2][$i])) === 0) return strtolower($key);
}
return 'unknown';
}
function vv_is_owner(): bool {
return vv_detect_host() === 'host1';
}
function vv_read_conf_raw(string $filename): string {
$path = CONF_DIR . '/' . $filename;
return file_exists($path) ? file_get_contents($path) : '';
}
function vv_write_conf_raw(string $filename, string $content): bool {
$path = CONF_DIR . '/' . $filename;
$tmp = $path . '.vv.tmp';
if (file_put_contents($tmp, $content) === false) return false;
return rename($tmp, $path);
}
function vv_get_conf_files(): array {
// Returns conf files this host is allowed to view/edit
$host = vv_detect_host();
$files = [];
if ($host === 'host1') {
// Owner sees master.conf + their own host conf
$files[] = 'master.conf';
$files[] = 'host1.conf';
} elseif (preg_match('/^host(\d+)$/', $host)) {
// Any other numbered host sees only their own conf
$files[] = $host . '.conf';
} else {
// Unknown host — show all for dev/debug
foreach (glob(CONF_DIR . '/*.conf') as $f) {
$files[] = basename($f);
}
}
return $files;
}
// Parse conf into key=>value map for $VAR substitution in docs
function vv_conf_vars(): array {
$vars = [];
$files = ['master.conf'];
$host = vv_detect_host();
if (preg_match('/^host\d+$/', $host)) $files[] = $host . '.conf';
foreach ($files as $f) {
$raw = vv_read_conf_raw($f);
// Match: VAR_NAME="value" or VAR_NAME=value (no quotes)
preg_match_all('/^\s*([A-Z0-9_]+)\s*=\s*["\']?([^"\'#\n]*?)["\']?\s*(?:#.*)?$/m', $raw, $m);
foreach ($m[1] as $i => $key) {
$vars[$key] = str_replace('\\$', '$', trim($m[2][$i]));
}
}
return $vars;
}
// Query the Unraid GraphQL API for a given host.
// For the local host queries http://localhost/graphql; for remote hosts uses the Tailscale IP.
// $apiKey may be passed explicitly (needed when querying a remote host from the local host,
// since vv_conf_vars() only loads the current host's conf file).
// Returns the decoded 'data' object on success, null on any failure.
// Debug log written to /tmp/vv_api_debug.json on failure.
function vv_unraid_api_query(string $hostId, string $gql, int $timeoutSec = 5, string $apiKey = ''): ?array {
$vars = vv_conf_vars();
$key = $apiKey ?: ($vars[strtoupper($hostId) . '_UNRAID_API_KEY'] ?? '');
if (!$key) return null;
$myHostId = vv_detect_host();
if (strtolower($hostId) === strtolower($myHostId)) {
$url = 'http://localhost/graphql';
} else {
$hostname = $vars[strtoupper($hostId)] ?? '';
if (!$hostname) return null;
$ip = vv_resolve_tailscale_ip($hostname);
if (!$ip) return null;
$url = "http://{$ip}/graphql";
}
$body = json_encode(['query' => $gql]);
// Use curl (preferred — doesn't require allow_url_fopen, better error handling).
if (function_exists('curl_init')) {
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => ['Content-Type: application/json', "x-api-key: {$key}"],
CURLOPT_POSTFIELDS => $body,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => $timeoutSec,
CURLOPT_CONNECTTIMEOUT => 3,
CURLOPT_FOLLOWLOCATION => false,
]);
$resp = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlErr = curl_error($ch);
curl_close($ch);
} else {
// Fallback to file_get_contents if curl is unavailable.
$ctx = stream_context_create(['http' => [
'method' => 'POST',
'header' => "Content-Type: application/json\r\nx-api-key: {$key}",
'content' => $body,
'timeout' => $timeoutSec,
'ignore_errors' => true,
]]);
$resp = @file_get_contents($url, false, $ctx);
$httpCode = $resp !== false ? 200 : 0;
$curlErr = '';
}
if ($resp === false || $resp === '' || ($httpCode !== 0 && $httpCode !== 200)) {
@file_put_contents(VV_CACHE_DIR . '/vv_api_debug.json', json_encode([
'ts' => time(),
'host' => $hostId,
'url' => $url,
'http_code' => $httpCode,
'curl_err' => $curlErr,
'response' => substr((string)$resp, 0, 800),
], JSON_PRETTY_PRINT));
return null;
}
$decoded = json_decode((string)$resp, true);
// If the API returned GraphQL errors, log them for diagnosis.
if (!empty($decoded['errors'])) {
@file_put_contents(VV_CACHE_DIR . '/vv_api_debug.json', json_encode([
'ts' => time(),
'host' => $hostId,
'url' => $url,
'http_code' => $httpCode,
'errors' => $decoded['errors'],
'data' => $decoded['data'] ?? null,
], JSON_PRETTY_PRINT));
}
// data key present (even if null means query ran but returned nothing useful).
return array_key_exists('data', $decoded ?? []) ? $decoded['data'] : null;
}
// ── File-based API cache (/tmp/vv_cache — tmpfs, cleared on reboot) ───────────
// Read a cached payload. Returns null if missing or older than $maxAge seconds.
function vv_cache_read(string $key, int $maxAge = 90): ?array {
$f = VV_CACHE_DIR . '/' . $key . '.json';
if (!file_exists($f) || (time() - filemtime($f)) > $maxAge) return null;
$raw = file_get_contents($f);
return $raw ? (json_decode($raw, true) ?: null) : null;
}
// Write a payload atomically (tmp + rename) so readers never see a partial file.
function vv_cache_write(string $key, array $data): void {
if (!is_dir(VV_CACHE_DIR)) @mkdir(VV_CACHE_DIR, 0755, true);
$f = VV_CACHE_DIR . '/' . $key . '.json';
$tmp = $f . '.tmp';
file_put_contents($tmp, json_encode($data));
rename($tmp, $f);
}
// ── Shared utility functions (used across include/ and api/ files) ────────────
// Format seconds into "2d 3h 15m".
function vv_format_uptime(int $seconds): string {
$d = intdiv($seconds, 86400);
$h = intdiv($seconds % 86400, 3600);
$m = intdiv($seconds % 3600, 60);
return ($d ? "{$d}d " : '') . ($h ? "{$h}h " : '') . "{$m}m";
}
// Parse a scalar value from raw conf text. Matches KEY="value" or KEY=value.
// Identical logic was previously duplicated as vv_arr_scalar / vv_wd_scalar /
// vv_fb_scalar / vv_media_conf_scalar — all reduce to this one regex.
function vv_parse_conf_scalar(string $raw, string $key): string {
return preg_match('/^\s*' . preg_quote($key, '/') . '\s*=\s*"?([^"\n]*)"?/m', $raw, $m)
? trim($m[1]) : '';
}
// Parse a key=value state file (e.g. fallback_state.db, partnership_state.db).
// Returns ['key' => 'value', ...]. Lines without '=' are ignored.
function vv_parse_kv_db(string $text): array {
$out = [];
foreach (explode("\n", $text) as $line) {
$line = trim($line);
if ($line === '' || $line[0] === '#') continue;
[$k, $v] = array_pad(explode('=', $line, 2), 2, '');
if ($k !== '') $out[trim($k)] = trim($v);
}
return $out;
}
// All configured hosts from master.conf as ['host1' => 'hostname', ...].
// Canonical version — previously duplicated as vv_arr_known_hosts / vv_fb_known_hosts.
function vv_known_hosts(): array {
$vars = vv_conf_vars();
$hosts = [];
foreach ($vars as $k => $v) {
if (preg_match('/^HOST(\d+)$/', $k, $m) && $v !== '') {
$hosts['host' . $m[1]] = $v;
}
}
ksort($hosts);
return $hosts ?: ['host1' => 'HOST1'];
}
// Create (or overwrite) the Varaverk Unraid API key and write it into host conf.
// Returns ['ok'=>true,'key_preview'=>'...'] or ['ok'=>false,'error'=>'...'].
function vv_auto_create_api_key(string $hostId, string $confFile): array {
$varName = strtoupper($hostId) . '_UNRAID_API_KEY';
$output = shell_exec('timeout 10 /usr/local/sbin/unraid-api apikey --name "Varaverk" --create --overwrite --description "Varaverk plugin" --roles ADMIN --json </dev/null 2>&1');
if (!$output) {
return ['ok' => false, 'error' => 'unraid-api returned no output'];
}
$data = json_decode(trim($output), true);
$key = $data['key'] ?? null;
if (!$key) {
return ['ok' => false, 'error' => 'No key in response'];
}
$raw = vv_read_conf_raw($confFile);
if ($raw === '') {
return ['ok' => false, 'error' => 'Cannot read ' . $confFile];
}
if (!str_contains($raw, $varName)) {
foreach ([strtoupper($hostId) . '_OWNER_EMAIL', strtoupper($hostId) . '_SSH_KEY'] as $anchor) {
if (str_contains($raw, $anchor)) {
$raw = preg_replace('/^(\s*' . preg_quote($anchor, '/') . '\s*=.*$)/m',
'$1' . "\n " . $varName . '=""', $raw, 1);
break;
}
}
}
$raw = preg_replace('/^(\s*' . preg_quote($varName, '/') . '\s*=\s*)"[^"]*"/m',
'${1}"' . $key . '"', $raw);
vv_write_conf_raw($confFile, $raw);
return ['ok' => true, 'key_preview' => substr($key, 0, 8) . '...' . substr($key, -4)];
}
// Local LAN IP via routing table — static-cached per request.
// Previously duplicated in include/docker_folders.php and inline in include/docker.php.
function vv_local_ip(): string {
static $ip = null;
if ($ip !== null) return $ip;
$ip = trim(shell_exec("ip route get 8.8.8.8 2>/dev/null | awk '/src/{for(i=1;i<=NF;i++)if(\$i==\"src\")print \$(i+1)}'") ?? '');
return $ip;
}
@@ -0,0 +1,401 @@
<?php
// Config file parser and writer.
// Reads master.conf and the appropriate host*.conf based on running host.
define('PLUGIN_CFG', '/boot/config/plugins/varaverk/varaverk.cfg');
$_vv_cfg = @parse_ini_file(PLUGIN_CFG) ?: [];
define('SCRIPTS_DIR', $_vv_cfg['SCRIPTS_DIR'] ?? '/boot/config/plugins/varaverk');
define('CONF_DIR', SCRIPTS_DIR . '/Configurations');
define('DATA_DIR', SCRIPTS_DIR . '/data');
define('STATE_DIR', SCRIPTS_DIR . '/State_Files');
define('LOG_DIR', '/var/log/varaverk');
unset($_vv_cfg);
define('VV_SETUP_STATE_FILE', STATE_DIR . '/varaverk_setup.db');
define('VV_CACHE_DIR', '/tmp/vv_cache');
// Read the setup state file into a key=>value array.
function vv_setup_state_read(): array {
$out = [];
foreach (file(VV_SETUP_STATE_FILE) ?: [] as $line) {
[$k, $v] = explode('=', trim($line), 2) + ['', ''];
if ($k !== '') $out[$k] = $v;
}
return $out;
}
// Write the setup state file (creates or overwrites).
function vv_setup_state_write(array $data): void {
$content = '';
foreach ($data as $k => $v) $content .= "$k=$v\n";
file_put_contents(VV_SETUP_STATE_FILE, $content);
}
// Push the setup state file to all remote hosts via scp.
// Unlike master.conf push, this does NOT require the plugin to be installed on the remote —
// it only needs SSH to be reachable, and pushes to /boot/config/ (always available).
function vv_push_setup_state(): void {
if (!file_exists(VV_SETUP_STATE_FILE)) return;
$myHostId = vv_detect_host();
$vars = vv_conf_vars();
$sshKey = $vars[strtoupper($myHostId) . '_SSH_KEY'] ?? '';
if (!$sshKey || !file_exists($sshKey)) return;
$master = vv_read_conf_raw('master.conf');
preg_match_all('/^\s*(HOST\d+)(?:_NAME)?\s*=\s*["\']?(\S+?)["\']?\s*$/m', $master, $m);
$seen = [];
foreach ($m[1] as $i => $hostKey) {
$hostId = strtolower($hostKey);
if ($hostId === $myHostId || isset($seen[$hostId])) continue;
$seen[$hostId] = true;
$hostname = trim($m[2][$i]);
if (!$hostname) continue;
$ip = vv_resolve_tailscale_ip($hostname);
if (!$ip) continue;
$sshBase = 'ssh -i ' . escapeshellarg($sshKey)
. ' -o ConnectTimeout=10 -o StrictHostKeyChecking=no root@' . $ip;
// Ensure the target dir exists (it always should on Unraid, but be safe)
shell_exec($sshBase . ' "mkdir -p /boot/config" 2>/dev/null');
$dest = escapeshellarg('root@' . $ip . ':/boot/config/varaverk_setup.db');
exec('scp -i ' . escapeshellarg($sshKey)
. ' -o ConnectTimeout=10 -o StrictHostKeyChecking=no'
. ' ' . escapeshellarg(VV_SETUP_STATE_FILE) . ' ' . $dest . ' 2>&1');
}
}
// Push master.conf to all remote hosts via scp after a local save.
// Returns one result entry per remote found in master.conf.
// Silently returns [] on non-owner hosts (no SSH key, no remote access).
function vv_push_master_conf(): array {
$myHostId = vv_detect_host();
$vars = vv_conf_vars();
$sshKey = $vars[strtoupper($myHostId) . '_SSH_KEY'] ?? '';
if (!$sshKey || !file_exists($sshKey)) return [];
$localPath = CONF_DIR . '/master.conf';
$master = vv_read_conf_raw('master.conf');
preg_match_all('/^\s*(HOST\d+)(?:_NAME)?\s*=\s*["\']?(\S+?)["\']?\s*$/m', $master, $m);
$results = [];
$seen = [];
foreach ($m[1] as $i => $hostKey) {
$hostId = strtolower($hostKey);
if ($hostId === $myHostId || isset($seen[$hostId])) continue;
$seen[$hostId] = true;
$hostname = trim($m[2][$i]);
$ip = vv_resolve_tailscale_ip($hostname);
if (!$ip) {
$results[] = ['host' => $hostKey, 'ok' => false, 'ready' => false, 'error' => 'Tailscale IP not found'];
continue;
}
// Single SSH call: get remote SCRIPTS_DIR and verify plugin is installed,
// Configurations/ exists, and master.conf is already present.
// Any missing piece means the remote isn't ready — skip rather than push blind.
$sshBase = 'ssh -i ' . escapeshellarg($sshKey)
. ' -o ConnectTimeout=10 -o StrictHostKeyChecking=no root@' . $ip;
$probe = trim(shell_exec(
$sshBase . ' "cfg=$(grep SCRIPTS_DIR /boot/config/plugins/varaverk/varaverk.cfg 2>/dev/null)'
. ' && sd=$(echo \"$cfg\" | grep -oP \'(?<=SCRIPTS_DIR=\")[^\"]+\')'
. ' && test -d \"${sd}/Configurations\"'
. ' && test -f \"${sd}/Configurations/master.conf\"'
. ' && echo \"$sd\""'
) ?: '');
if ($probe === '') {
$results[] = ['host' => $hostKey, 'ok' => false, 'ready' => false,
'error' => 'plugin not installed, dir missing, or master.conf absent — skipped'];
continue;
}
$remoteConf = rtrim($probe, '/') . '/Configurations';
$dest = escapeshellarg('root@' . $ip . ':' . $remoteConf . '/master.conf');
$cmd = 'scp -i ' . escapeshellarg($sshKey)
. ' -o ConnectTimeout=10 -o StrictHostKeyChecking=no'
. ' ' . escapeshellarg($localPath) . ' ' . $dest . ' 2>&1';
exec($cmd, $out, $rc);
$results[] = [
'host' => $hostKey,
'ok' => $rc === 0,
'error' => $rc !== 0 ? implode('; ', $out) : '',
];
}
return $results;
}
function vv_get_hostname(): string {
return trim(shell_exec('hostname -s') ?: '');
}
// Mirror of common.sh resolve_tailscale_ip(): tries `tailscale ip -4` first (Tailscale manages
// the mapping so this survives IP changes), falls back to parsing `tailscale status` text.
function vv_resolve_tailscale_ip(string $hostname): string {
$h = strtolower($hostname);
$ip = trim(shell_exec('tailscale ip -4 ' . escapeshellarg($h) . ' 2>/dev/null') ?: '');
if ($ip) return $ip;
$out = shell_exec('tailscale status 2>/dev/null') ?: '';
foreach (explode("\n", $out) as $line) {
$cols = preg_split('/\s+/', trim($line));
if (isset($cols[1]) && stripos($cols[1], $h . '.') === 0) return $cols[0];
}
return '';
}
function vv_detect_host(): string {
// Reads master.conf for HOST1="name" (or HOST1_NAME="name") and matches running hostname.
// Returns 'host1', 'host2', 'host3', ... or 'unknown'. Works for any number of hosts.
$master = vv_read_conf_raw('master.conf');
preg_match_all('/^\s*(HOST\d+)(?:_NAME)?\s*=\s*["\']?(\S+?)["\']?\s*$/m', $master, $m);
$hostname = vv_get_hostname();
foreach ($m[1] as $i => $key) {
if (strcasecmp($hostname, trim($m[2][$i])) === 0) return strtolower($key);
}
return 'unknown';
}
function vv_is_owner(): bool {
return vv_detect_host() === 'host1';
}
function vv_read_conf_raw(string $filename): string {
$path = CONF_DIR . '/' . $filename;
return file_exists($path) ? file_get_contents($path) : '';
}
function vv_write_conf_raw(string $filename, string $content): bool {
$path = CONF_DIR . '/' . $filename;
$tmp = $path . '.vv.tmp';
if (file_put_contents($tmp, $content) === false) return false;
return rename($tmp, $path);
}
function vv_get_conf_files(): array {
// Returns conf files this host is allowed to view/edit
$host = vv_detect_host();
$files = [];
if ($host === 'host1') {
// Owner sees master.conf + their own host conf
$files[] = 'master.conf';
$files[] = 'host1.conf';
} elseif (preg_match('/^host(\d+)$/', $host)) {
// Any other numbered host sees only their own conf
$files[] = $host . '.conf';
} else {
// Unknown host — show all for dev/debug
foreach (glob(CONF_DIR . '/*.conf') as $f) {
$files[] = basename($f);
}
}
return $files;
}
// Parse conf into key=>value map for $VAR substitution in docs
function vv_conf_vars(): array {
$vars = [];
$files = ['master.conf'];
$host = vv_detect_host();
if (preg_match('/^host\d+$/', $host)) $files[] = $host . '.conf';
foreach ($files as $f) {
$raw = vv_read_conf_raw($f);
// Match: VAR_NAME="value" or VAR_NAME=value (no quotes)
preg_match_all('/^\s*([A-Z0-9_]+)\s*=\s*["\']?([^"\'#\n]*?)["\']?\s*(?:#.*)?$/m', $raw, $m);
foreach ($m[1] as $i => $key) {
$vars[$key] = str_replace('\\$', '$', trim($m[2][$i]));
}
}
return $vars;
}
// Query the Unraid GraphQL API for a given host.
// For the local host queries http://localhost/graphql; for remote hosts uses the Tailscale IP.
// $apiKey may be passed explicitly (needed when querying a remote host from the local host,
// since vv_conf_vars() only loads the current host's conf file).
// Returns the decoded 'data' object on success, null on any failure.
// Debug log written to /tmp/vv_api_debug.json on failure.
function vv_unraid_api_query(string $hostId, string $gql, int $timeoutSec = 5, string $apiKey = ''): ?array {
$vars = vv_conf_vars();
$key = $apiKey ?: ($vars[strtoupper($hostId) . '_UNRAID_API_KEY'] ?? '');
if (!$key) return null;
$myHostId = vv_detect_host();
if (strtolower($hostId) === strtolower($myHostId)) {
$url = 'http://localhost/graphql';
} else {
$hostname = $vars[strtoupper($hostId)] ?? '';
if (!$hostname) return null;
$ip = vv_resolve_tailscale_ip($hostname);
if (!$ip) return null;
$url = "http://{$ip}/graphql";
}
$body = json_encode(['query' => $gql]);
// Use curl (preferred — doesn't require allow_url_fopen, better error handling).
if (function_exists('curl_init')) {
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => ['Content-Type: application/json', "x-api-key: {$key}"],
CURLOPT_POSTFIELDS => $body,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => $timeoutSec,
CURLOPT_CONNECTTIMEOUT => 3,
CURLOPT_FOLLOWLOCATION => false,
]);
$resp = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlErr = curl_error($ch);
curl_close($ch);
} else {
// Fallback to file_get_contents if curl is unavailable.
$ctx = stream_context_create(['http' => [
'method' => 'POST',
'header' => "Content-Type: application/json\r\nx-api-key: {$key}",
'content' => $body,
'timeout' => $timeoutSec,
'ignore_errors' => true,
]]);
$resp = @file_get_contents($url, false, $ctx);
$httpCode = $resp !== false ? 200 : 0;
$curlErr = '';
}
if ($resp === false || $resp === '' || ($httpCode !== 0 && $httpCode !== 200)) {
@file_put_contents(VV_CACHE_DIR . '/vv_api_debug.json', json_encode([
'ts' => time(),
'host' => $hostId,
'url' => $url,
'http_code' => $httpCode,
'curl_err' => $curlErr,
'response' => substr((string)$resp, 0, 800),
], JSON_PRETTY_PRINT));
return null;
}
$decoded = json_decode((string)$resp, true);
// If the API returned GraphQL errors, log them for diagnosis.
if (!empty($decoded['errors'])) {
@file_put_contents(VV_CACHE_DIR . '/vv_api_debug.json', json_encode([
'ts' => time(),
'host' => $hostId,
'url' => $url,
'http_code' => $httpCode,
'errors' => $decoded['errors'],
'data' => $decoded['data'] ?? null,
], JSON_PRETTY_PRINT));
}
// data key present (even if null means query ran but returned nothing useful).
return array_key_exists('data', $decoded ?? []) ? $decoded['data'] : null;
}
// ── File-based API cache (/tmp/vv_cache — tmpfs, cleared on reboot) ───────────
// Read a cached payload. Returns null if missing or older than $maxAge seconds.
function vv_cache_read(string $key, int $maxAge = 90): ?array {
$f = VV_CACHE_DIR . '/' . $key . '.json';
if (!file_exists($f) || (time() - filemtime($f)) > $maxAge) return null;
$raw = file_get_contents($f);
return $raw ? (json_decode($raw, true) ?: null) : null;
}
// Write a payload atomically (tmp + rename) so readers never see a partial file.
function vv_cache_write(string $key, array $data): void {
if (!is_dir(VV_CACHE_DIR)) @mkdir(VV_CACHE_DIR, 0755, true);
$f = VV_CACHE_DIR . '/' . $key . '.json';
$tmp = $f . '.tmp';
file_put_contents($tmp, json_encode($data));
rename($tmp, $f);
}
// ── Shared utility functions (used across include/ and api/ files) ────────────
// Format seconds into "2d 3h 15m".
function vv_format_uptime(int $seconds): string {
$d = intdiv($seconds, 86400);
$h = intdiv($seconds % 86400, 3600);
$m = intdiv($seconds % 3600, 60);
return ($d ? "{$d}d " : '') . ($h ? "{$h}h " : '') . "{$m}m";
}
// Parse a scalar value from raw conf text. Matches KEY="value" or KEY=value.
// Identical logic was previously duplicated as vv_arr_scalar / vv_wd_scalar /
// vv_fb_scalar / vv_media_conf_scalar — all reduce to this one regex.
function vv_parse_conf_scalar(string $raw, string $key): string {
return preg_match('/^\s*' . preg_quote($key, '/') . '\s*=\s*"?([^"\n]*)"?/m', $raw, $m)
? trim($m[1]) : '';
}
// Parse a key=value state file (e.g. fallback_state.db, partnership_state.db).
// Returns ['key' => 'value', ...]. Lines without '=' are ignored.
function vv_parse_kv_db(string $text): array {
$out = [];
foreach (explode("\n", $text) as $line) {
$line = trim($line);
if ($line === '' || $line[0] === '#') continue;
[$k, $v] = array_pad(explode('=', $line, 2), 2, '');
if ($k !== '') $out[trim($k)] = trim($v);
}
return $out;
}
// All configured hosts from master.conf as ['host1' => 'hostname', ...].
// Canonical version — previously duplicated as vv_arr_known_hosts / vv_fb_known_hosts.
function vv_known_hosts(): array {
$vars = vv_conf_vars();
$hosts = [];
foreach ($vars as $k => $v) {
if (preg_match('/^HOST(\d+)$/', $k, $m) && $v !== '') {
$hosts['host' . $m[1]] = $v;
}
}
ksort($hosts);
return $hosts ?: ['host1' => 'HOST1'];
}
// Create (or overwrite) the Varaverk Unraid API key and write it into host conf.
// Returns ['ok'=>true,'key_preview'=>'...'] or ['ok'=>false,'error'=>'...'].
function vv_auto_create_api_key(string $hostId, string $confFile): array {
$varName = strtoupper($hostId) . '_UNRAID_API_KEY';
$hostname = trim((string)shell_exec("hostname -s 2>/dev/null | sed 's/^[Uu][Nn][Rr][Aa][Ii][Dd]-//'")) ?: 'Varaverk';
$keyName = 'Varaverk ' . $hostname;
$output = shell_exec('timeout 10 /usr/local/sbin/unraid-api apikey --name ' . escapeshellarg($keyName) . ' --create --overwrite --description "Varaverk plugin" --roles ADMIN --json </dev/null 2>&1');
if (!$output) {
return ['ok' => false, 'error' => 'unraid-api returned no output'];
}
$data = json_decode(trim($output), true);
$key = $data['key'] ?? null;
if (!$key) {
return ['ok' => false, 'error' => 'No key in response'];
}
$raw = vv_read_conf_raw($confFile);
if ($raw === '') {
return ['ok' => false, 'error' => 'Cannot read ' . $confFile];
}
if (!str_contains($raw, $varName)) {
foreach ([strtoupper($hostId) . '_OWNER_EMAIL', strtoupper($hostId) . '_SSH_KEY'] as $anchor) {
if (str_contains($raw, $anchor)) {
$raw = preg_replace('/^(\s*' . preg_quote($anchor, '/') . '\s*=.*$)/m',
'$1' . "\n " . $varName . '=""', $raw, 1);
break;
}
}
}
$raw = preg_replace('/^(\s*' . preg_quote($varName, '/') . '\s*=\s*)"[^"]*"/m',
'${1}"' . $key . '"', $raw);
vv_write_conf_raw($confFile, $raw);
return ['ok' => true, 'key_preview' => substr($key, 0, 8) . '...' . substr($key, -4)];
}
// Local LAN IP via routing table — static-cached per request.
// Previously duplicated in include/docker_folders.php and inline in include/docker.php.
function vv_local_ip(): string {
static $ip = null;
if ($ip !== null) return $ip;
$ip = trim(shell_exec("ip route get 8.8.8.8 2>/dev/null | awk '/src/{for(i=1;i<=NF;i++)if(\$i==\"src\")print \$(i+1)}'") ?? '');
return $ip;
}