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

This commit is contained in:
Gmer4Lfe
2026-06-03 15:58:50 -04:00
parent 8be54ab5ee
commit de6fcc3997
13 changed files with 2546 additions and 343 deletions
+464 -176
View File
@@ -66,6 +66,16 @@ textarea.vv-set-input { resize:vertical; white-space:pre; }
<div id="vv-pt-config-body" style="color:#444;font-size:12px;">Loading…</div>
</div>
<!-- Host Settings -->
<div class="vv-card" id="vv-pt-hosts-card" style="margin-bottom:12px;display:none;">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;">
<h3 style="margin:0;">Host Settings</h3>
<button id="vv-pt-hosts-save" class="vv-pt-action-btn run"
onclick="vvPtSaveHosts(this)" style="display:none;">Save</button>
</div>
<div id="vv-pt-hosts-body"></div>
</div>
<!-- Offline countdown warning (shown only when partner has been unreachable) -->
<div id="vv-pt-offline-warn" style="display:none;margin-bottom:12px;"></div>
@@ -74,16 +84,19 @@ textarea.vv-set-input { resize:vertical; white-space:pre; }
<div style="color:#444;font-size:12px;padding:16px 0;text-align:center;">Loading…</div>
</div>
<!-- Mirror sync health -->
<div class="vv-card" id="vv-pt-sync-card" style="margin-bottom:12px;">
<h3>Mirror Sync</h3>
<div id="vv-pt-sync-body" style="color:#444;font-size:12px;">Loading…</div>
</div>
<!-- Array settings (above sync/actions row) -->
<div id="vv-pt-arrays-wrap" style="margin-bottom:12px;display:none;"></div>
<!-- Actions -->
<div class="vv-card" id="vv-pt-actions-card">
<h3>Actions</h3>
<div id="vv-pt-actions-body" style="color:#444;font-size:12px;">Loading…</div>
<!-- Mirror sync + Actions side by side -->
<div style="display:flex;gap:12px;margin-bottom:12px;align-items:stretch;flex-wrap:wrap;">
<div class="vv-card" id="vv-pt-sync-card" style="flex:1 1 220px;min-width:0;display:flex;flex-direction:column;">
<h3>Mirror Sync</h3>
<div id="vv-pt-sync-body" style="flex:1;color:#444;font-size:12px;">Loading…</div>
</div>
<div class="vv-card" id="vv-pt-actions-card" style="flex:2 1 300px;min-width:0;">
<h3>Actions</h3>
<div id="vv-pt-actions-body" style="color:#444;font-size:12px;">Loading…</div>
</div>
</div>
<!-- Settings -->
@@ -303,6 +316,165 @@ function vvPtToggleSync(el, varName, enabled) {
.catch(e => { alert('Error: ' + e); el.style.pointerEvents = ''; el.style.opacity = ''; });
}
// ── Host Settings ─────────────────────────────────────────────────────────────
function _renderHostSettings(nodes, isOwner) {
let html = '<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:10px;">';
for (const n of nodes) {
const rdonly = isOwner ? '' : 'readonly style="opacity:.5;cursor:default;"';
html += `<div>
<div class="vv-set-key" style="margin-bottom:4px;">${_vvSetEsc(n.id)}</div>
<input type="text" ${rdonly}
class="vv-set-input vv-pt-host-inp"
data-key="${_vvSetEsc(n.id)}" data-file="master.conf" data-type="scalar"
data-orig="${_vvSetEsc(n.hostname)}"
value="${_vvSetEsc(n.hostname)}"
oninput="vvPtHostChanged(this)"
placeholder="hostname">
</div>`;
}
html += '</div>';
if (!isOwner) html += '<div style="font-size:10px;color:#333;margin-top:6px;">Editing requires host1.</div>';
return html;
}
function vvPtHostChanged(el) {
el.classList.toggle('changed', el.value !== el.dataset.orig);
const any = document.querySelector('.vv-pt-host-inp.changed');
document.getElementById('vv-pt-hosts-save').style.display = any ? '' : 'none';
}
function vvPtSaveHosts(btn) {
const changedEls = document.querySelectorAll('.vv-pt-host-inp.changed');
if (!changedEls.length) return;
const changes = [];
changedEls.forEach(el => changes.push({key: el.dataset.key, file: el.dataset.file, type: el.dataset.type, value: el.value}));
if (!confirm(`Save ${changes.length} host setting(s)?`)) return;
btn.disabled = true; btn.textContent = '⟳ Saving…';
fetch('/plugins/varaverk/api/confform.php', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({
csrf_token: typeof csrf_token !== 'undefined' ? csrf_token : '',
id: '__settings__',
changes: JSON.stringify(changes)
})
})
.then(r => r.json())
.then(d => {
btn.disabled = false;
if (d.ok) {
changedEls.forEach(el => { el.dataset.orig = el.value; el.classList.remove('changed'); });
btn.textContent = '✓ Saved';
setTimeout(() => { btn.textContent = 'Save'; btn.style.display = 'none'; }, 2000);
} else {
btn.textContent = 'Save';
alert('Save failed: ' + (d.error ?? 'Unknown error'));
}
})
.catch(e => { btn.disabled = false; btn.textContent = 'Save'; alert('Error: ' + e); });
}
// ── Array settings cards ───────────────────────────────────────────────────────
let _vvArrFiles = null;
function _vvInitArrayCards() {
if (_vvArrFiles !== null) return;
_vvArrFiles = false; // loading sentinel
fetch('/plugins/varaverk/api/partnership_settings.php?_=' + Date.now())
.then(r => r.json())
.then(d => {
_vvArrFiles = (d.ok && d.files) ? d.files : [];
_vvRenderArrayCards();
})
.catch(() => { _vvArrFiles = []; document.getElementById('vv-pt-arrays-wrap').style.display = 'none'; });
}
function _vvRenderArrayCards() {
const files = _vvArrFiles;
if (!files || !files.length) { document.getElementById('vv-pt-arrays-wrap').style.display = 'none'; return; }
let html = '';
let hasArrays = false;
for (const f of files) {
for (const g of f.groups) {
for (const fld of g.fields) {
if (fld.type === 'scalar') continue;
hasArrays = true;
const rows = Math.min(16, (fld.value.match(/\n/g) || []).length + 2);
const attrs = `class="vv-set-input" data-key="${_vvSetEsc(fld.key)}" `
+ `data-file="${_vvSetEsc(fld.file)}" data-type="${_vvSetEsc(fld.type)}" `
+ `data-orig="${_vvSetEsc(fld.value)}" oninput="vvPtArrChanged(this)"`;
html += `<div class="vv-set-file">
<div class="vv-set-file-hdr" onclick="vvPtToggleNode(this)">
<span>${_vvSetEsc(fld.key)}</span>
<div style="display:flex;align-items:center;gap:6px;">
${fld.desc ? `<span style="font-size:9px;color:#444;font-weight:normal;font-family:inherit;">${_vvSetEsc(fld.desc)}</span>` : ''}
<span class="vv-set-chev">▸</span>
</div>
</div>
<div class="vv-set-file-body" style="display:none;">
<textarea ${attrs} rows="${rows}">${_vvSetEsc(fld.value)}</textarea>
</div>
</div>`;
}
}
}
const wrap = document.getElementById('vv-pt-arrays-wrap');
if (!hasArrays) { wrap.style.display = 'none'; return; }
wrap.style.display = '';
wrap.innerHTML = `<div class="vv-card">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">
<h3 style="margin:0;">Array Settings</h3>
<button id="vv-pt-arrays-save" class="vv-pt-action-btn run"
onclick="vvPtSaveArrays(this)" style="display:none;">Save Changes</button>
</div>
${html}
</div>`;
}
function vvPtArrChanged(el) {
el.classList.toggle('changed', el.value !== el.dataset.orig);
const any = document.querySelector('#vv-pt-arrays-wrap .vv-set-input.changed');
const saveBtn = document.getElementById('vv-pt-arrays-save');
if (saveBtn) saveBtn.style.display = any ? '' : 'none';
}
function vvPtSaveArrays(btn) {
const changedEls = document.querySelectorAll('#vv-pt-arrays-wrap .vv-set-input.changed');
if (!changedEls.length) return;
const changes = [];
changedEls.forEach(el => changes.push({key: el.dataset.key, file: el.dataset.file, type: el.dataset.type, value: el.value}));
if (!confirm(`Save ${changes.length} changed setting(s)?`)) return;
btn.disabled = true; btn.textContent = '⟳ Saving…';
fetch('/plugins/varaverk/api/confform.php', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({
csrf_token: typeof csrf_token !== 'undefined' ? csrf_token : '',
id: '__settings__',
changes: JSON.stringify(changes)
})
})
.then(r => r.json())
.then(d => {
btn.disabled = false;
if (d.ok) {
changedEls.forEach(el => { el.dataset.orig = el.value; el.classList.remove('changed'); });
btn.textContent = '✓ Saved';
_vvArrFiles = null; // invalidate so next init re-fetches
setTimeout(() => _vvInitArrayCards(), 600);
} else {
btn.textContent = 'Save Changes';
alert('Save failed: ' + (d.error ?? 'Unknown error'));
}
})
.catch(e => { btn.disabled = false; btn.textContent = 'Save Changes'; alert('Error: ' + e); });
}
// ── Settings panel ─────────────────────────────────────────────────────────────
let _vvSetLoaded = false;
@@ -343,33 +515,26 @@ function vvPtLoadSettings() {
function vvPtRenderSettings(files) {
let html = '';
for (const f of files) {
// One block per file; partnership scope yields a single section per file, so flatten.
const label = f.file === 'master.conf' ? 'master.conf — shared' : f.file + ' — this host';
const scalarFields = f.groups.flatMap(g => g.fields.filter(fld => fld.type === 'scalar'));
if (!scalarFields.length) continue;
html += `<div class="vv-set-file">
<div class="vv-set-file-hdr" onclick="vvPtToggleNode(this)">
<span>${_vvSetEsc(label)}</span><span class="vv-set-chev">▾</span>
</div>
<div class="vv-set-file-body">`;
for (const g of f.groups) {
for (const fld of g.fields) {
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>`;
}
for (const fld of scalarFields) {
const attrs = `class="vv-set-input" data-key="${_vvSetEsc(fld.key)}" `
+ `data-file="${_vvSetEsc(fld.file)}" data-type="${_vvSetEsc(fld.type)}" `
+ `data-orig="${_vvSetEsc(fld.value)}" oninput="vvPtSetChanged(this)"`;
html += `<div class="vv-set-field">
<div class="vv-set-key">${_vvSetEsc(fld.key)}</div>`;
if (fld.desc) html += `<div class="vv-set-desc">${_vvSetEsc(fld.desc)}</div>`;
html += `<input type="text" ${attrs} value="${_vvSetEsc(fld.value)}"></div>`;
}
html += `</div></div>`;
}
return html;
return html || '<div style="color:#555;font-size:12px;padding:8px 0;">No scalar settings available.</div>';
}
function vvPtToggleNode(hdr) {
@@ -491,47 +656,98 @@ function _syncToggle(gate, dimmed) {
}
function _renderSync(sync) {
const jobs = sync.jobs || {};
const gates = sync.gates || {};
const jobs = sync.jobs || {};
const gates = sync.gates || {};
const intervalMin = sync.interval_min || 30;
const sCol = s => s === 'ok' ? '#4caf50' : s === 'running' ? '#4a9eff'
: s === 'warn' ? '#ff9800' : s === 'error' ? '#f44336' : '#555';
const sLbl = s => s === 'ok' ? '✓ ok' : s === 'running' ? '⟳ running'
: s === 'warn' ? '⚠ warn' : s === 'error' ? '✗ error' : '— never';
const sLbl = s => s === 'ok' ? '✓' : s === 'running' ? '⟳' : s === 'warn' ? '⚠' : s === 'error' ? '✗' : '—';
function _dur(sec) {
if (!sec || sec < 0) return '';
if (sec < 60) return sec + 's';
if (sec < 3600) return Math.floor(sec / 60) + 'm ' + (sec % 60) + 's';
return Math.floor(sec / 3600) + 'h ' + Math.floor((sec % 3600) / 60) + 'm';
}
function _countdown(ts) {
if (!ts) return null;
const sec = ts - Math.floor(Date.now() / 1000);
if (sec <= 0) return 'due';
if (sec < 60) return 'in ' + sec + 's';
if (sec < 3600) return 'in ' + Math.floor(sec / 60) + 'm';
return 'in ' + Math.floor(sec / 3600) + 'h ' + Math.floor((sec % 3600) / 60) + 'm';
}
const globalOff = gates.global && !gates.global.on;
// Master toggle row — controls all mirroring (RSYNC_ENABLED, Tier 1).
let html = '';
// Wrap everything in a flex-column so the bottom section anchors naturally
let html = '<div style="display:flex;flex-direction:column;gap:0;height:100%;">';
// Master toggle row
if (gates.global) {
html += `<div class="vv-pt-row" style="margin-bottom:6px;padding-bottom:6px;border-bottom:1px solid #1c1c1c;">
html += `<div class="vv-pt-row" style="margin-bottom:8px;padding-bottom:8px;border-bottom:1px solid #1c1c1c;">
<span class="vv-pt-lbl" style="font-weight:600;color:#999;">All mirroring</span>
<span class="vv-pt-val">${_syncToggle(gates.global, false)}</span>
</div>`;
}
// Per-tier rows: status + last-run + per-tier toggle (dimmed when global is off).
const labels = {critical: 'Critical (30 min)', daily: 'Daily', weekly: 'Weekly'};
// Per-tier rows
const tierMeta = {
critical: {label: 'Critical', sched: 'every 30 min', desc: 'appdata · partnership · play state'},
daily: {label: 'Daily', sched: 'nightly', desc: 'media shares · arr sync · docker'},
weekly: {label: 'Weekly', sched: 'weekly', desc: 'bulk shares · updates · cleanup'},
};
// Critical next-run estimate
const critJob = jobs.critical;
const critNextTs = critJob && critJob.end && critJob.status !== 'running'
? critJob.end + intervalMin * 60 : null;
html += '<div style="display:flex;flex-direction:column;gap:10px;flex:1;">';
for (const key of ['critical', 'daily', 'weekly']) {
const j = jobs[key];
const j = jobs[key];
const meta = tierMeta[key];
const status = j ? j.status : null;
const when = j && j.start
? (j.status === 'running' ? 'started ' + _relTime(j.start)
: _relTime(j.end || j.start))
: '';
html += `<div class="vv-pt-row">
<span class="vv-pt-lbl">${labels[key]}</span>
<span class="vv-pt-val">
<span style="color:${sCol(status)};">${sLbl(status)}</span>
<span style="color:#444;font-size:10px;margin-left:6px;">${when}</span>
${_syncToggle(gates[key], globalOff)}
</span>
? (j.status === 'running' ? 'started ' + _relTime(j.start) : _relTime(j.end || j.start))
: null;
const dur = j && j.start && j.end && j.status !== 'running' ? _dur(j.end - j.start) : '';
const summary = j && j.summary ? j.summary : '';
html += `<div style="background:#0f0f0f;border:1px solid #1e1e1e;border-radius:4px;padding:7px 9px;">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:3px;">
<span style="font-size:11px;font-weight:500;color:#aaa;">${meta.label}
<span style="font-size:9px;color:#333;font-weight:normal;margin-left:3px;">${meta.sched}</span>
</span>
<span style="display:flex;align-items:center;gap:5px;">
<span style="font-size:11px;color:${sCol(status)};">${sLbl(status)}</span>
${dur ? `<span style="font-size:9px;color:#3a3a3a;">${dur}</span>` : ''}
${when ? `<span style="font-size:9px;color:#2e2e2e;">${when}</span>` : ''}
${_syncToggle(gates[key], globalOff)}
</span>
</div>
<div style="font-size:9px;color:#2a2a2a;">${meta.desc}</div>
${summary ? `<div style="font-size:9px;color:#3a5a3a;margin-top:3px;font-family:monospace;">${_vvSetEsc(summary)}</div>` : ''}
</div>`;
}
html += '</div>'; // flex:1
// Bottom info strip
const nextLabel = critNextTs ? _countdown(critNextTs) : null;
html += `<div style="margin-top:10px;padding-top:8px;border-top:1px solid #1a1a1a;display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:6px;">
<span style="font-size:10px;color:#2a2a2a;">interval: ${intervalMin}min</span>
${nextLabel ? `<span style="font-size:10px;color:#2e3e2e;">next critical ${nextLabel}</span>` : ''}
</div>`;
if (globalOff) {
html += `<div style="font-size:10px;color:#f44336;margin-top:6px;">⚠ All mirroring is off — per-tier switches have no effect until re-enabled.</div>`;
}
html += '</div>'; // outer flex column
return html;
}
@@ -721,214 +937,280 @@ function _renderActions(nodes, cfg) {
let html = '';
// ── No partner ─────────────────────────────────────────────────────────────
if (!hasPartner && isOwner) {
html += `<div style="font-size:11px;color:#444;margin-bottom:12px;">
No partner configured — edit master.conf (Scheduler tab) and set HOST2.
html += `<div style="background:#0f0f0f;border:1px solid #1e1e1e;border-radius:4px;padding:14px 16px;
display:flex;flex-direction:column;align-items:center;gap:6px;text-align:center;">
<div style="font-size:11px;color:#555;">No partner host configured</div>
<div style="font-size:10px;color:#333;">Set HOST2 in master.conf (Host Settings above) to enable partnership.</div>
</div>`;
}
// ── Per-remote host section ────────────────────────────────────────────────
if (isOwner && hasPartner) {
remotes.forEach(remote => {
const phase = remote.onboard_phase ?? 0;
remotes.forEach((remote, idx) => {
const phase = remote.onboard_phase ?? 0;
const isOnboarding = !!_vvOnboarding[remote.id];
const dotCol = remote.ts_online === null ? '#555' : remote.ts_online ? '#4caf50' : '#f44336';
const dotLbl = remote.ts_online === null ? 'unknown' : remote.ts_online ? 'online' : 'offline';
const pt = remote.partnership || {};
const ptState = (pt.state || '').toUpperCase();
const ptDate = pt.updated
const isDeleting = !!_vvDeleteKeys[remote.id];
const dotCol = remote.ts_online === null ? '#444' : remote.ts_online ? '#4caf50' : '#f44336';
const dotLbl = remote.ts_online === null ? 'unknown' : remote.ts_online ? 'online' : 'offline';
const pt = remote.partnership || {};
const ptState = (pt.state || '').toUpperCase();
const ptDate = pt.updated
? new Date(parseInt(pt.updated) * 1000).toLocaleDateString([], {month:'short',day:'numeric',year:'numeric'})
: null;
const sys = remote.system || {};
const sysUptime = sys.uptime_sec ? _uptime(sys.uptime_sec) : null;
const sysVer = sys.unraid_version || null;
const sys = remote.system || {};
const sysUptime = sys.uptime_sec ? _uptime(sys.uptime_sec) : null;
const sysVer = sys.unraid_version || null;
html += `<div style="margin-bottom:14px;padding-bottom:14px;border-bottom:1px solid #1e1e1e;">`;
// Left border colour by phase
const borderCol = phase === 2 ? '#2a5a2a'
: phase === 1 ? '#4a3000'
: isOnboarding ? '#0e2a4a' : '#222';
// Header: host + online dot + test connection
html += `<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">
<div>
<span style="font-size:10px;color:#444;">${remote.id}</span>
<span style="font-size:12px;color:#bbb;font-weight:500;margin-left:5px;">${remote.hostname}</span>
</div>
if (idx > 0) html += '<div style="height:10px;"></div>';
html += `<div style="background:#0d0d0d;border:1px solid #1e1e1e;border-left:3px solid ${borderCol};
border-radius:4px;overflow:hidden;">`;
// ── Card header ─────────────────────────────────────────────────────────
html += `<div style="display:flex;justify-content:space-between;align-items:center;
padding:8px 10px;background:#111;border-bottom:1px solid #1a1a1a;">
<div style="display:flex;align-items:center;gap:8px;">
<span id="vv-pt-ping-${remote.id}" style="font-size:10px;color:#444;"></span>
<span style="font-size:9px;color:#444;font-family:monospace;">${remote.id}</span>
<span style="font-size:12px;color:#ccc;font-weight:600;">${remote.hostname}</span>
<span style="font-size:9px;color:${dotCol};">● ${dotLbl}</span>
</div>
<div style="display:flex;align-items:center;gap:6px;">
<span id="vv-pt-ping-${remote.id}" style="font-size:9px;color:#444;"></span>
<button class="vv-pt-action-btn info" onclick="vvPtPing(this,'${remote.slot}','${remote.id}')"
style="font-size:10px;padding:2px 8px;" title="SSH echo round-trip">⇄ Test</button>
<span style="font-size:10px;color:${dotCol};">● ${dotLbl}</span>
style="font-size:9px;padding:2px 7px;" title="SSH echo round-trip">⇄ ping</button>
</div>
</div>`;
const isDeleting = !!_vvDeleteKeys[remote.id];
html += `<div style="padding:10px 12px;">`;
// ── Phase 0: not provisioned ─────────────────────────────────────────
// ── Phase 0: not provisioned ───────────────────────────────────────────
if (phase === 0) {
if (isOnboarding) {
html += `<div style="background:#0d0d0d;border:1px solid #1e1e1e;border-radius:4px;padding:10px 12px;">
<div style="display:flex;align-items:baseline;gap:8px;margin-bottom:8px;">
<span style="font-size:9px;color:#4a9eff;background:#0e1a2a;padding:2px 8px;border-radius:10px;font-weight:600;">Step 1</span>
<span style="font-size:11px;color:#888;">Install SSH key on ${remote.hostname}</span>
html += `<div style="display:flex;flex-direction:column;gap:10px;">
<div>
<div style="display:flex;align-items:center;gap:6px;margin-bottom:6px;">
<span style="font-size:9px;font-weight:700;color:#4a9eff;background:#0a1828;
padding:2px 8px;border-radius:10px;border:1px solid #1a3a5a;">Step 1</span>
<span style="font-size:11px;color:#888;">Install SSH key on ${remote.hostname}</span>
</div>
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:4px;">
<a href="${termBase}" target="_blank"
style="padding:3px 10px;background:#0e1a2a;color:#7ab;border:1px solid #1e3a5a;
border-radius:3px;text-decoration:none;font-size:10px;white-space:nowrap;">Open Terminal</a>
<code onclick="navigator.clipboard.writeText('${termCmd}').then(()=>{this.style.color='#4caf50';setTimeout(()=>this.style.color='#444',1500)})"
style="font-size:9px;color:#444;background:#080808;padding:3px 8px;border-radius:3px;
border:1px solid #181818;flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;
white-space:nowrap;cursor:pointer;" title="Click to copy">${termCmd}</code>
</div>
<div style="font-size:9px;color:#2a2a2a;">Enter ${remote.hostname} root password when prompted · tab auto-updates on completion</div>
</div>
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:4px;">
<a href="${termBase}" target="_blank"
style="padding:3px 10px;background:#1a2a3a;color:#7ab;border:1px solid #2e4a6b;
border-radius:3px;text-decoration:none;font-size:10px;white-space:nowrap;">🖥 Open Terminal</a>
<code onclick="navigator.clipboard.writeText('${termCmd}').then(()=>{this.style.color='#4caf50';setTimeout(()=>this.style.color='#555',1500)})"
style="font-size:9px;color:#555;background:#0a0a0a;padding:3px 8px;border-radius:3px;
border:1px solid #1a1a1a;flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;
white-space:nowrap;cursor:pointer;" title="Click to copy">${termCmd}</code>
<div style="border-top:1px solid #1a1a1a;padding-top:10px;">
<div style="display:flex;align-items:center;gap:6px;margin-bottom:6px;">
<span style="font-size:9px;font-weight:700;color:#4caf50;background:#0a1a0a;
padding:2px 8px;border-radius:10px;border:1px solid #1a3a1a;">Step 2</span>
<span style="font-size:11px;color:#888;">Push conf</span>
<span style="font-size:9px;color:#2a2a2a;">if key already installed separately</span>
</div>
<button class="vv-pt-action-btn info" onclick="vvPtPushConf(this,'${remote.id}')"
title="Runs --phase1-only --skip-ssh" style="font-size:11px;">▶ Push Conf</button>
</div>
<div style="font-size:9px;color:#2a2a2a;margin-bottom:14px;">Enter ${remote.hostname} root password when prompted · tab auto-updates on completion</div>
<div style="display:flex;align-items:baseline;gap:8px;margin-bottom:8px;">
<span style="font-size:9px;color:#4caf50;background:#0a1a0a;padding:2px 8px;border-radius:10px;font-weight:600;">Step 2</span>
<span style="font-size:11px;color:#888;">Push conf</span>
<span style="font-size:9px;color:#2a2a2a;">if key already installed separately</span>
</div>
<button class="vv-pt-action-btn info" onclick="vvPtPushConf(this,'${remote.id}')"
title="Runs --phase1-only --skip-ssh">▶ Push Conf</button>
</div>
<div class="vv-pt-actions" style="margin-top:8px;">
<button class="vv-pt-action-btn warn" onclick="vvPtEndOnboard('${remote.id}')" style="opacity:.6;">✕ Close</button>
<div style="margin-top:10px;padding-top:8px;border-top:1px solid #181818;">
<button class="vv-pt-action-btn warn" onclick="vvPtEndOnboard('${remote.id}')"
style="opacity:.5;font-size:11px;">✕ Close</button>
</div>`;
} else {
html += `<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
<span style="font-size:10px;color:#444;">○ Not provisioned</span>
<button class="vv-pt-action-btn run" onclick="vvPtStartOnboard('${remote.id}')">▶ Onboard</button>
<button class="vv-pt-action-btn warn" onclick="vvPtShowDeleteKeys('${remote.id}')"
style="opacity:.5;font-size:11px;">🗑 Delete Keys</button>
html += `<div style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:8px;">
<div>
<div style="font-size:11px;color:#555;margin-bottom:2px;">Not provisioned</div>
<div style="font-size:9px;color:#2a2a2a;">SSH keys not installed · conf not pushed</div>
</div>
<div style="display:flex;gap:6px;flex-wrap:wrap;">
<button class="vv-pt-action-btn run" onclick="vvPtStartOnboard('${remote.id}')"
style="font-size:11px;">▶ Onboard</button>
<button class="vv-pt-action-btn warn" onclick="vvPtShowDeleteKeys('${remote.id}')"
style="opacity:.45;font-size:11px;">🗑 Keys</button>
</div>
</div>`;
}
// ── Phase 1: SSH done, awaiting HOST2 ─────────────────────────────────
} else if (phase === 1) {
html += `<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:6px;">
<span style="font-size:10px;color:#ff9800;background:#1a1200;padding:2px 8px;border-radius:10px;border:1px solid #3a2800;">⏳ SSH ready</span>
${remote.ts_ip ? `<span style="font-size:9px;color:#2a2a2a;">${remote.ts_ip}</span>` : ''}
</div>
<div style="font-size:10px;color:#444;margin-bottom:8px;">
Waiting for ${remote.id} to install Varaverk and complete its onboard. Phase 2 triggers automatically.
</div>
<div class="vv-pt-actions">
<button class="vv-pt-action-btn info" onclick="vvPtPhase2(this,'${remote.id}')"
title="Manually trigger Phase 2 if ${remote.id} auto-notification did not arrive">
▶ Run Phase 2 Manually
</button>
<button class="vv-pt-action-btn warn" onclick="vvPtCancel(this,'${remote.id}')">✕ Cancel</button>
<button class="vv-pt-action-btn warn" onclick="vvPtShowDeleteKeys('${remote.id}')"
style="opacity:.6;font-size:11px;">🗑 Delete Keys</button>
html += `<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:10px;flex-wrap:wrap;margin-bottom:10px;">
<div>
<div style="display:flex;align-items:center;gap:6px;margin-bottom:4px;">
<span style="font-size:10px;font-weight:600;color:#ff9800;background:#1a1200;
padding:2px 9px;border-radius:10px;border:1px solid #3a2800;">SSH ready</span>
${remote.ts_ip ? `<span style="font-size:9px;color:#333;font-family:monospace;">${remote.ts_ip}</span>` : ''}
</div>
<div style="font-size:9px;color:#383838;line-height:1.5;">
Waiting for ${remote.id} to install Varaverk and complete onboard.<br>Phase 2 triggers automatically.
</div>
</div>
<div style="display:flex;flex-direction:column;gap:5px;align-items:flex-end;">
<button class="vv-pt-action-btn info" onclick="vvPtPhase2(this,'${remote.id}')"
style="font-size:11px;" title="Manually trigger Phase 2">▶ Phase 2</button>
<button class="vv-pt-action-btn warn" onclick="vvPtCancel(this,'${remote.id}')"
style="font-size:11px;">✕ Cancel</button>
<button class="vv-pt-action-btn warn" onclick="vvPtShowDeleteKeys('${remote.id}')"
style="opacity:.45;font-size:10px;">🗑 Keys</button>
</div>
</div>`;
// ── Phase 2: fully onboarded ───────────────────────────────────────────
} else {
const ptColor = ptState === 'ACTIVE' ? '#4caf50' : ptState === 'INACTIVE' ? '#f44336' : '#555';
html += `<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:6px;">
<span style="font-size:10px;color:#4caf50;background:#0a1a0a;padding:2px 8px;border-radius:10px;border:1px solid #1a4a1a;">✅ Active</span>
${ptDate ? `<span style="font-size:9px;color:#333;">since ${ptDate}</span>` : ''}
</div>
<div style="display:flex;gap:14px;flex-wrap:wrap;font-size:10px;color:#2a2a2a;margin-bottom:8px;">
${remote.ts_ip ? `<span>${remote.ts_ip}</span>` : ''}
${sysUptime ? `<span>up ${sysUptime}</span>` : ''}
${sysVer ? `<span>unRAID ${sysVer}</span>` : ''}
${ptState ? `<span style="color:${ptColor};">${ptState}</span>` : ''}
</div>
<div class="vv-pt-actions">
<button class="vv-pt-action-btn info" onclick="vvPtPhase2(this,'${remote.id}')"
style="opacity:.4;" title="Re-run Phase 2">↻ Re-run Phase 2</button>
<button class="vv-pt-action-btn warn" onclick="vvPtShowDeleteKeys('${remote.id}')"
style="opacity:.5;font-size:11px;">🗑 Delete Keys</button>
html += `<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:10px;flex-wrap:wrap;">
<div>
<div style="display:flex;align-items:center;gap:6px;margin-bottom:5px;">
<span style="font-size:10px;font-weight:600;color:#4caf50;background:#0a1a0a;
padding:2px 9px;border-radius:10px;border:1px solid #1a4a1a;">Active</span>
${ptState ? `<span style="font-size:9px;color:${ptColor};font-weight:500;">${ptState}</span>` : ''}
${ptDate ? `<span style="font-size:9px;color:#2e2e2e;">since ${ptDate}</span>` : ''}
</div>
<div style="display:flex;flex-wrap:wrap;gap:10px;font-size:9px;color:#333;font-family:monospace;">
${remote.ts_ip ? `<span>${remote.ts_ip}</span>` : ''}
${sysVer ? `<span>unRAID ${sysVer}</span>` : ''}
${sysUptime ? `<span>up ${sysUptime}</span>` : ''}
</div>
</div>
<div style="display:flex;flex-direction:column;gap:5px;align-items:flex-end;">
<button class="vv-pt-action-btn info" onclick="vvPtPhase2(this,'${remote.id}')"
style="opacity:.35;font-size:10px;" title="Re-run Phase 2"> Re-run</button>
<button class="vv-pt-action-btn warn" onclick="vvPtShowDeleteKeys('${remote.id}')"
style="opacity:.4;font-size:10px;">🗑 Keys</button>
</div>
</div>`;
}
// ── Delete Keys panel (any phase) ──────────────────────────────────────
// ── Delete Keys panel ──────────────────────────────────────────────────
if (isDeleting) {
html += `<div style="margin-top:10px;background:#0d0d0d;border:1px solid #2a1a1a;border-radius:4px;padding:10px 12px;">
<div style="font-size:10px;color:#888;margin-bottom:10px;font-weight:600;text-transform:uppercase;letter-spacing:.05em;">Remove SSH access</div>
<div style="display:flex;flex-direction:column;gap:8px;">
html += `<div style="margin-top:10px;padding:10px;background:#0a0a0a;border:1px solid #2a1515;
border-radius:3px;">
<div style="font-size:9px;color:#5a2a2a;font-weight:700;text-transform:uppercase;
letter-spacing:.06em;margin-bottom:8px;">Remove SSH access</div>
<div style="display:flex;flex-direction:column;gap:7px;">
<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;">
<div>
<div style="font-size:11px;color:#bbb;">HOST1 ${remote.id}</div>
<div style="font-size:9px;color:#333;">HOST1's key on ${remote.hostname} · deletes local key pair</div>
<div style="font-size:10px;color:#888;">HOST1 ${remote.id}</div>
<div style="font-size:9px;color:#333;">HOST1's key on ${remote.hostname} · deletes local pair</div>
</div>
<button class="vv-pt-action-btn warn" onclick="vvPtDeleteH1(this,'${remote.id}')"
style="white-space:nowrap;font-size:11px;">✕ Remove HOST1 key</button>
style="white-space:nowrap;font-size:10px;">✕ HOST1 key</button>
</div>
<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;">
<div>
<div style="font-size:11px;color:#bbb;">${remote.id} → HOST1</div>
<div style="font-size:9px;color:#333;">${remote.hostname}'s key on HOST1 · removes from authorized_keys</div>
<div style="font-size:10px;color:#888;">${remote.id} → HOST1</div>
<div style="font-size:9px;color:#333;">${remote.hostname}'s key from authorized_keys</div>
</div>
<button class="vv-pt-action-btn warn" onclick="vvPtDeleteH2(this,'${remote.id}')"
style="white-space:nowrap;font-size:11px;"> Remove ${remote.id} key</button>
style="white-space:nowrap;font-size:10px;"> ${remote.id} key</button>
</div>
</div>
<div class="vv-pt-actions" style="margin-top:10px;">
<div style="margin-top:8px;">
<button class="vv-pt-action-btn info" onclick="vvPtHideDeleteKeys('${remote.id}')"
style="font-size:11px;opacity:.7;"> Done</button>
style="font-size:10px;opacity:.6;"> Done</button>
</div>
</div>`;
}
html += `</div>`;
html += `</div></div>`; // padding + card
});
}
// ── HOST1 local setup (when needed after phase 1+) ────────────────────────
// ── HOST1 local setup ──────────────────────────────────────────────────────
if (isOwner && selfNode && !selfNode.local_done && remotes.some(r => (r.onboard_phase ?? 0) >= 1)) {
html += `<div style="margin-bottom:12px;padding-bottom:12px;border-bottom:1px solid #1e1e1e;">
<div style="font-size:11px;color:#555;margin-bottom:6px;">
${selfNode.id} ${selfNode.hostname} <span style="color:#2a2a2a;">(this server)</span>
html += `<div style="margin-top:10px;padding:10px 12px;background:#0d0d0d;
border:1px solid #1e1e1e;border-left:3px solid #0e2a4a;border-radius:4px;
display:flex;align-items:center;justify-content:space-between;gap:10px;">
<div>
<div style="font-size:11px;color:#888;margin-bottom:2px;">${selfNode.id} ${selfNode.hostname}</div>
<div style="font-size:9px;color:#333;">FolderView3 integration · marks HOST1 ready</div>
</div>
<div class="vv-pt-actions">
<button class="vv-pt-action-btn run" onclick="vvPtLocalSetup(this)"
title="FolderView3, setup state — does not require HOST2">
Complete HOST1 Setup
</button>
<button class="vv-pt-action-btn run" onclick="vvPtLocalSetup(this)"
style="font-size:11px;white-space:nowrap;"
title="Does not require HOST2 to be online"> Complete Setup</button>
</div>`;
}
// ── Mirror onboard (non-owner) ─────────────────────────────────────────────
if (!isOwner) {
html += `<div style="padding:10px 12px;background:#0d0d0d;border:1px solid #1e1e1e;border-radius:4px;">
<div style="display:flex;align-items:center;justify-content:space-between;gap:10px;">
<div>
<div style="font-size:11px;color:#888;margin-bottom:2px;">Join partnership</div>
<div style="font-size:9px;color:#333;">SSH key setup + notify owner</div>
</div>
<button class="vv-pt-action-btn run" onclick="vvPtOnboard(this)"
${!hasPartner ? 'disabled style="opacity:.35;cursor:default;"' : ''}
style="font-size:11px;white-space:nowrap;"> Onboard</button>
</div>
</div>`;
}
// ── Bottom row ─────────────────────────────────────────────────────────────
html += `<div class="vv-pt-actions" style="padding-top:4px;">`;
if (!isOwner) {
html += `<button class="vv-pt-action-btn run" onclick="vvPtOnboard(this)"
${!hasPartner ? 'disabled style="opacity:.35;cursor:default;"' : 'title="Mirror onboard: SSH key setup + notify owner"'}>
Onboard (Mirror)
</button>`;
}
html += `<button class="vv-pt-action-btn warn" onclick="vvPtOffboard(this)"
${!cfg.enabled ? 'style="opacity:.35;cursor:default;" title="No active partnership"' : ''}>
Offboard
</button></div>`;
// ── Offboard + Transfer ────────────────────────────────────────────────────
html += `<div style="margin-top:12px;padding-top:10px;border-top:1px solid #181818;">`;
html += `<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap;">
<div>
<div style="font-size:11px;color:#666;">Offboard</div>
<div style="font-size:9px;color:#2a2a2a;">End partnership · revoke SSH · restore WebUIs</div>
</div>
<button class="vv-pt-action-btn warn" onclick="vvPtOffboard(this)"
style="font-size:11px;white-space:nowrap;"
${!cfg.enabled ? 'style="opacity:.35;cursor:default;" title="No active partnership"' : ''}>
Offboard
</button>
</div>`;
if (cfg.enabled && isOwner) {
const tok = (cfg.transfer_confirm || 'i-understand-this-transfers-ownership')
.replace(/'/g, "\\'");
html += `<div style="margin-top:12px;padding-top:10px;border-top:1px solid #1e1e1e;">
<div style="display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap;">
<span style="font-size:11px;color:#555;">Transfer ownership to the mirror promotes it to owner.</span>
<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'}
const tok = (cfg.transfer_confirm || 'i-understand-this-transfers-ownership').replace(/'/g, "\\'");
html += `<div style="margin-top:10px;padding-top:10px;border-top:1px dashed #181818;
display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap;">
<div>
<div style="font-size:11px;color:#666;">Transfer ownership</div>
<div style="font-size:9px;color:#2a2a2a;">Promotes mirror to owner · requires sustained health checks</div>
</div>
<button class="vv-pt-action-btn warn" onclick="vvPtTransfer(this, '${tok}')"
style="font-size:11px;white-space:nowrap;"
title="Runs partnership_transfer.sh — requires sustained health checks before switching">
Transfer
</button>
</div>
<div style="margin-top:6px;font-size:9px;color:#252525;font-family:monospace;padding:4px 8px;
background:#080808;border-radius:3px;">
bash Partnership/partnership_transfer.sh --confirm=${cfg.transfer_confirm || 'i-understand-this-transfers-ownership'}
</div>`;
}
html += '</div>'; // offboard+transfer section
return html;
}
// ── Main render ───────────────────────────────────────────────────────────────
function _render(data) {
const cfg = data.config || {};
const nodes = data.nodes || [];
const cfg = data.config || {};
const nodes = data.nodes || [];
const isOwner = nodes.some(n => n.is_me && n.is_owner);
// Config bar
document.getElementById('vv-pt-config-body').innerHTML = _renderConfig(cfg);
// Host settings — always shown upfront
const hostsCard = document.getElementById('vv-pt-hosts-card');
hostsCard.style.display = nodes.length ? '' : 'none';
document.getElementById('vv-pt-hosts-body').innerHTML = _renderHostSettings(nodes, isOwner);
document.getElementById('vv-pt-hosts-save').style.display = 'none';
// Offline countdown warning
_renderOfflineWarn(cfg);
@@ -946,6 +1228,12 @@ function _render(data) {
// Actions
document.getElementById('vv-pt-actions-body').innerHTML = _renderActions(nodes, cfg);
// Array settings cards above Actions — load once
_vvInitArrayCards();
// Settings panel — host1 only
document.getElementById('vv-pt-settings-card').style.display = isOwner ? '' : 'none';
// Highlight Onboard button when arriving from first-run wizard
if (new URLSearchParams(location.search).get('vv_onboard') === '1') {
const onboardBtn = document.querySelector('#vv-pt-actions-body .vv-pt-action-btn.run');