audit echo vs log across all scripts — outcomes always visible, verbose for per-item loops
This commit is contained in:
@@ -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>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 ? ` <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>
|
||||
+147
@@ -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
|
||||
+153
@@ -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();
|
||||
})();
|
||||
+1920
File diff suppressed because it is too large
Load Diff
+1921
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+153
@@ -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
|
||||
+166
@@ -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
|
||||
+219
@@ -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 "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
+226
@@ -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 "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
+1126
File diff suppressed because it is too large
Load Diff
+1128
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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user