Add Rsync page, upgrade monitor rsync card, partnership overhaul, API key periodic check, docker watchdog manual-stop detection
This commit is contained in:
+464
-176
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user