Files
Varaverk/Plugin/unraid/pages/partnership.php
T
Gmer4Lfe e8ee5b052b Failover Coverage and Shared Services pickers on the Partnership page
Coverage edits FALLBACK_<me>_TIER1-4 directly — the array fallback.sh reads during an outage — rather than a parallel list that could drift from it.
2026-08-17 15:10:32 -04:00

1651 lines
86 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// Partnership tab. The mesh view — which nodes exist, whether they are reachable over
// Tailscale, their system summary, sync history, and the onboarding controls that deploy
// stacks to a partner.
//
// DESIGN PRINCIPLES
// Pure view — all node data comes from api/partnership.php; actions are separate endpoints.
// Reachability, SSH and system collection live in include/partnership.php.
//
// Reachability is probed on demand as well as polled, because "is my partner up right now"
// is the question this page exists to answer.
//
// OPERATIONAL SAFEGUARDS
// Every action that changes a partner is confirmed first — this page can deploy stacks and
// provision credentials on a machine 50 miles away, and there is no undo for that.
//
// Long-running operations go through api/run.php rather than blocking the page, so a deploy
// that takes minutes does not leave the browser hanging on a request.
//
// An unreachable partner degrades to its own card only; the rest of the mesh still renders.
//
// Values from remote nodes are escaped on render — this page displays strings originating
// on another machine.
//
// RENDERS
// Node cards with identity, reachability, system summary, sync history
// Onboarding and stack-deploy controls, API key provisioning, partnership settings
//
// DEPENDS ON
// api/partnership.php polled every 10s → include/partnership.php
// api/partnership_ping.php on-demand reachability probe
// api/partnership_settings.php settings writes
// api/create_api_key.php credential provisioning
// api/run.php long-running deploy operations
// api/confform.php inline conf edits
require_once dirname(__DIR__) . '/include/confui.php';
require_once dirname(__DIR__) . '/include/ai_chat.php';
// Same shared surface the AI tab and the Monitor row use, gated the same way. A partnership
// question — why a sync is behind, what a tier covers, whether the mirror is current — is asked
// while looking at this page, and sending someone to another tab to ask it loses the context
// they are looking at.
if (vv_ai_ui_on()) vv_ai_chat_assets();
?>
<style>
.vv-pt-grid { display:grid; gap:12px; }
.vv-pt-node { background:#161616; border:1px solid #2a2a2a; border-radius:6px; padding:12px; min-width:0; }
.vv-pt-node.me { border-color:#2a3a2a; }
.vv-pt-node-head { display:flex; justify-content:space-between; align-items:flex-start; margin-bottom:10px; }
.vv-pt-hostname { font-size:13px; font-weight:bold; color:#ccc; }
.vv-pt-slot { font-size:10px; color:#444; margin-top:1px; }
.vv-pt-tags { display:flex; gap:4px; flex-wrap:wrap; justify-content:flex-end; }
.vv-pt-tag { font-size:8px; padding:1px 5px; border-radius:3px; white-space:nowrap; }
.vv-pt-tag.me { background:#1a3a1a; color:#4caf50; }
.vv-pt-tag.owner { background:#1a2a3a; color:#4a9eff; }
.vv-pt-tag.mirror { background:#2a2a1a; color:#ff9800; }
.vv-pt-row { display:flex; justify-content:space-between; align-items:baseline; margin:3px 0; }
.vv-pt-lbl { font-size:11px; color:#555; }
.vv-pt-val { font-size:11px; color:#bbb; text-align:right; }
.vv-pt-sep { border:none; border-top:1px solid #222; margin:8px 0; }
.vv-pt-state { font-size:12px; font-weight:500; }
.vv-pt-dot { width:8px; height:8px; border-radius:50%; display:inline-block; flex-shrink:0; margin-right:5px; }
.vv-pt-actions { display:flex; gap:10px; flex-wrap:wrap; margin-top:4px; }
.vv-pt-action-btn { padding:5px 16px; border:none; border-radius:4px; cursor:pointer;
font-size:12px; font-weight:500; }
.vv-pt-action-btn.run { background:#1a3a1a; color:#6fcf97; border:1px solid #2e6b2e; }
.vv-pt-action-btn.warn { background:#3a1a1a; color:#f88; border:1px solid #6b2e2e; }
.vv-pt-action-btn.info { background:#1a2a3a; color:#7ab; border:1px solid #2e4a6b; }
.vv-pt-action-btn:disabled { opacity:0.4; cursor:default; }
.vv-pt-transfer-note { font-size:10px; color:#555; margin-top:6px; font-family:monospace; }
.vv-pt-onboard-highlight {
box-shadow: 0 0 0 2px #4a8, 0 0 12px rgba(100,200,120,.25) !important;
animation: vvOnboardPulse 1.4s ease-in-out 4;
}
@keyframes vvOnboardPulse {
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-pt-set-file { border:1px solid #222; border-radius:5px; margin-bottom:8px; overflow:hidden; }
.vv-pt-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-pt-set-file-hdr:hover { background:#1a1a1a; }
.vv-pt-set-file-body { padding:6px 10px 10px; }
.vv-pt-set-sec { border-top:1px solid #1c1c1c; }
.vv-pt-set-sec:first-child { border-top:none; }
.vv-pt-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-pt-set-sec-hdr:hover { color:#ccc; }
.vv-pt-set-sec-body { padding:4px 0 8px 8px; }
.vv-pt-set-chev { color:#444; font-size:10px; }
.vv-pt-set-field { margin-bottom:10px; }
.vv-pt-set-key { font-size:11px; color:#7ab; font-family:monospace; margin-bottom:2px; }
.vv-pt-set-desc { font-size:10px; color:#555; margin-bottom:3px; line-height:1.4; }
.vv-pt-set-input { width:100%; box-sizing:border-box; background:#0d0d0d; border:1px solid #2a2a2a;
color:#bbb; padding:5px 8px; border-radius:3px; font-family:monospace; font-size:11px; }
.vv-pt-set-input:focus { outline:none; border-color:#4a8; }
.vv-pt-set-input.changed { border-color:#ff9800; }
textarea.vv-pt-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;">
<span style="font-size:13px;font-weight:bold;color:#888;text-transform:uppercase;letter-spacing:.06em;">Partnership</span>
<span style="font-size:11px;color:#3a3a3a;" id="vv-pt-ts"></span>
</div>
<!-- Config bar -->
<div class="vv-card" style="margin-bottom:12px;" id="vv-pt-config-card">
<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>
<!-- Row 2 — what has moved, and what you can do about it -->
<div style="display:flex;gap:12px;margin-bottom:12px;align-items:stretch;flex-wrap:wrap;">
<div class="vv-card" id="vv-pt-xfer-card" style="flex:1 1 260px;min-width:0;display:flex;flex-direction:column;">
<h3>Data Transferred</h3>
<div id="vv-pt-xfer-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 340px;min-width:0;">
<h3>Actions</h3>
<div id="vv-pt-actions-body" style="color:#444;font-size:12px;">Loading…</div>
</div>
</div>
<!-- Row 3 — sync state, and the two lists that decide what the partner carries -->
<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>
<!-- Failover coverage: which of THIS host's containers the partner starts when this host is
down, and after how long. Backed by FALLBACK_<me>_TIER1-4 in this host's own conf, which
is what fallback.sh on the partner actually reads — so the picker edits the real list
rather than a second one that could disagree with it. -->
<div class="vv-card" id="vv-pt-cover-card" style="flex:1 1 300px;min-width:0;display:flex;flex-direction:column;">
<div style="display:flex;justify-content:space-between;align-items:center;">
<h3 style="margin:0;">Failover Coverage</h3>
<button id="vv-pt-cover-save" class="vv-pt-action-btn run"
onclick="vvPtSaveCover(this)" style="display:none;font-size:11px;">Save</button>
</div>
<div id="vv-pt-cover-body" style="flex:1;color:#444;font-size:12px;margin-top:8px;">Loading…</div>
</div>
<!-- Services deployed to the partner that are neither auth nor arr. These are pushed as XML
templates during onboard, so they run there rather than only starting during an outage. -->
<div class="vv-card" id="vv-pt-svc-card" style="flex:1 1 300px;min-width:0;display:flex;flex-direction:column;">
<div style="display:flex;justify-content:space-between;align-items:center;">
<h3 style="margin:0;">Shared Services</h3>
<button id="vv-pt-svc-save" class="vv-pt-action-btn run"
onclick="vvPtSaveSvc(this)" style="display:none;font-size:11px;">Save</button>
</div>
<div id="vv-pt-svc-body" style="flex:1;color:#444;font-size:12px;margin-top:8px;">Loading…</div>
</div>
</div>
<!-- Assistant -->
<?php if (vv_ai_ui_on()): ?>
<div class="vv-card" id="vv-pt-ai-card" style="margin-bottom:12px;">
<?php vv_ai_chat_markup('vv-pt-ai', [
'profile' => 'varaverk',
'compact' => true,
'title' => 'Assistant',
'height' => '300px',
'tall' => '500px',
'tallLarge' => '750px',
'empty' => 'Ask about the partnership — sync state, fallback tiers, what the mirror '
. 'is running, why a transfer moved nothing.',
]); ?>
</div>
<?php endif; ?>
<!-- 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, host and array configuration for this server.
</div>
<!-- One card, three sections. These were three separate top-level cards — "Host Settings",
"Array Settings" and "Settings" — stacked down the page between the things people
actually come here to look at. They are all the same kind of thing: conf this host owns.
Collapsed together they cost one line until opened. -->
<div id="vv-pt-settings-body" style="display:none;margin-top:10px;">
<div id="vv-pt-arrays-wrap" style="display:none;margin-bottom:16px;"></div>
<div id="vv-pt-settings-conf"></div>
</div>
</div>
<?php
// The conf sections this page is about, drawn by the shared renderer. They were reachable
// only from the Settings tab's catch-all, which is a long way to go for a setting named
// after the page you are already looking at.
vv_conf_ui_card('vv-cf-partnership', 'partnership', 'Partnership settings');
?>
<script>
// ── Toggle states — persist across 10s poll re-renders ────────────────────────
const _vvOnboarding = {}; // hostId → true when onboard steps are expanded
const _vvDeleteKeys = {}; // hostId → true when delete-keys panel is expanded
let _vvPtReload = null; // set by IIFE so top-level fns can trigger a refresh
// ── Public action functions — top-level so onclick= attributes can reach them ──
// ── Live progress for a launched job ──────────────────────────────────────────
// api/run.php returns {ok:true} the instant the job is *launched*, not when it finishes, and
// onboard then runs for minutes. Nothing surfaced that, so pressing Onboard looked like pressing
// nothing — and the natural response is to press it again, which is worse: the second run is
// refused by the script's own lock and its failure stat overwrites the live run's record.
//
// Polls the job's stat for liveness and its log for the step banner run_job.sh is capturing, and
// renders the last "━━━ Step … ━━━" line as the current activity.
function vvPtStartOnboard(hostId) {
_vvOnboarding[hostId] = true;
delete _vvDeleteKeys[hostId];
if (_vvPtReload) _vvPtReload();
}
function vvPtEndOnboard(hostId) {
delete _vvOnboarding[hostId];
if (_vvPtReload) _vvPtReload();
}
function vvPtShowDeleteKeys(hostId) {
_vvDeleteKeys[hostId] = true;
delete _vvOnboarding[hostId];
if (_vvPtReload) _vvPtReload();
}
function vvPtHideDeleteKeys(hostId) {
delete _vvDeleteKeys[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', { method: 'POST' })
.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 {
vvAlert('Failed: ' + (d.error ?? 'Unknown error'));
btn.disabled = false; btn.textContent = origText;
}
})
.catch(e => {
vvAlert('Error: ' + e);
btn.disabled = false; btn.textContent = origText;
});
}
async function vvPtPhase2(btn, hostId) {
if (!await vvConfirm(`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;
btn.textContent = '⟳ Starting…';
_vvPtRun('Partnership/partnership_onboard.sh', '--phase2-only')
.then(d => { if (!d.ok) vvAlert('Failed: ' + (d.error ?? 'Unknown error')); })
.catch(e => vvAlert('Error: ' + e))
.finally(() => setTimeout(() => { btn.disabled = false; btn.textContent = '▶ Run Phase 2 Manually'; }, 3000));
}
// One status line per button, inserted after it and reused across polls.
async function vvPtPushConf(btn, hostId) {
if (!await vvConfirm(`Push conf to ${hostId}?\n\nAssumes SSH key is already installed on ${hostId}.\nSkips key generation/install, goes straight to conf push + local setup.`)) return;
btn.disabled = true;
btn.textContent = '⟳ Pushing…';
_vvPtRun('Partnership/partnership_onboard.sh', '--phase1-only --skip-ssh')
.then(d => { if (!d.ok) vvAlert('Failed: ' + (d.error ?? 'Unknown error')); })
.catch(e => vvAlert('Error: ' + e))
.finally(() => setTimeout(() => { btn.disabled = false; btn.textContent = '▶ Push Conf (key installed)'; }, 4000));
}
async function vvPtLocalSetup(btn) {
if (!await vvConfirm('Complete HOST1 local setup?\n\nRuns FolderView3 integration and marks HOST1 as locally ready.\nDoes not require HOST2 to be online.')) return;
btn.disabled = true;
btn.textContent = '⟳ Running…';
_vvPtRun('Partnership/partnership_manager.sh', '--onboard --local-only')
.then(d => { if (!d.ok) vvAlert('Failed: ' + (d.error ?? 'Unknown error')); })
.catch(e => vvAlert('Error: ' + e))
.finally(() => setTimeout(() => { btn.disabled = false; btn.textContent = '▶ Complete HOST1 Setup'; }, 4000));
}
async function vvPtCancel(btn, hostId) {
if (!await vvConfirm(`Cancel onboard for ${hostId}?\n\nThis will remove keys in both directions and reset all phase state.\n\nContinue?`)) return;
btn.disabled = true;
btn.textContent = '⟳ Cancelling…';
_vvPtRun('Partnership/onboard_cancel.sh', '--direction=both')
.then(d => { if (!d.ok) vvAlert('Failed: ' + (d.error ?? 'Unknown error')); })
.catch(e => vvAlert('Error: ' + e))
.finally(() => setTimeout(() => { btn.disabled = false; btn.textContent = '✕ Cancel'; }, 4000));
}
async function vvPtDeleteH1(btn, hostId) {
if (!await vvConfirm(`Remove HOST1's key from ${hostId}?\n\n• Deletes local SSH key pair\n• Removes it from ${hostId}'s authorized_keys\n• Clears phase state`)) return;
btn.disabled = true; btn.textContent = '⟳ Removing…';
_vvPtRun('Partnership/onboard_cancel.sh', '--direction=h1')
.then(d => { if (!d.ok) vvAlert('Failed: ' + (d.error ?? 'Unknown error')); })
.catch(e => vvAlert('Error: ' + e))
.finally(() => setTimeout(() => { btn.disabled = false; btn.textContent = '✕ Remove HOST1 key'; }, 4000));
}
async function vvPtDeleteH2(btn, hostId) {
if (!await vvConfirm(`Remove ${hostId}'s key from HOST1?\n\n• Removes ${hostId}'s public key from HOST1's authorized_keys\n• ${hostId} will no longer be able to SSH into HOST1`)) return;
btn.disabled = true; btn.textContent = '⟳ Removing…';
_vvPtRun('Partnership/onboard_cancel.sh', '--direction=h2')
.then(d => { if (!d.ok) vvAlert('Failed: ' + (d.error ?? 'Unknown error')); })
.catch(e => vvAlert('Error: ' + e))
.finally(() => setTimeout(() => { btn.disabled = false; btn.textContent = `✕ Remove ${hostId} key`; }, 4000));
}
async function vvPtOffboard(btn) {
if (btn.style.opacity === '0.35' || btn.style.cursor === 'default') return;
if (!await vvConfirm('Run partnership_offboard.sh?\n\nThis will end the partnership, reconfigure WebUIs, and revoke SSH access.\n\nContinue?')) return;
btn.disabled = true;
btn.textContent = '⟳ Starting…';
_vvPtRun('Partnership/partnership_offboard.sh')
.then(d => { if (!d.ok) vvAlert('Failed: ' + (d.error ?? 'Unknown error')); })
.catch(e => vvAlert('Error: ' + e))
.finally(() => setTimeout(() => { btn.disabled = false; btn.textContent = '▶ Offboard'; }, 4000));
}
async function vvPtTransfer(btn, token) {
if (!await vvConfirm('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 (await vvPrompt('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) vvAlert('Failed: ' + (d.error ?? 'Unknown error')); })
.catch(e => vvAlert('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 { vvAlert('Failed: ' + (d.error ?? 'Unknown error')); el.style.pointerEvents = ''; el.style.opacity = ''; }
})
.catch(e => { vvAlert('Error: ' + e); el.style.pointerEvents = ''; el.style.opacity = ''; });
}
// ── Host Settings ─────────────────────────────────────────────────────────────
// Stop the background media seed. Safe to press: rsync.sh runs --inplace --partial, so this
// costs the file in flight and a later start resumes the share rather than restarting it.
// Phase 3 — the media seed, as its own phase rather than the tail of an onboard. Routed through
// partnership_onboard.sh --phase3-only rather than straight at media_seed.sh, because the phase
// is more than the transfer: it refuses when Phase 2 is unfinished, and it sets the gate posture
// the seed needs (Tier 1 open so rsync.sh will move anything, every Tier 2 gate closed so the
// scheduled orchestrators are not fighting it for the link for the next several weeks).
async function vvPtPhase3(btn, id) {
const label = btn.textContent;
if (!await vvConfirm(`Start Phase 3 — seed media to ${id}?\n\nEvery DAILY_SYNC_SHARES entry is pushed. A first seed of a full library runs for days, and the partnership is already live without it.\n\nTier 1 rsync is opened and every per-orchestrator gate is closed for the duration.`)) return;
btn.disabled = true;
btn.textContent = '⟳ Starting…';
_vvPtRun('Partnership/partnership_onboard.sh', '--phase3-only')
.then(d => { if (!d.ok) vvAlert('Phase 3 did not start: ' + (d.error ?? 'Unknown error')); })
.catch(e => vvAlert('Error: ' + e))
.finally(() => setTimeout(() => {
btn.disabled = false; btn.textContent = label;
if (typeof vvPtLoad === 'function') vvPtLoad();
}, 3000));
}
async function vvPtStopSeed(btn) {
if (!await vvConfirm('Stop the media seed?\n\nThe transfer resumes from where it stopped when restarted.')) return;
btn.disabled = true; btn.textContent = '⟳';
try {
const r = await fetch('/plugins/varaverk/api/media_seed.php', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({
csrf_token: typeof csrf_token !== 'undefined' ? csrf_token : '',
action: 'stop'
})
});
const d = await r.json();
if (!d.ok) { vvAlert('Stop failed: ' + (d.error ?? 'Unknown error')); btn.disabled = false; btn.textContent = '■ Stop'; return; }
btn.textContent = '✓';
if (typeof vvPtLoad === 'function') vvPtLoad();
} catch (e) {
btn.disabled = false; btn.textContent = '■ Stop';
vvAlert('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-pt-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-pt-set-file">
<div class="vv-pt-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-pt-set-chev">▸</span>
</div>
</div>
<div class="vv-pt-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>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">
<div class="vv-cl-title" style="margin:0;">Array settings</div>
<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-pt-set-input.changed');
const saveBtn = document.getElementById('vv-pt-arrays-save');
if (saveBtn) saveBtn.style.display = any ? '' : 'none';
}
async function vvPtSaveArrays(btn) {
const changedEls = document.querySelectorAll('#vv-pt-arrays-wrap .vv-pt-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 (!await vvConfirm(`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';
vvAlert('Save failed: ' + (d.error ?? 'Unknown error'));
}
})
.catch(e => { btn.disabled = false; btn.textContent = 'Save Changes'; vvAlert('Error: ' + e); });
}
// ── 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() {
// The conf rows render into their own sub-node, not the whole collapsible body — the body now
// also holds the host and array sections, and writing over it would delete them.
const body = document.getElementById('vv-pt-settings-conf');
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) {
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-pt-set-file">
<div class="vv-pt-set-file-hdr" onclick="vvPtToggleNode(this)">
<span>${_vvSetEsc(label)}</span><span class="vv-pt-set-chev">▾</span>
</div>
<div class="vv-pt-set-file-body">`;
for (const fld of scalarFields) {
const attrs = `class="vv-pt-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-pt-set-field">
<div class="vv-pt-set-key">${_vvSetEsc(fld.key)}</div>`;
if (fld.desc) html += `<div class="vv-pt-set-desc">${_vvSetEsc(fld.desc)}</div>`;
html += `<input type="text" ${attrs} value="${_vvSetEsc(fld.value)}"></div>`;
}
html += `</div></div>`;
}
return html || '<div style="color:#555;font-size:12px;padding:8px 0;">No scalar settings available.</div>';
}
function vvPtToggleNode(hdr) {
const body = hdr.nextElementSibling;
const chev = hdr.querySelector('.vv-pt-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-conf .vv-pt-set-input.changed');
document.getElementById('vv-pt-settings-save').style.display = any ? '' : 'none';
}
async function vvPtSaveSettings(btn) {
const changedEls = document.querySelectorAll('#vv-pt-settings-conf .vv-pt-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 (!await vvConfirm(`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';
vvAlert('Save failed: ' + (d.error ?? 'Unknown error'));
}
})
.catch(e => { btn.disabled = false; btn.textContent = 'Save Changes'; vvAlert('Error: ' + e); });
}
// ── Private page logic ─────────────────────────────────────────────────────────
(function() {
// Resolve the install path once, up front, and redraw when it lands. termCmd is built
// synchronously inside _renderActions, so without this the first paint would use the
// render-time fallback — right on a flash host, wrong on one that moved to appdata.
vvScriptsDir(<?= json_encode(SCRIPTS_DIR) ?>).then(() => { if (_vvPtReload) _vvPtReload(); });
// ── Helpers ───────────────────────────────────────────────────────────────────
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.
// The 1-day floor is what keeps a single missed 30-minute check from raising a banner: the
// counter ticks per interval, so one tick is half an hour, not a day.
if (!cfg.enabled || !days || days < 1) { el.style.display = 'none'; el.innerHTML = ''; return; }
const daysTxt = days < 10 ? days.toFixed(1) : Math.round(days);
const left = Math.max(0, Math.round(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 ${daysTxt} day${daysTxt==="1.0"||daysTxt===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>`;
}
// ── Data transferred ──────────────────────────────────────────────────────────
//
// A window with runs but no bytes says "no data moved", not "0.00 GB". They are different
// claims: rsync completing with nothing to send is the normal state for a mirror that is
// already in step, and printing a zero total invites the reading that the sync is broken.
// A window with no runs at all says so separately again.
function _renderXfer(x) {
const el = document.getElementById('vv-pt-xfer-body');
if (!el) return;
x = x || {};
const w = x.windows || {};
const fmt = (b) => {
if (b === null || b === undefined) return null;
const u = ['B','KB','MB','GB','TB'];
let i = 0, v = b;
while (v >= 1024 && i < u.length - 1) { v /= 1024; i++; }
return (i >= 3 ? v.toFixed(2) : Math.round(v)) + ' ' + u[i];
};
const row = (label, key) => {
const d = w[key] || {};
const bytes = fmt(d.bytes);
let val, col;
if (!d.runs) { val = 'no syncs ran'; col = '#333'; }
else if (bytes) { val = bytes; col = '#4caf50'; }
else { val = 'no data moved'; col = '#555'; }
const extra = d.runs
? `<span style="color:#333;font-size:9px;">${d.runs} run${d.runs===1?'':'s'}${d.failed?` · ${d.failed} failed`:''}</span>`
: '';
return `<div style="display:flex;justify-content:space-between;align-items:baseline;gap:10px;
padding:5px 0;border-bottom:1px solid #171717;">
<span style="color:#666;font-size:11px;">${label}</span>
<span style="display:flex;align-items:baseline;gap:8px;">
${extra}<span style="color:${col};font-size:12px;font-family:monospace;">${val}</span>
</span></div>`;
};
const live = x.live;
const liveHtml = live
? `<div style="display:flex;justify-content:space-between;align-items:baseline;gap:10px;
padding:5px 0;border-bottom:1px solid #171717;">
<span style="color:#666;font-size:11px;">Live</span>
<span style="color:#ff9800;font-size:12px;font-family:monospace;">⟳ ${live.running} transfer${live.running===1?'':'s'}${live.share?` · ${vvEscHtml(live.share)}`:''}</span>
</div>`
: `<div style="display:flex;justify-content:space-between;align-items:baseline;gap:10px;
padding:5px 0;border-bottom:1px solid #171717;">
<span style="color:#666;font-size:11px;">Live</span>
<span style="color:#333;font-size:12px;font-family:monospace;">idle</span>
</div>`;
el.innerHTML = liveHtml + row('Last 24 hours','24h') + row('Last 7 days','7d') + row('Last 30 days','30d')
+ `<div style="margin-top:7px;font-size:9px;color:#2e2e2e;line-height:1.5;">
Counted from ${vvEscHtml(x.epoch || '')} — before that rsync logged every transfer as
0 bytes, so those rows are excluded rather than summed into a false total.
</div>`;
}
// ── Failover coverage + shared services ───────────────────────────────────────
// Both cards read one endpoint and edit real conf arrays: the tiers fallback.sh consults during
// an outage, and the services stack onboard pushes. Loaded once — they are conf, not telemetry,
// and re-fetching them on the 10s poll would fight whatever the operator is mid-way through
// selecting.
let _vvLists = null, _vvListsLoaded = false;
function vvPtLoadLists() {
if (_vvListsLoaded) return;
_vvListsLoaded = true;
fetch('/plugins/varaverk/api/partnership_lists.php?_=' + Date.now())
.then(r => r.json())
.then(d => { if (d.ok) { _vvLists = d; _renderCover(); _renderSvc(); } })
.catch(() => {});
}
function _vvListRow(name, present, right) {
return `<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;
padding:3px 0;border-bottom:1px solid #151515;">
<span style="font-size:11px;color:${present ? '#888' : '#5a4a2a'};overflow:hidden;
text-overflow:ellipsis;white-space:nowrap;" title="${vvEscAttr(name)}${present ? '' : ' — not on this host'}">
${vvEscHtml(name)}${present ? '' : ' <span style="color:#5a4a2a;">·absent</span>'}</span>
${right}</div>`;
}
function _renderCover() {
const el = document.getElementById('vv-pt-cover-body');
if (!el || !_vvLists) return;
const cover = _vvLists.cover || {};
const have = new Set(_vvLists.containers || []);
const names = Object.keys(cover).sort((a,b) => (cover[a]-cover[b]) || a.localeCompare(b));
let html = `<div style="font-size:9px;color:#3a3a3a;margin-bottom:6px;line-height:1.5;">
What the partner starts when this host is down. Tier 1 is immediate; 24 wait for
HOST*_TIER&lt;n&gt;_DELAY.</div>`;
html += names.length
? names.map(n => _vvListRow(n, have.has(n),
`<select class="vv-pt-cover-sel" data-name="${vvEscAttr(n)}" onchange="vvPtCoverChanged()"
style="font-size:10px;background:#0d0d0d;color:#aaa;border:1px solid #2a2a2a;border-radius:2px;">
${[1,2,3,4].map(t => `<option value="${t}"${cover[n]===t?' selected':''}>T${t}</option>`).join('')}
<option value="0">remove</option>
</select>`)).join('')
: `<div style="font-size:11px;color:#444;padding:6px 0;">Nothing covered.</div>`;
const opts = (_vvLists.containers || []).filter(c => !(c in cover));
html += `<div style="margin-top:8px;display:flex;gap:6px;align-items:center;">
<select id="vv-pt-cover-add" style="flex:1;min-width:0;font-size:10px;background:#0d0d0d;color:#aaa;
border:1px solid #2a2a2a;border-radius:2px;">
<option value="">+ add container…</option>
${opts.map(c => `<option value="${vvEscAttr(c)}">${vvEscHtml(c)}</option>`).join('')}
</select>
<button class="vv-pt-action-btn info" style="font-size:10px;" onclick="vvPtCoverAdd()">Add</button>
</div>`;
el.innerHTML = html;
}
function vvPtCoverAdd() {
const sel = document.getElementById('vv-pt-cover-add');
if (!sel || !sel.value || !_vvLists) return;
_vvLists.cover = _vvLists.cover || {};
_vvLists.cover[sel.value] = 1;
_renderCover();
vvPtCoverChanged();
}
function vvPtCoverChanged() {
document.getElementById('vv-pt-cover-save').style.display = '';
}
async function vvPtSaveCover(btn) {
const map = {};
document.querySelectorAll('.vv-pt-cover-sel').forEach(s => {
const t = parseInt(s.value, 10);
if (t >= 1 && t <= 4) map[s.dataset.name] = t; // 0 = remove, simply not sent
});
btn.disabled = true; btn.textContent = '⟳';
try {
const r = await fetch('/plugins/varaverk/api/partnership_lists.php', {
method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({
csrf_token: typeof csrf_token !== 'undefined' ? csrf_token : '',
action: 'cover', tiers: JSON.stringify(map),
})
});
const d = await r.json();
if (!d.ok) { vvAlert('Save failed: ' + (d.error ?? 'Unknown error')); btn.disabled = false; btn.textContent = 'Save'; return; }
btn.textContent = '✓'; _vvListsLoaded = false; vvPtLoadLists();
setTimeout(() => { btn.disabled = false; btn.textContent = 'Save'; btn.style.display = 'none'; }, 1500);
} catch (e) { btn.disabled = false; btn.textContent = 'Save'; vvAlert('Error: ' + e); }
}
function _renderSvc() {
const el = document.getElementById('vv-pt-svc-body');
if (!el || !_vvLists) return;
const cur = _vvLists.services || [];
const have = new Set(_vvLists.containers || []);
const xml = new Set(_vvLists.have_xml || []);
let html = `<div style="font-size:9px;color:#3a3a3a;margin-bottom:6px;line-height:1.5;">
Non-auth, non-arr services deployed to the partner at onboard and run there continuously.
Needs a my-&lt;name&gt;.xml template to push.</div>`;
html += cur.length
? cur.map(n => _vvListRow(n, have.has(n),
`<button class="vv-pt-action-btn warn" style="font-size:9px;opacity:.6;"
onclick="vvPtSvcRemove('${vvEscAttr(n)}')">✕</button>`)).join('')
: `<div style="font-size:11px;color:#444;padding:6px 0;">No shared services.</div>`;
const opts = (_vvLists.containers || []).filter(c => !cur.includes(c) && xml.has(c));
html += `<div style="margin-top:8px;display:flex;gap:6px;align-items:center;">
<select id="vv-pt-svc-add" style="flex:1;min-width:0;font-size:10px;background:#0d0d0d;color:#aaa;
border:1px solid #2a2a2a;border-radius:2px;">
<option value="">+ add service…</option>
${opts.map(c => `<option value="${vvEscAttr(c)}">${vvEscHtml(c)}</option>`).join('')}
</select>
<button class="vv-pt-action-btn info" style="font-size:10px;" onclick="vvPtSvcAdd()">Add</button>
</div>`;
el.innerHTML = html;
}
function vvPtSvcAdd() {
const sel = document.getElementById('vv-pt-svc-add');
if (!sel || !sel.value || !_vvLists) return;
_vvLists.services = (_vvLists.services || []).concat([sel.value]);
_renderSvc();
document.getElementById('vv-pt-svc-save').style.display = '';
}
function vvPtSvcRemove(name) {
if (!_vvLists) return;
_vvLists.services = (_vvLists.services || []).filter(n => n !== name);
_renderSvc();
document.getElementById('vv-pt-svc-save').style.display = '';
}
async function vvPtSaveSvc(btn) {
btn.disabled = true; btn.textContent = '⟳';
try {
const r = await fetch('/plugins/varaverk/api/partnership_lists.php', {
method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({
csrf_token: typeof csrf_token !== 'undefined' ? csrf_token : '',
action: 'services', stack: JSON.stringify(_vvLists.services || []),
})
});
const d = await r.json();
if (!d.ok) { vvAlert('Save failed: ' + (d.error ?? 'Unknown error')); btn.disabled = false; btn.textContent = 'Save'; return; }
btn.textContent = '✓'; _vvListsLoaded = false; vvPtLoadLists();
setTimeout(() => { btn.disabled = false; btn.textContent = 'Save'; btn.style.display = 'none'; }, 1500);
} catch (e) { btn.disabled = false; btn.textContent = 'Save'; vvAlert('Error: ' + e); }
}
function _renderSync(sync) {
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' ? '✓' : 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;
// 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: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
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 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))
: 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;
}
// ── Config bar ────────────────────────────────────────────────────────────────
function _renderConfig(cfg) {
const dot = cfg.enabled ? '#4caf50' : '#555';
const label = cfg.enabled
? `<span style="color:#4caf50;">enabled</span>`
: `<span style="color:#555;">disabled</span>`;
const owner = cfg.owner_host || '—';
const items = [
label,
`Owner: <span style="color:#ccc;">${owner}</span>`,
`Sync: ${cfg.sync_min}min`,
`Grace: ${cfg.grace_hours}h`,
`Offline threshold: ${cfg.offline_threshold}d`,
cfg.remove_tailscale ? 'Tailscale removal: on' : 'Tailscale removal: off',
cfg.tailscale_configured ? '' : '<span style="color:#ff9800;">⚠ Tailscale API key not set</span>',
].filter(Boolean);
return `<div style="display:flex;align-items:center;gap:6px;flex-wrap:wrap;">
<span class="vv-pt-dot" style="background:${dot}"></span>
${items.map(i => `<span style="font-size:11px;color:#666;">${i}</span>`)
.join('<span style="color:#333;">·</span>')}
</div>`;
}
// ── Node card ─────────────────────────────────────────────────────────────────
const fbColors = {
NORMAL: '#4caf50',
FAILOVER: '#f44336',
NO_INTERNET: '#ff9800',
DARK: '#9e9e9e',
UNKNOWN: '#444',
};
const fbLabels = {
NORMAL: '✓ Nominal',
FAILOVER: '⚠ Failover',
NO_INTERNET: '⚡ No internet',
DARK: '◌ Dark mode',
UNKNOWN: '— Unknown',
};
const ptStates = {
ACTIVE: ['#4caf50', 'Active'],
INACTIVE: ['#555', 'Inactive'],
PENDING: ['#ff9800', 'Pending'],
};
function _nodeCard(node) {
const tsOnline = node.ts_online;
const dotCol = tsOnline === null ? '#555' : tsOnline ? '#4caf50' : '#f44336';
const dotTip = tsOnline === null ? 'unknown' : tsOnline ? (node.ts_active ? 'active' : 'idle') : 'offline';
const tags = [
node.is_me ? '<span class="vv-pt-tag me">US</span>' : '',
node.is_owner ? '<span class="vv-pt-tag owner">OWNER</span>' : '<span class="vv-pt-tag mirror">MIRROR</span>',
].join('');
// System info
const sys = node.system || {};
const ver = sys.unraid_version || '—';
const uptime = sys.uptime_sec ? _uptime(sys.uptime_sec) : '—';
// Fallback
const fb = (node.fallback || 'UNKNOWN').toUpperCase();
const fbCol = fbColors[fb] || '#444';
const fbLbl = fbLabels[fb] || fb;
// Partnership DB
const pt = node.partnership || {};
const ptState = (pt.state || '').toUpperCase();
const [ptCol, ptLbl] = ptStates[ptState] || ['#444', ptState || '—'];
const ptUpdated = pt.updated ? _relTime(parseInt(pt.updated)) : null;
// Onboard phase badge (remote nodes) or local-done badge (self)
const phase = node.onboard_phase;
// The self badge keys off partnership state, not off local_done alone.
//
// local_done means this host ran its own local partnership setup, and stays true after a
// cancel or an offboard — correctly, because that setup was not undone. But the badge read
// "waiting for partner" from that flag alone, so cancelling an onboard left the card claiming
// a handshake was still in flight when the keys had just been deleted and there was nothing
// to wait for.
//
// ACTIVE needs no badge at all: the card's own partnership row already says so, and repeating
// it as a "setup complete" note reads like something still pending.
const phaseHtml = node.is_me
? (!node.local_done || ptState === 'ACTIVE' ? ''
: `<div style="font-size:10px;color:#4caf50;margin-bottom:6px;padding:2px 6px;background:#0a1a0a;border:1px solid #2a5a2a;border-radius:3px;">✓ Local setup complete · no partner onboarded</div>`)
: (phase === 2 ? '' :
phase === 1 ? `<div style="font-size:10px;color:#ff9800;margin-bottom:6px;padding:2px 6px;background:#1a1200;border:1px solid #3a2800;border-radius:3px;">⏳ SSH ready · awaiting onboard</div>` :
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>`)
// Remotes get the same row, minus the buttons — the key belongs to that host and is renewed
// there. Two facts, kept apart: the key is configured (read from the partner's conf in the
// RAM cache) and the key is answering (that host's API served this page's stats). A key that
// is set but no longer authenticates is the interesting case, and merging them would hide it.
: (node.api_key_set
? `<div style="font-size:10px;margin-bottom:6px;padding:2px 6px;background:#0a1a0a;
border:1px solid #1a3a1a;border-radius:3px;display:flex;justify-content:space-between;
align-items:center;gap:8px;flex-wrap:wrap;">
<span style="color:#3a7a3a;">🔑 API key: <span style="color:#4caf50;">${node.api_key_preview}</span></span>
<span style="color:${node.api_live ? '#4caf50' : '#ff9800'};font-size:9px;white-space:nowrap;">
${node.api_live ? '● live' : '○ set, not answering'}</span>
</div>`
: `<div style="font-size:10px;color:#8a6a2a;margin-bottom:6px;padding:2px 6px;background:#1a1200;
border:1px solid #3a2800;border-radius:3px;">
⚡ No API key on this host — create it there, then Save Conf to share it
</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;">
<span class="vv-pt-dot" style="background:${dotCol}"></span>
<span style="font-size:11px;color:${dotCol};">${dotTip}</span>`;
if (node.ts_ip) body += `<span style="font-size:10px;color:#444;margin-left:4px;">${node.ts_ip}</span>`;
body += `</div>`;
// System
body += _row('unRAID', ver);
body += _row('Uptime', uptime);
body += `<hr class="vv-pt-sep">`;
// States
body += `<div class="vv-pt-row">
<span class="vv-pt-lbl">Fallback</span>
<span class="vv-pt-state" style="color:${fbCol};">${fbLbl}</span>
</div>`;
body += `<div class="vv-pt-row">
<span class="vv-pt-lbl">Partnership</span>
<span class="vv-pt-state" style="color:${ptCol};">${ptLbl}${ptUpdated ? `<span style="font-size:9px;color:#444;margin-left:4px;">${ptUpdated}</span>` : ''}</span>
</div>`;
if (pt.reason) {
body += `<div style="font-size:10px;color:#555;margin-top:3px;text-align:right;">${pt.reason}</div>`;
}
return `<div class="vv-pt-node${node.is_me ? ' me' : ''}">
<div class="vv-pt-node-head">
<div>
<div class="vv-pt-hostname">${node.hostname}</div>
<div class="vv-pt-slot">${node.id}</div>
</div>
<div class="vv-pt-tags">${tags}</div>
</div>
${body}
</div>`;
}
// ── Actions ───────────────────────────────────────────────────────────────────
function _renderActions(nodes, cfg) {
const isOwner = nodes.some(n => n.is_me && n.is_owner);
const selfNode = nodes.find(n => n.is_me);
const remotes = nodes.filter(n => !n.is_me);
const hasPartner = remotes.some(n => n.hostname);
const termBase = `https://${window.location.hostname}/webterminal/ttyd/`;
// _vvScriptsDir is filled by the live probe below and falls back to the render-time value.
// Both matter: a host in appdata must be handed its appdata path, and this panel is rendered
// on whichever side is the mirror — so it cannot assume either layout. The owner and the
// mirror are independently either flash or appdata.
const termCmd = `bash ${_vvScriptsDir || <?= json_encode(SCRIPTS_DIR) ?>}/Partnership/partnership_onboard.sh --phase1-only`;
let html = '';
// ── No partner ─────────────────────────────────────────────────────────────
if (!hasPartner && isOwner) {
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, idx) => {
const phase = remote.onboard_phase ?? 0;
const isOnboarding = !!_vvOnboarding[remote.id];
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;
// Left border colour by phase
const borderCol = phase === 2 ? '#2a5a2a'
: phase === 1 ? '#4a3000'
: isOnboarding ? '#0e2a4a' : '#222';
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 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:9px;padding:2px 7px;" title="SSH echo round-trip">⇄ ping</button>
</div>
</div>`;
html += `<div style="padding:10px 12px;">`;
// ── Phase 0: not provisioned ───────────────────────────────────────────
if (phase === 0) {
if (isOnboarding) {
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" class="localURL"
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="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>
<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;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: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: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;">
${isOwner ? `<button class="vv-pt-action-btn info" onclick="vvPtPhase2(this,'${remote.id}')"
style="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>`;
// Media seed strip. The seed is dispatched detached by onboard Step 13 and runs for
// days, so without this the page would show a finished onboard and no sign that
// terabytes are still moving underneath it.
// The toggle is read first and the record second. media_seed.sh exits 0 when
// MEDIA_SEED_ENABLED is false, so the record can read "ok" for a seed that never
// moved a byte — showing that as "Seed complete" would be exactly the kind of
// outcome-from-a-toggle claim this codebase keeps having to unpick.
// Phase 3 — the media seed. Deliberately not rendered at all when MEDIA_SEED_ENABLED is
// false: with seeding off the model is two phases, and a greyed-out third one would
// invite the question of how to reach it. The toggle removes the phase, it does not
// disable a button.
//
// The toggle is also read before the job record. media_seed.sh exits 0 when seeding is
// off, so the record can read "ok" for a run that moved nothing.
// Owner-side only. The seed pushes THIS host's DAILY_SYNC_SHARES to the partner, so on
// the mirror the whole strip describes an action it cannot take against its own owner.
const ms = remote.media_seed || {};
if (isOwner && ms.enabled !== false) {
const seedRunning = ms.status === 'running';
const seedDone = ms.phase3_done === true;
let seedColor, seedLabel;
if (seedRunning) { seedColor = '#ff9800'; seedLabel = 'Phase 3 — seeding'; }
else if (seedDone) { seedColor = '#4caf50'; seedLabel = 'Phase 3 complete'; }
else if (ms.status === 'stopped') { seedColor = '#666'; seedLabel = 'Phase 3 stopped'; }
else if (ms.status === 'ok') { seedColor = '#ff9800'; seedLabel = 'Phase 3 partial'; }
else if (ms.status) { seedColor = '#f44336'; seedLabel = 'Phase 3 failed'; }
else { seedColor = '#555'; seedLabel = 'Phase 3 not started'; }
html += `<div style="margin-top:9px;padding-top:8px;border-top:1px solid #181818;">
<div style="display:flex;align-items:center;gap:7px;flex-wrap:wrap;">
<span style="font-size:9px;font-weight:600;color:${seedColor};">${seedLabel}</span>
${ms.share ? `<span style="font-size:9px;color:#444;font-family:monospace;">${vvEscHtml(ms.share)}</span>` : ''}
${seedRunning
? `<button class="vv-pt-action-btn warn" onclick="vvPtStopSeed(this)"
style="font-size:9px;opacity:.55;margin-left:auto;">■ Stop</button>`
: `<button class="vv-pt-action-btn info" onclick="vvPtPhase3(this,'${remote.id}')"
style="font-size:9px;opacity:.55;margin-left:auto;"
title="Seed every DAILY_SYNC_SHARES entry to ${remote.id}">${seedDone ? '↻ Re-seed' : '▶ Phase 3'}</button>`}
</div>
${ms.line ? `<div style="margin-top:3px;font-size:9px;color:#2e2e2e;font-family:monospace;
white-space:nowrap;overflow:hidden;text-overflow:ellipsis;"
>${vvEscHtml(ms.line)}</div>` : ''}
</div>`;
}
}
// ── Mesh health banner ─────────────────────────────────────────────────
// Green only when every leg holds. When one does not, the banner names which — "mesh
// degraded" on its own sends someone hunting through four subsystems, and the answer is
// already known here.
const mesh = remote.mesh;
if (mesh) {
const legs = [
['Tailscale', mesh.tailscale],
['SSH', mesh.ssh],
['Partnership', mesh.partnership],
['Onboarded', mesh.onboarded],
];
const bad = legs.filter(l => !l[1]).map(l => l[0]);
const ok = bad.length === 0;
html += `<div style="margin-top:9px;padding:7px 10px;border-radius:3px;
background:${ok ? '#0a1a0a' : '#1a1200'};border:1px solid ${ok ? '#1a4a1a' : '#3a2800'};
display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
<span style="font-size:11px;color:${ok ? '#4caf50' : '#ff9800'};font-weight:600;">
${ok ? '✓ Mesh connected — all systems good' : '⚠ Mesh degraded'}</span>
<span style="font-size:9px;color:${ok ? '#2e6b2e' : '#8a6a2a'};font-family:monospace;">
${ok ? legs.map(l => l[0]).join(' · ') : 'failing: ' + bad.join(', ')}</span>
</div>`;
}
// ── Delete Keys panel ──────────────────────────────────────────────────
if (isDeleting) {
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: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:10px;">✕ HOST1 key</button>
</div>
<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;">
<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:10px;">✕ ${remote.id} key</button>
</div>
</div>
<div style="margin-top:8px;">
<button class="vv-pt-action-btn info" onclick="vvPtHideDeleteKeys('${remote.id}')"
style="font-size:10px;opacity:.6;">✓ Done</button>
</div>
</div>`;
}
html += `</div></div>`; // padding + card
});
}
// ── HOST1 local setup ──────────────────────────────────────────────────────
if (isOwner && selfNode && !selfNode.local_done && remotes.some(r => (r.onboard_phase ?? 0) >= 1)) {
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>
<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) ─────────────────────────────────────────────
// Rendered by vvRenderMirrorOnboard() in Varaverk.page so the wizard shows the identical
// panel. Only one pages/*.php loads per request, so a copy here could not be reached from
// setup.php — and two copies of a panel this fiddly would drift.
if (!isOwner) {
const ownerNode = remotes.find(n => n.is_owner) || remotes[0];
html += vvRenderMirrorOnboard({
ownerName: ownerNode?.hostname,
termBase: termBase,
termCmd: termCmd,
phase: selfNode?.onboard_phase ?? 0,
hasPartner: hasPartner,
});
}
// ── 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: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 isOwner = nodes.some(n => n.is_me && n.is_owner);
// Config bar
document.getElementById('vv-pt-config-body').innerHTML = _renderConfig(cfg);
// Offline countdown warning
_renderOfflineWarn(cfg);
// Mirror sync health
vvPtLoadLists();
_renderXfer(data.xfer || {});
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');
grid.style.gridTemplateColumns = `repeat(${cols}, 1fr)`;
grid.innerHTML = nodes.length
? nodes.map(_nodeCard).join('')
: '<div style="color:#444;font-size:12px;padding:12px 0;">No hosts found in master.conf.</div>';
// 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');
if (onboardBtn) {
onboardBtn.classList.add('vv-pt-onboard-highlight');
setTimeout(() => onboardBtn.scrollIntoView({behavior: 'smooth', block: 'center'}), 200);
}
}
// Timestamp
const ts = data.ts ? new Date(data.ts * 1000).toLocaleString([],
{month:'numeric',day:'numeric',year:'numeric',hour:'2-digit',minute:'2-digit',second:'2-digit'}) : '';
document.getElementById('vv-pt-ts').textContent = ts ? 'Updated: ' + ts : '';
}
// ── Poll ──────────────────────────────────────────────────────────────────────
function vvPtLoad() {
fetch('/plugins/varaverk/api/partnership.php')
.then(r => r.json())
.then(_render)
.catch(() => {});
}
_vvPtReload = vvPtLoad; // expose to top-level toggle fns
vvPtLoad();
setInterval(vvPtLoad, 10000);
// ── Assistant ─────────────────────────────────────────────────────────────────
// resumeProfile pins this card to General Chat. Without it the surface resumes whatever thread
// was last touched anywhere in the UI, so opening the Partnership tab could land mid-way through
// an unrelated Scheduler conversation, on a card with no picker to get back from.
if (typeof VvAiChat === 'function' && document.getElementById('vv-pt-ai-chat')) {
VvAiChat({
prefix: 'vv-pt-ai',
// The Varaverk assistant, not general chat: every question asked on this page is about this
// installation, and the strict profile is the one that can answer from it.
profile: 'varaverk',
resumeProfile: 'varaverk',
empty: 'Ask about the partnership — sync state, fallback tiers, what the mirror is running, '
+ 'why a transfer moved nothing.',
});
}
})();
</script>