Consolidate all paths to plugin flash dir, fix watchdog 7.3 triggers

- Move SCRIPTS_DIR/DATA_DIR/STATE_DIR from appdata to /boot/config/plugins/varaverk
- All state files now in STATE_DIR (no more /tmp or /boot/config root writes)
- Bootstrap: Gitea-first clone with GitHub fallback, no array dependency
- varaverk.cfg seeded with Gitea connection settings
- .gitignore: add State_Files/, varaverk.cfg, varaverk-*.txz
- Partnership/transcode/fallback scripts use STATE_DIR variables
- PHP config.php: DATA_DIR/STATE_DIR constants, VV_SETUP_STATE_FILE dynamic
- deploy.sh PROD_ROOT updated to plugin flash dir

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Gmer4Lfe
2026-05-31 13:30:20 -04:00
co-authored by Claude Sonnet 4.6
parent 9191a54637
commit fb0530deba
40 changed files with 1609 additions and 262 deletions
+55 -9
View File
@@ -467,7 +467,10 @@ function vvRenderScripts() {
const color = s.status === 'running' ? '#4fc3f7' : s.status === 'ok' ? '#4caf50' : s.status === 'warn' ? '#ff9800' : s.status === 'error' ? '#f44336' : '#555';
const name = s.name.length > 24 ? s.name.slice(0, 23) + '…' : s.name;
const dur = s.duration != null ? ` ${s.duration}s` : '';
listHtml += `<div style="display:flex;align-items:center;gap:5px;margin-bottom:4px;font-size:11px;">
const sid = (s.id || '').replace(/\\/g,'\\\\').replace(/'/g,"\\'");
listHtml += `<div onclick="vvOpenScript('${sid}')" title="Open ${name} in Scheduler"
style="display:flex;align-items:center;gap:5px;margin-bottom:4px;font-size:11px;cursor:pointer;"
onmouseover="this.style.background='#161616'" onmouseout="this.style.background=''">
<span style="color:${color};flex-shrink:0;width:10px;text-align:center;">${icon}</span>
<span style="color:#888;flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${name}</span>
<span style="color:#444;font-size:10px;flex-shrink:0;">${ago}${dur}</span>
@@ -485,6 +488,12 @@ function vvScriptsFilterSet(type) {
vvRenderScripts();
}
// Deep-link a script row to the Scheduler tab (opens its settings/log panel there).
function vvOpenScript(id) {
if (!id) { window.location.href = '?tab=scheduler'; return; }
window.location.href = '?tab=scheduler&vv_script=' + encodeURIComponent(id);
}
// ── Disk / storage helpers (module-level so vvRenderPools can call them) ─────
function vvTempColor(tempC, transport) {
@@ -575,6 +584,16 @@ function vvPollMonitor() {
const _runningVMs = (d.vms?.vms ?? []).filter(v => v.state === 'running').length;
const _threadInfo = sys.cpu_threads ? `${sys.cpu_cores}c / ${sys.cpu_threads}t` : '';
// Load average (1/5/15m) — colour by load[0] vs core count
const _load = Array.isArray(sys.load_avg) ? sys.load_avg : null;
const _cores = sys.cpu_cores || 0;
const _loadColor = _load && _cores
? (_load[0] > _cores * 2 ? '#f44336' : _load[0] > _cores ? '#ff9800' : '#888') : '#888';
const _loadStr = _load
? `${_load[0].toFixed(2)} <span style="color:#444;">·</span> ${_load[1].toFixed(2)} <span style="color:#444;">·</span> ${_load[2].toFixed(2)}`
: '—';
const _coreMeta = _threadInfo ? ` <span style="color:#444;">(${_threadInfo})</span>` : '';
document.getElementById('vv-system-body').innerHTML =
`<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:8px;">
<div style="min-width:0;">
@@ -608,9 +627,10 @@ function vvPollMonitor() {
<div style="font-size:20px;font-weight:300;color:#ccc;line-height:1;">${timeStr}</div>
<div style="font-size:10px;color:#555;margin-bottom:10px;">${dateStr} &middot; ${tz}</div>
<div style="display:grid;grid-template-columns:auto 1fr;gap:3px 8px;font-size:11px;">
<span style="color:#444;">Model</span> <span style="color:#888;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${sys.cpu_model}${_threadInfo ? ` <span style="color:#444;">(${_threadInfo})</span>` : ''}</span>
<span style="color:#444;">Model</span> <span style="color:#888;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${sys.cpu_model}${_coreMeta}</span>
<span style="color:#444;">Array</span> <span style="color:${arrayColor};font-weight:600;">${sys.array_state}</span>
<span style="color:#444;">Uptime</span> <span style="color:#888;">${sys.uptime}</span>
<span style="color:#444;">Load</span> <span style="color:${_loadColor};">${_loadStr}</span>
<span style="color:#444;">Running</span> <span style="color:#888;">${_runningCtrs} ctr${_runningCtrs !== 1 ? 's' : ''}${_runningVMs > 0 ? ` · ${_runningVMs} VM` : ''}</span>
<span style="color:#444;">Version</span> <span style="color:#3a3a3a;">${ver}</span>
</div>`;
@@ -856,6 +876,8 @@ function vvPollMonitor() {
const maxSeen = Math.max(...vvNetRxHistory, ...vvNetTxHistory, 1);
const maxBps = maxSeen * 1.25; // auto-scale with 25% headroom
const peakRx = Math.max(...vvNetRxHistory, 0); // window peak (last 2 min)
const peakTx = Math.max(...vvNetTxHistory, 0);
const ipRows = [
net.local_ip ? `<div><span style="color:#555;font-size:9px;">LAN&nbsp;&nbsp;</span>${net.local_ip}</div>` : '',
@@ -868,8 +890,8 @@ function vvPollMonitor() {
<div>
<div style="font-size:12px;color:#888;margin-bottom:4px;">${net.iface} &nbsp;·&nbsp; ${linkLabel}</div>
<div style="display:flex;gap:16px;font-size:13px;font-weight:600;">
<span><span style="color:#4caf50;font-size:9px;margin-right:4px;">━ IN (RX)</span><span style="color:#4caf50;">${vvFmtBps(rx)}</span></span>
<span><span style="color:#ff9800;font-size:9px;margin-right:4px;">━ OUT (TX)</span><span style="color:#ff9800;">${vvFmtBps(tx)}</span></span>
<span><span style="color:#4caf50;font-size:9px;margin-right:4px;">━ IN (RX)</span><span style="color:#4caf50;">${vvFmtBps(rx)}</span><span style="color:#444;font-size:9px;font-weight:400;margin-left:4px;">peak ${vvFmtBps(peakRx)}</span></span>
<span><span style="color:#ff9800;font-size:9px;margin-right:4px;">━ OUT (TX)</span><span style="color:#ff9800;">${vvFmtBps(tx)}</span><span style="color:#444;font-size:9px;font-weight:400;margin-left:4px;">peak ${vvFmtBps(peakTx)}</span></span>
</div>
</div>
<div style="text-align:right;font-size:11px;color:#aaa;line-height:1.6;">${ipRows}</div>
@@ -881,9 +903,15 @@ function vvPollMonitor() {
document.getElementById('vv-network-body').innerHTML = '<p style="color:#555;font-style:italic">No network interface detected</p>';
}
// ── CPU title ────────────────────────────────────────────────────────────
// ── CPU title (core count + temp to its right) ────────────────────────────
const _cpuTitleEl = document.getElementById('vv-cpu-title');
if (_cpuTitleEl && sys.cpu_threads) _cpuTitleEl.textContent = `CPU · ${sys.cpu_cores}c/${sys.cpu_threads}t`;
if (_cpuTitleEl) {
const _cpuTemp = d.watchdog?.stability?.cpu_temp ?? null;
const _tColor = _cpuTemp == null ? '#888' : _cpuTemp >= 88 ? '#f44336' : _cpuTemp >= 75 ? '#ff9800' : '#4caf50';
const _coreLbl = sys.cpu_threads ? `CPU · ${sys.cpu_cores}c/${sys.cpu_threads}t` : 'CPU';
_cpuTitleEl.innerHTML = _coreLbl
+ (_cpuTemp != null ? ` <span style="color:${_tColor};font-weight:400;">${_cpuTemp}°</span>` : '');
}
// ── UPS / Power ─────────────────────────────────────────────────────────
const ups = d.ups ?? {};
@@ -1072,13 +1100,15 @@ function vvPollMonitor() {
const sshdOk = stab.sshd_ok ?? true;
const zombies = stab.zombies ?? 0;
let statsHtml = `<div style="display:grid;grid-template-columns:auto 1fr;gap:2px 8px;font-size:11px;margin-top:8px;margin-bottom:6px;">
const cpuRow = stab.cpu_temp != null
? `<span style="color:#444;">CPU</span><span style="color:${wdPct(stab.cpu_temp,75,90)};">${stab.cpu_temp}°C</span>` : '';
let statsHtml = `<div style="display:grid;grid-template-columns:auto 1fr auto 1fr;gap:2px 8px;font-size:11px;margin-top:8px;margin-bottom:6px;">
<span style="color:#444;">rootfs</span><span style="color:${wdPct(stab.rootfs_pct??0,75,90)};">${stab.rootfs_pct??0}%</span>
<span style="color:#444;">/var/log</span><span style="color:${wdPct(stab.log_pct??0,75,90)};">${stab.log_pct??0}%</span>
<span style="color:#444;">/tmp</span><span style="color:${wdPct(stab.tmp_pct??0,75,90)};">${stab.tmp_pct??0}%</span>
<span style="color:#444;">RAM free</span><span style="color:${ramColor};">${ramFree}GB</span>
<span style="color:#444;">Load</span><span style="color:${loadColor};">${load}</span>
${stab.cpu_temp != null ? `<span style="color:#444;">CPU</span><span style="color:${wdPct(stab.cpu_temp,75,90)};">${stab.cpu_temp}°C</span>` : ''}
${cpuRow}
<span style="color:#444;">Zombies</span><span style="color:${zombies>0?'#ff9800':'#4caf50'};">${zombies}</span>
<span style="color:#444;">${stab.nic??'nic'}</span><span style="color:${nicOk?'#4caf50':'#f44336'};">● ${stab.nic_state??'?'}</span>
<span style="color:#444;">sshd</span><span style="color:${sshdOk?'#4caf50':'#f44336'};">${sshdOk?'● ok':'✗ down'}</span>
@@ -1208,6 +1238,22 @@ function vvPollMonitor() {
const procCount = gpuProcs.length;
const procColor = procCount > 0 ? '#4caf50' : '#555';
// Process list — which apps are actually using the GPU (name + VRAM)
let gpuProcHtml = '';
if (procCount > 0) {
const rows = gpuProcs.map(p => {
const pname = (p.name || '').split('/').pop() || p.name || 'proc';
return `<div style="display:flex;justify-content:space-between;font-size:10px;margin-bottom:2px;">
<span style="color:#888;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;">${pname}</span>
<span style="color:#555;flex-shrink:0;margin-left:6px;">${p.memory_mb} MB</span>
</div>`;
}).join('');
gpuProcHtml = `<div style="margin-top:6px;border-top:1px solid #2a2a2a;padding-top:6px;">
<div style="font-size:10px;color:#555;margin-bottom:3px;">GPU processes</div>
<div style="max-height:60px;overflow-y:auto;">${rows}</div>
</div>`;
}
document.getElementById('vv-gpu-body').innerHTML =
// header row: name + process count pill
`<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;">
@@ -1224,7 +1270,7 @@ function vvPollMonitor() {
<span style="color:${tempColor};font-weight:bold;">${temp}°C</span>
<span style="color:#666;margin-left:12px;">Power</span>
<span style="color:#aaa;font-weight:bold;">${powerStr}</span>
</div>`;
</div>` + gpuProcHtml;
} else {
document.getElementById('vv-gpu-body').innerHTML = '<p style="color:#555;font-style:italic">No GPU detected</p>';
}
+405 -6
View File
@@ -32,6 +32,28 @@
0%,100% { box-shadow: 0 0 0 2px #4a8, 0 0 8px rgba(100,200,120,.2); }
50% { box-shadow: 0 0 0 4px #4a8, 0 0 18px rgba(100,200,120,.45); }
}
/* ── Settings panel ─────────────────────────────────────────── */
.vv-set-file { border:1px solid #222; border-radius:5px; margin-bottom:8px; overflow:hidden; }
.vv-set-file-hdr { display:flex; justify-content:space-between; align-items:center; padding:8px 12px;
background:#161616; cursor:pointer; font-size:12px; font-weight:bold; color:#bbb; font-family:monospace; }
.vv-set-file-hdr:hover { background:#1a1a1a; }
.vv-set-file-body { padding:6px 10px 10px; }
.vv-set-sec { border-top:1px solid #1c1c1c; }
.vv-set-sec:first-child { border-top:none; }
.vv-set-sec-hdr { display:flex; justify-content:space-between; align-items:center; padding:6px 4px;
cursor:pointer; font-size:11px; color:#888; text-transform:uppercase; letter-spacing:.04em; }
.vv-set-sec-hdr:hover { color:#ccc; }
.vv-set-sec-body { padding:4px 0 8px 8px; }
.vv-set-chev { color:#444; font-size:10px; }
.vv-set-field { margin-bottom:10px; }
.vv-set-key { font-size:11px; color:#7ab; font-family:monospace; margin-bottom:2px; }
.vv-set-desc { font-size:10px; color:#555; margin-bottom:3px; line-height:1.4; }
.vv-set-input { width:100%; box-sizing:border-box; background:#0d0d0d; border:1px solid #2a2a2a;
color:#ddd; padding:5px 8px; border-radius:3px; font-family:monospace; font-size:11px; }
.vv-set-input:focus { outline:none; border-color:#4a8; }
.vv-set-input.changed { border-color:#ff9800; }
textarea.vv-set-input { resize:vertical; white-space:pre; }
</style>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;padding:0 2px;">
@@ -44,17 +66,46 @@
<div id="vv-pt-config-body" style="color:#444;font-size:12px;">Loading…</div>
</div>
<!-- Offline countdown warning (shown only when partner has been unreachable) -->
<div id="vv-pt-offline-warn" style="display:none;margin-bottom:12px;"></div>
<!-- Node grid -->
<div id="vv-pt-nodes" class="vv-pt-grid" style="margin-bottom:12px;">
<div style="color:#444;font-size:12px;padding:16px 0;text-align:center;">Loading…</div>
</div>
<!-- Mirror sync health -->
<div class="vv-card" id="vv-pt-sync-card" style="margin-bottom:12px;">
<h3>Mirror Sync</h3>
<div id="vv-pt-sync-body" style="color:#444;font-size:12px;">Loading…</div>
</div>
<!-- Actions -->
<div class="vv-card" id="vv-pt-actions-card">
<h3>Actions</h3>
<div id="vv-pt-actions-body" style="color:#444;font-size:12px;">Loading…</div>
</div>
<!-- Settings -->
<div class="vv-card" id="vv-pt-settings-card" style="margin-top:12px;">
<div style="display:flex;justify-content:space-between;align-items:center;cursor:pointer;"
onclick="vvPtToggleSettings()">
<h3 style="margin:0;">Settings</h3>
<div style="display:flex;align-items:center;gap:10px;">
<button id="vv-pt-settings-save" class="vv-pt-action-btn run"
onclick="event.stopPropagation();vvPtSaveSettings(this)"
style="display:none;">Save Changes</button>
<span id="vv-pt-settings-toggle"
style="font-size:11px;color:#7ab;background:#1a2a3a;border:1px solid #2e4a6b;
border-radius:4px;padding:3px 10px;white-space:nowrap;"> Show settings</span>
</div>
</div>
<div id="vv-pt-settings-hint" style="font-size:10px;color:#444;margin-top:4px;">
Partnership configuration for this server.
</div>
<div id="vv-pt-settings-body" style="display:none;margin-top:10px;"></div>
</div>
<script>
// ── Toggle states — persist across 10s poll re-renders ────────────────────────
const _vvOnboarding = {}; // hostId → true when onboard steps are expanded
@@ -96,6 +147,33 @@ function vvPtHideDeleteKeys(hostId) {
if (_vvPtReload) _vvPtReload();
}
function vvApiKey(btn) {
const origText = btn.textContent;
btn.disabled = true;
btn.textContent = '⟳ Working…';
fetch('/plugins/varaverk/api/create_api_key.php?_=' + Date.now())
.then(r => {
if (!r.ok || r.status === 0) throw new Error('HTTP ' + r.status + ' ' + r.statusText);
return r.text();
})
.then(text => {
if (!text.trim()) throw new Error('Empty response from server');
return JSON.parse(text);
})
.then(d => {
if (d.ok) {
if (_vvPtReload) _vvPtReload();
} else {
alert('Failed: ' + (d.error ?? 'Unknown error'));
btn.disabled = false; btn.textContent = origText;
}
})
.catch(e => {
alert('Error: ' + e);
btn.disabled = false; btn.textContent = origText;
});
}
function vvPtPhase2(btn, hostId) {
if (!confirm(`Phase 2: Deploy containers + arr stack + establish partnership on ${hostId}?\n\nRequires ${hostId} to have Varaverk installed and SSH keys set up.`)) return;
btn.disabled = true;
@@ -175,6 +253,176 @@ function vvPtOffboard(btn) {
.finally(() => setTimeout(() => { btn.disabled = false; btn.textContent = '▶ Offboard'; }, 4000));
}
function vvPtTransfer(btn, token) {
if (!confirm('Transfer ownership to the mirror?\n\nThis promotes the mirror to OWNER and demotes this server. '
+ 'partnership_transfer.sh requires sustained health checks before the switch completes.\n\nContinue?')) return;
if (prompt('Type YES to confirm ownership transfer:') !== 'YES') return;
btn.disabled = true;
btn.textContent = '⟳ Transferring…';
_vvPtRun('Partnership/partnership_transfer.sh', '--confirm=' + token)
.then(d => { if (!d.ok) alert('Failed: ' + (d.error ?? 'Unknown error')); })
.catch(e => alert('Error: ' + e))
.finally(() => setTimeout(() => { btn.disabled = false; btn.textContent = '⇄ Transfer Ownership'; }, 4000));
}
function vvPtPing(btn, slot, idUpper) {
const out = document.getElementById('vv-pt-ping-' + idUpper);
btn.disabled = true;
btn.textContent = '⟳';
if (out) { out.textContent = ''; out.style.color = '#444'; }
fetch('/plugins/varaverk/api/partnership_ping.php?id=' + encodeURIComponent(slot) + '&_=' + Date.now())
.then(r => r.json())
.then(d => {
if (out) {
if (d.ok) { out.textContent = '✓ ' + d.latency_ms + 'ms'; out.style.color = '#4caf50'; }
else { out.textContent = '✗ ' + (d.error || 'failed'); out.style.color = '#f44336'; }
}
})
.catch(e => { if (out) { out.textContent = '✗ ' + e; out.style.color = '#f44336'; } })
.finally(() => { btn.disabled = false; btn.textContent = '⇄ Test'; });
}
function vvPtToggleSync(el, varName, enabled) {
el.style.pointerEvents = 'none';
el.style.opacity = '0.5';
const params = new URLSearchParams({
csrf_token: typeof csrf_token !== 'undefined' ? csrf_token : '',
name: varName,
enabled: String(enabled)
});
fetch('/plugins/varaverk/api/flag_toggle.php', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: params
})
.then(r => r.json())
.then(d => {
if (d.ok) { if (_vvPtReload) _vvPtReload(); }
else { alert('Failed: ' + (d.error ?? 'Unknown error')); el.style.pointerEvents = ''; el.style.opacity = ''; }
})
.catch(e => { alert('Error: ' + e); el.style.pointerEvents = ''; el.style.opacity = ''; });
}
// ── Settings panel ─────────────────────────────────────────────────────────────
let _vvSetLoaded = false;
function _vvSetEsc(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function vvPtToggleSettings() {
const body = document.getElementById('vv-pt-settings-body');
const toggle = document.getElementById('vv-pt-settings-toggle');
const open = body.style.display !== 'none';
body.style.display = open ? 'none' : '';
toggle.textContent = open ? '▸ Show settings' : '▾ Hide settings';
if (!open && !_vvSetLoaded) vvPtLoadSettings();
}
function vvPtLoadSettings() {
const body = document.getElementById('vv-pt-settings-body');
body.innerHTML = '<div style="color:#555;font-size:12px;padding:8px 0;">Loading…</div>';
fetch('/plugins/varaverk/api/partnership_settings.php?_=' + Date.now())
.then(r => r.json())
.then(d => {
if (!d.ok || !(d.files || []).length) {
body.innerHTML = '<div style="color:#555;font-size:12px;padding:8px 0;">No settings available.</div>';
return;
}
// Host-aware hint: master.conf edits propagate; host config stays local.
const hasMaster = d.files.some(f => f.file === 'master.conf');
document.getElementById('vv-pt-settings-hint').textContent = hasMaster
? 'Shared (master.conf) edits propagate to partners on save · host config stays local to this server.'
: 'These settings apply to this host only.';
body.innerHTML = vvPtRenderSettings(d.files);
_vvSetLoaded = true;
})
.catch(e => { body.innerHTML = '<div style="color:#f88;font-size:12px;padding:8px 0;">Error: ' + e + '</div>'; });
}
function vvPtRenderSettings(files) {
let html = '';
for (const f of files) {
// One block per file; partnership scope yields a single section per file, so flatten.
const label = f.file === 'master.conf' ? 'master.conf — shared' : f.file + ' — this host';
html += `<div class="vv-set-file">
<div class="vv-set-file-hdr" onclick="vvPtToggleNode(this)">
<span>${_vvSetEsc(label)}</span><span class="vv-set-chev">▾</span>
</div>
<div class="vv-set-file-body">`;
for (const g of f.groups) {
for (const fld of g.fields) {
const attrs = `class="vv-set-input" data-key="${_vvSetEsc(fld.key)}" `
+ `data-file="${_vvSetEsc(fld.file)}" data-type="${_vvSetEsc(fld.type)}" `
+ `data-orig="${_vvSetEsc(fld.value)}" oninput="vvPtSetChanged(this)"`;
html += `<div class="vv-set-field">
<div class="vv-set-key">${_vvSetEsc(fld.key)}</div>`;
if (fld.desc) html += `<div class="vv-set-desc">${_vvSetEsc(fld.desc)}</div>`;
if (fld.type === 'scalar') {
html += `<input type="text" ${attrs} value="${_vvSetEsc(fld.value)}">`;
} else {
const rows = Math.min(16, (fld.value.match(/\n/g) || []).length + 2);
html += `<textarea ${attrs} rows="${rows}">${_vvSetEsc(fld.value)}</textarea>`;
}
html += `</div>`;
}
}
html += `</div></div>`;
}
return html;
}
function vvPtToggleNode(hdr) {
const body = hdr.nextElementSibling;
const chev = hdr.querySelector('.vv-set-chev');
const open = body.style.display !== 'none';
body.style.display = open ? 'none' : '';
if (chev) chev.textContent = open ? '▸' : '▾';
}
function vvPtSetChanged(el) {
const changed = el.value !== el.dataset.orig;
el.classList.toggle('changed', changed);
// Show Save button if any field is changed
const any = document.querySelector('#vv-pt-settings-body .vv-set-input.changed');
document.getElementById('vv-pt-settings-save').style.display = any ? '' : 'none';
}
function vvPtSaveSettings(btn) {
const changedEls = document.querySelectorAll('#vv-pt-settings-body .vv-set-input.changed');
if (!changedEls.length) return;
const changes = [];
changedEls.forEach(el => changes.push({
key: el.dataset.key, file: el.dataset.file, type: el.dataset.type, value: el.value
}));
if (!confirm(`Save ${changes.length} changed setting(s)?`)) return;
btn.disabled = true; btn.textContent = '⟳ Saving…';
const params = new URLSearchParams({
csrf_token: typeof csrf_token !== 'undefined' ? csrf_token : '',
id: '__settings__',
changes: JSON.stringify(changes)
});
fetch('/plugins/varaverk/api/confform.php', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: params
})
.then(r => r.json())
.then(d => {
btn.disabled = false;
if (d.ok) {
// Commit new originals, clear highlights
changedEls.forEach(el => { el.dataset.orig = el.value; el.classList.remove('changed'); });
btn.textContent = '✓ Saved';
setTimeout(() => { btn.textContent = 'Save Changes'; btn.style.display = 'none'; }, 2000);
} else {
btn.textContent = 'Save Changes';
alert('Save failed: ' + (d.error ?? 'Unknown error'));
}
})
.catch(e => { btn.disabled = false; btn.textContent = 'Save Changes'; alert('Error: ' + e); });
}
// ── Private page logic ─────────────────────────────────────────────────────────
(function() {
@@ -202,6 +450,91 @@ function _row(lbl, val) {
return `<div class="vv-pt-row"><span class="vv-pt-lbl">${lbl}</span><span class="vv-pt-val">${val}</span></div>`;
}
// ── Offline countdown warning ───────────────────────────────────────────────────
function _renderOfflineWarn(cfg) {
const el = document.getElementById('vv-pt-offline-warn');
const days = cfg.offline_days;
const thr = cfg.offline_threshold || 30;
// Auto-offboard only runs for active partnerships — a stale counter while disabled is meaningless.
if (!cfg.enabled || !days || days < 1) { el.style.display = 'none'; el.innerHTML = ''; return; }
const left = Math.max(0, thr - days);
const crit = left <= 5;
const col = crit ? '#f44336' : '#ff9800';
const bg = crit ? '#2a1212' : '#1a1200';
const pct = Math.min(100, Math.round(days / thr * 100));
el.style.display = '';
el.innerHTML = `<div class="vv-card" style="background:${bg};border-color:${col};">
<div style="display:flex;justify-content:space-between;align-items:baseline;gap:10px;flex-wrap:wrap;">
<span style="font-size:12px;color:${col};font-weight:600;">⚠ Partner unreachable ${days} day${days===1?'':'s'}</span>
<span style="font-size:11px;color:#888;">Auto-offboard in ${left} day${left===1?'':'s'} (threshold ${thr}d)</span>
</div>
<div style="margin-top:6px;height:5px;background:#000;border-radius:3px;overflow:hidden;">
<div style="width:${pct}%;height:100%;background:${col};"></div>
</div>
</div>`;
}
// ── Mirror sync health ──────────────────────────────────────────────────────────
function _syncToggle(gate, dimmed) {
if (!gate) return '';
const on = gate.on;
const col = on ? '#4caf50' : '#f44336';
const bg = on ? '#0a1a0a' : '#2a1212';
const op = dimmed ? 'opacity:.4;' : '';
return `<span onclick="vvPtToggleSync(this,'${gate.var}',${on ? 0 : 1})"
style="cursor:pointer;font-size:9px;font-weight:600;padding:2px 9px;border-radius:10px;
background:${bg};color:${col};border:1px solid ${col};white-space:nowrap;margin-left:8px;${op}"
title="${gate.var} — click to ${on ? 'disable' : 'enable'}">${on ? 'ON' : 'OFF'}</span>`;
}
function _renderSync(sync) {
const jobs = sync.jobs || {};
const gates = sync.gates || {};
const sCol = s => s === 'ok' ? '#4caf50' : s === 'running' ? '#4a9eff'
: s === 'warn' ? '#ff9800' : s === 'error' ? '#f44336' : '#555';
const sLbl = s => s === 'ok' ? '✓ ok' : s === 'running' ? '⟳ running'
: s === 'warn' ? '⚠ warn' : s === 'error' ? '✗ error' : '— never';
const globalOff = gates.global && !gates.global.on;
// Master toggle row — controls all mirroring (RSYNC_ENABLED, Tier 1).
let html = '';
if (gates.global) {
html += `<div class="vv-pt-row" style="margin-bottom:6px;padding-bottom:6px;border-bottom:1px solid #1c1c1c;">
<span class="vv-pt-lbl" style="font-weight:600;color:#999;">All mirroring</span>
<span class="vv-pt-val">${_syncToggle(gates.global, false)}</span>
</div>`;
}
// Per-tier rows: status + last-run + per-tier toggle (dimmed when global is off).
const labels = {critical: 'Critical (30 min)', daily: 'Daily', weekly: 'Weekly'};
for (const key of ['critical', 'daily', 'weekly']) {
const j = jobs[key];
const status = j ? j.status : null;
const when = j && j.start
? (j.status === 'running' ? 'started ' + _relTime(j.start)
: _relTime(j.end || j.start))
: '—';
html += `<div class="vv-pt-row">
<span class="vv-pt-lbl">${labels[key]}</span>
<span class="vv-pt-val">
<span style="color:${sCol(status)};">${sLbl(status)}</span>
<span style="color:#444;font-size:10px;margin-left:6px;">${when}</span>
${_syncToggle(gates[key], globalOff)}
</span>
</div>`;
}
if (globalOff) {
html += `<div style="font-size:10px;color:#f44336;margin-top:6px;">⚠ All mirroring is off — per-tier switches have no effect until re-enabled.</div>`;
}
return html;
}
// ── Config bar ────────────────────────────────────────────────────────────────
function _renderConfig(cfg) {
@@ -286,9 +619,56 @@ function _nodeCard(node) {
node.key_ready ? `<div style="font-size:10px;color:#ff9800;margin-bottom:6px;padding:2px 6px;background:#1a1200;border:1px solid #3a2800;border-radius:3px;">🔑 Key generated · install on HOST2 then Push Conf</div>` :
`<div style="font-size:10px;color:#555;margin-bottom:6px;padding:2px 6px;background:#111;border:1px solid #222;border-radius:3px;">○ Not yet provisioned</div>`);
const apiKeyHtml = node.is_me
? (node.api_key_set
? `<div style="font-size:10px;color:#2a4a2a;margin-bottom:6px;padding:2px 6px;background:#0a1a0a;
border:1px solid #1a3a1a;border-radius:3px;display:flex;justify-content:space-between;align-items:center;">
<span style="color:#3a7a3a;">🔑 API key: <span style="color:#4caf50;">${node.api_key_preview}</span></span>
<button onclick="vvApiKey(this)"
style="font-size:9px;padding:1px 6px;background:#0d0d0d;color:#444;border:1px solid #222;
border-radius:2px;cursor:pointer;margin-left:6px;white-space:nowrap;">↻ renew</button>
</div>`
: `<div style="font-size:10px;color:#ff9800;margin-bottom:6px;padding:2px 6px;background:#1a1200;
border:1px solid #3a2800;border-radius:3px;display:flex;justify-content:space-between;align-items:center;">
<span>⚡ No API key — Monitor stats unavailable</span>
<button onclick="vvApiKey(this)"
style="font-size:9px;padding:1px 8px;background:#1a3a1a;color:#6fcf97;border:1px solid #2e6b2e;
border-radius:2px;cursor:pointer;margin-left:6px;white-space:nowrap;">+ Create</button>
</div>`)
: '';
// ── Metrics strip ────────────────────────────────────────────────────────────
const m = node.metrics || {};
function _ptCol(v, warn, crit) {
return v >= crit ? '#f44336' : v >= warn ? '#ff9800' : '#4caf50';
}
const metricPairs = [];
if (m.cpu_pct != null) metricPairs.push(['CPU', `<span style="color:${_ptCol(m.cpu_pct,70,90)};">${m.cpu_pct}%</span>`]);
if (m.ram_used_gb != null) {
const ramPct = m.ram_total_gb ? Math.round(m.ram_used_gb / m.ram_total_gb * 100) : 0;
metricPairs.push(['RAM', `<span style="color:${_ptCol(ramPct,75,90)};">${m.ram_used_gb}<span style="color:#444;font-size:9px;">/${m.ram_total_gb}GB</span></span>`]);
}
if (m.load_avg != null) metricPairs.push(['Load', `<span style="color:${m.load_avg>8?'#f44336':m.load_avg>4?'#ff9800':'#4caf50'};">${m.load_avg}</span>`]);
if (m.containers != null) metricPairs.push(['Conts', `<span style="color:#bbb;">${m.containers}</span>`]);
if (m.array_used_tb != null) metricPairs.push(['Array', `<span style="color:#bbb;">${m.array_used_tb}<span style="color:#444;font-size:9px;">/${m.array_total_tb}TB</span></span>`]);
if (m.max_disk_temp != null) metricPairs.push(['Temp', `<span style="color:${_ptCol(m.max_disk_temp,55,70)};">${m.max_disk_temp}°C</span>`]);
if (m.vm_count != null && m.vm_count > 0) metricPairs.push(['VMs', `<span style="color:#bbb;">${m.vm_count}</span>`]);
let metricsHtml = '';
if (metricPairs.length) {
let cells = '';
for (const [lbl, val] of metricPairs) {
cells += `<span style="color:#555;">${lbl}</span>${val}`;
}
if (metricPairs.length % 2 !== 0) cells += '<span></span><span></span>';
metricsHtml = `<div style="display:grid;grid-template-columns:auto 1fr auto 1fr;gap:2px 8px;font-size:10px;margin-bottom:6px;">${cells}</div>`;
}
let body = '';
body += phaseHtml;
body += apiKeyHtml;
body += metricsHtml;
// Network
body += `<div style="display:flex;align-items:center;gap:6px;margin-bottom:8px;">
@@ -337,7 +717,7 @@ function _renderActions(nodes, cfg) {
const remotes = nodes.filter(n => !n.is_me);
const hasPartner = remotes.some(n => n.hostname);
const termBase = `https://${window.location.hostname}/webterminal/ttyd/`;
const termCmd = `bash /mnt/user/appdata/Varaverk/Partnership/partnership_onboard.sh --phase1-only`;
const termCmd = `bash <?= SCRIPTS_DIR ?>/Partnership/partnership_onboard.sh --phase1-only`;
let html = '';
@@ -365,13 +745,18 @@ function _renderActions(nodes, cfg) {
html += `<div style="margin-bottom:14px;padding-bottom:14px;border-bottom:1px solid #1e1e1e;">`;
// Header: host + online dot
// Header: host + online dot + test connection
html += `<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">
<div>
<span style="font-size:10px;color:#444;">${remote.id}</span>
<span style="font-size:12px;color:#bbb;font-weight:500;margin-left:5px;">${remote.hostname}</span>
</div>
<span style="font-size:10px;color:${dotCol};">● ${dotLbl}</span>
<div style="display:flex;align-items:center;gap:8px;">
<span id="vv-pt-ping-${remote.id}" style="font-size:10px;color:#444;"></span>
<button class="vv-pt-action-btn info" onclick="vvPtPing(this,'${remote.slot}','${remote.id}')"
style="font-size:10px;padding:2px 8px;" title="SSH echo round-trip">⇄ Test</button>
<span style="font-size:10px;color:${dotCol};">● ${dotLbl}</span>
</div>
</div>`;
const isDeleting = !!_vvDeleteKeys[remote.id];
@@ -516,10 +901,18 @@ function _renderActions(nodes, cfg) {
</button></div>`;
if (cfg.enabled && isOwner) {
const tok = (cfg.transfer_confirm || 'i-understand-this-transfers-ownership')
.replace(/'/g, "\\'");
html += `<div style="margin-top:12px;padding-top:10px;border-top:1px solid #1e1e1e;">
<span style="font-size:11px;color:#555;">Transfer ownership run manually:</span>
<div class="vv-pt-transfer-note" style="margin-top:4px;padding:6px 10px;background:#111;border-radius:4px;color:#555;">
bash Partnership/partnership_transfer.sh --confirm=i-understand-this-transfers-ownership
<div style="display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap;">
<span style="font-size:11px;color:#555;">Transfer ownership to the mirror promotes it to owner.</span>
<button class="vv-pt-action-btn warn" onclick="vvPtTransfer(this, '${tok}')"
title="Runs partnership_transfer.sh — requires sustained health checks before switching">
Transfer Ownership
</button>
</div>
<div class="vv-pt-transfer-note" style="margin-top:6px;padding:6px 10px;background:#111;border-radius:4px;color:#444;">
Or run manually: bash Partnership/partnership_transfer.sh --confirm=${cfg.transfer_confirm || 'i-understand-this-transfers-ownership'}
</div>
</div>`;
}
@@ -536,6 +929,12 @@ function _render(data) {
// Config bar
document.getElementById('vv-pt-config-body').innerHTML = _renderConfig(cfg);
// Offline countdown warning
_renderOfflineWarn(cfg);
// Mirror sync health
document.getElementById('vv-pt-sync-body').innerHTML = _renderSync(data.sync || {});
// Node grid — columns based on count
const cols = nodes.length <= 2 ? nodes.length : nodes.length <= 4 ? 2 : 3;
const grid = document.getElementById('vv-pt-nodes');
+20 -2
View File
@@ -4,6 +4,10 @@ require_once dirname(__DIR__) . '/include/scheduler.php';
// Setup mode — auto-open a conf file and force the editing sequence
$vv_setup_conf = preg_match('/^[\w.]+\.conf$/', $_GET['vv_setup'] ?? '')
? $_GET['vv_setup'] : '';
// Deep-link from Monitor → open a specific script on load (e.g. ?tab=scheduler&vv_script=Media/x.sh)
$vv_open_script = preg_match('#^[\w./-]+\.sh$#', $_GET['vv_script'] ?? '')
? $_GET['vv_script'] : '';
$_vv_my_host = vv_detect_host();
$_vv_local_host_conf = ($_vv_my_host !== 'unknown') ? $_vv_my_host . '.conf' : 'host1.conf';
@@ -959,6 +963,20 @@ if (vvSetupConf) {
});
}
// Deep-link from Monitor: open a specific script's panel once the tree is rendered.
const vvOpenScriptId = <?= json_encode($vv_open_script) ?>;
if (vvOpenScriptId) {
document.addEventListener('DOMContentLoaded', () => {
setTimeout(() => {
if (document.querySelector('[data-id="' + CSS.escape(vvOpenScriptId) + '"]')) {
vvOpenRight(vvOpenScriptId);
const row = document.querySelector('[data-id="' + CSS.escape(vvOpenScriptId) + '"]');
if (row) row.scrollIntoView({behavior: 'smooth', block: 'center'});
}
}, 200);
});
}
function vvEscHtml(s) {
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
@@ -1995,13 +2013,13 @@ function vvUpdateErrors(errors) {
const now = Math.floor(Date.now() / 1000);
let html = '<div class="vv-errors-list">';
for (const e of unacked) {
const jobId = JSON.stringify(e.script + '.sh');
const jobId = "'" + (e.script + '.sh').replace(/\\/g,"\\\\").replace(/'/g,"\\'") + "'";
const label = e.script.split('/').pop().replace(/_/g, ' ');
html += '<div class="vv-err-row">'
+ '<div class="vv-err-top">'
+ '<span class="vv-err-script" onclick="vvOpenRight(' + jobId + ')" style="cursor:pointer" title="' + vvEscHtml(e.script) + '">' + vvEscHtml(label) + '</span>'
+ '<span class="vv-err-age">' + vvFmtAge(now - e.ts) + '</span>'
+ '<button class="vv-btn-sm vv-ack-btn" onclick="vvAckError(' + JSON.stringify(e.script) + ',' + e.ts + ',this)">Ack</button>'
+ '<button class="vv-btn-sm vv-ack-btn" onclick="vvAckError(\'' + e.script.replace(/\\/g,"\\\\").replace(/'/g,"\\'") + "'," + e.ts + ',this)">Ack</button>'
+ '</div>'
+ '<div class="vv-err-line">' + vvEscHtml(e.line) + '</div>'
+ '</div>';
+60 -6
View File
@@ -93,8 +93,8 @@ $isConfOnlyFlow = !empty($masterHost1) && $confMissing;
method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'}, body: params
}).then(r => r.json()).then(d => {
if (d.ok) {
status.textContent = '✓ Created — loading…'; status.className = 'ok';
setTimeout(() => { window.location.href = '?tab=scheduler&vv_setup=' + encodeURIComponent(<?= json_encode($myHostId . '.conf') ?>); }, 600);
status.textContent = '✓ Created'; status.className = 'ok';
vvShowStep2('?tab=scheduler&vv_setup=' + encodeURIComponent(<?= json_encode($myHostId . '.conf') ?>));
} else {
btn.disabled = false; btn.textContent = 'Create <?= htmlspecialchars($myHostId) ?>.conf and continue →';
status.textContent = '✗ ' + (d.error ?? 'Error'); status.className = 'err';
@@ -151,8 +151,8 @@ $isConfOnlyFlow = !empty($masterHost1) && $confMissing;
method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'}, body: params
}).then(r => r.json()).then(d => {
if (d.ok) {
status.textContent = '✓ Configuration pulled — loading…'; status.className = 'ok';
setTimeout(() => { window.location.href = d.redirect ?? '?tab=scheduler'; }, 600);
status.textContent = '✓ Configuration pulled'; status.className = 'ok';
vvShowStep2(d.redirect ?? '?tab=scheduler');
} else {
btn.disabled = false; btn.textContent = 'Pull configuration from HOST1 →';
status.textContent = '✗ ' + (d.error ?? 'Error'); status.className = 'err';
@@ -244,8 +244,8 @@ $isConfOnlyFlow = !empty($masterHost1) && $confMissing;
method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'}, body: params
}).then(r => r.json()).then(d => {
if (d.ok) {
status.textContent = '✓ Saved — loading…'; status.className = 'ok';
setTimeout(() => { window.location.href = d.redirect ?? '?tab=scheduler&vv_setup=master.conf'; }, 600);
status.textContent = '✓ Saved'; status.className = 'ok';
vvShowStep2(d.redirect ?? '?tab=scheduler&vv_setup=master.conf');
} else {
btn.disabled = false; btn.textContent = 'Save and continue →';
status.textContent = '✗ ' + (d.error ?? 'Error'); status.className = 'err';
@@ -255,4 +255,58 @@ $isConfOnlyFlow = !empty($masterHost1) && $confMissing;
</script>
<?php endif; ?>
<!-- Step 2: API key — shown after any wizard flow completes -->
<div id="vv-setup-step2" style="display:none;">
<hr class="vv-setup-divider">
<div style="font-size:10px;color:#555;text-transform:uppercase;letter-spacing:.06em;margin-bottom:10px;">Step 2 of 2 — Unraid API Key</div>
<div class="vv-setup-info-box">
Varaverk uses the local Unraid API to display live stats on the Monitor tab.
Creates a <strong>Varaverk</strong> key via <code style="color:#555;">unraid-api</code> and writes it to your host conf.
</div>
<div style="display:flex;gap:10px;align-items:center;">
<button id="vv-key-btn2" onclick="vvCreateApiKeyWizard(this)"
style="flex:1;padding:10px 0;background:#1a3a1a;border:1px solid #2e6b2e;color:#6fcf97;
font-family:monospace;font-size:13px;border-radius:3px;cursor:pointer;">
Create API Key
</button>
<a id="vv-skip-link" href="#" onclick="vvWizardContinue(event)"
style="font-size:11px;color:#444;text-decoration:none;white-space:nowrap;">Skip →</a>
</div>
<div id="vv-key-status2" style="margin-top:8px;font-size:12px;min-height:16px;"></div>
</div>
<script>
let _vvWizardNext = '';
function vvShowStep2(redirect) {
_vvWizardNext = redirect;
document.getElementById('vv-setup-step2').style.display = 'block';
}
function vvWizardContinue(e) {
if (e) e.preventDefault();
window.location.href = _vvWizardNext || '?tab=scheduler';
}
function vvCreateApiKeyWizard(btn) {
const status = document.getElementById('vv-key-status2');
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 = 'Continue →'; btn.disabled = false;
btn.onclick = vvWizardContinue;
const skip = document.getElementById('vv-skip-link');
if (skip) skip.style.display = 'none';
} 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';
});
}
</script>
</div>