Files
Varaverk/Plugin/unraid/pages/partnership.php
T
Gmer4Lfe ecded4a8a7 Mesh Chat card on the Partnership page
Channel picker, unread filter, colour and font selectors, emoji, and the Varaverk mark faded behind it.
2026-08-17 16:43:22 -04:00

1483 lines
78 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>
<!-- Data moved between the servers -->
<div class="vv-card" id="vv-pt-xfer-card" style="margin-bottom:12px;">
<h3>Data Transferred</h3>
<div id="vv-pt-xfer-body" style="color:#444;font-size:12px;">Loading…</div>
</div>
<!-- Actions -->
<div class="vv-card" id="vv-pt-actions-card" style="margin-bottom:12px;">
<h3>Actions</h3>
<div id="vv-pt-actions-body" style="color:#444;font-size:12px;">Loading…</div>
</div>
<!-- Mesh chat — the partners' own channel -->
<div class="vv-card" id="vv-nc-card" style="margin-bottom:12px;position:relative;overflow:hidden;">
<!-- The logo sits behind the conversation rather than beside it: this is the one card on the
page that is about the people rather than the machines, and it can afford the room. -->
<img src="/plugins/varaverk/icons/varaverk.png" alt="" aria-hidden="true"
style="position:absolute;right:-30px;bottom:-40px;width:260px;opacity:.035;
pointer-events:none;filter:grayscale(1);">
<div style="position:relative;">
<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:8px;">
<h3 style="margin:0;">Mesh Chat</h3>
<select id="vv-nc-ch" class="vv-cf-sel" style="max-width:200px;font-size:11px;padding:3px 8px;"
onchange="vvNcSelect(this.value)"></select>
<label class="vv-ai-cb" style="font-size:10px;"
title="Show only what has arrived since you last looked. Unticked shows the whole conversation.">
<input type="checkbox" id="vv-nc-unread" onchange="vvNcRender()"> <span>Unread only</span>
</label>
<span id="vv-nc-status" style="margin-left:auto;font-size:9px;color:#3a3a3a;"></span>
</div>
<div id="vv-nc-log"
style="max-height:260px;overflow-y:auto;padding:6px 2px;font-size:12px;">
<div style="color:#444;font-size:11px;">Loading…</div>
</div>
<div style="display:flex;gap:6px;align-items:flex-start;margin-top:8px;flex-wrap:wrap;">
<textarea id="vv-nc-text" rows="2" placeholder="Message the mesh…"
onkeydown="if((event.ctrlKey||event.metaKey)&&event.key==='Enter')vvNcSend()"
style="flex:1 1 260px;min-width:0;background:#0d0d0d;border:1px solid #2a2a2a;
color:#ddd;border-radius:4px;padding:6px 8px;font-family:inherit;
font-size:12px;resize:vertical;"></textarea>
<div style="display:flex;flex-direction:column;gap:5px;">
<div style="display:flex;gap:5px;">
<select id="vv-nc-color" class="vv-cf-sel" style="font-size:10px;padding:3px 6px;" title="Message colour">
<option value="">Default</option>
<option value="#4caf50">Green</option>
<option value="#4a9eff">Blue</option>
<option value="#ff9800">Amber</option>
<option value="#ef5350">Red</option>
<option value="#ab7df6">Violet</option>
</select>
<select id="vv-nc-font" class="vv-cf-sel" style="font-size:10px;padding:3px 6px;" title="Message font">
<option value="">Default</option>
<option value="sans">Sans</option>
<option value="mono">Mono</option>
<option value="serif">Serif</option>
</select>
</div>
<div style="display:flex;gap:5px;align-items:center;">
<select id="vv-nc-emoji" class="vv-cf-sel" style="font-size:12px;padding:2px 6px;"
title="Insert an emoji" onchange="vvNcEmoji(this)">
<option value="">🙂</option>
<option value="👍">👍</option><option value="👋">👋</option><option value="✅">✅</option>
<option value="⚠️">⚠️</option><option value="🔥">🔥</option><option value="🎉">🎉</option>
<option value="🛠️">🛠️</option><option value="💾">💾</option><option value="🔑">🔑</option>
<option value="😀">😀</option><option value="😅">😅</option><option value="🤔">🤔</option>
</select>
<button class="vv-pt-action-btn run" style="font-size:11px;" onclick="vvNcSend()">Send</button>
</div>
</div>
</div>
<div style="margin-top:5px;font-size:9px;color:#3a3a3a;line-height:1.5;">
Encrypted over Tailscale between mesh nodes only — but stored as plain text on every
machine that receives it. Delete removes it from this machine, not from theirs.
</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-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 ───────────────────────────────────────────────────────
// ── 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>'; });
}
// Every partnership field, scalars and arrays together, in the order the conf declares them.
//
// These were two sections drawing the same endpoint and splitting it on whether a value happened
// to be a list: 25 scalars under "Partnership settings", 10 arrays under "Array settings", each
// with its own Save. Nothing about the subject is divided that way — PROVISION_SHARES and
// PARTNERSHIP_ENABLED are the same conversation — and the split meant looking in two places for
// one setting, with the arrays half easy to mistake for something belonging to another page.
//
// Both render into the same .vv-pt-set-field wrapper so a list and a value read as siblings; the
// only difference is a textarea instead of an input, sized to the content.
function vvPtRenderSettings(files) {
let html = '';
for (const f of files) {
const label = f.file === 'master.conf' ? 'master.conf — shared' : f.file + ' — this host';
const fields = f.groups.flatMap(g => g.fields);
if (!fields.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 fields) {
const isArr = fld.type !== 'scalar';
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)}${isArr ? ' <span style="color:#3a3a3a;font-weight:normal;">list</span>' : ''}</div>`;
if (fld.desc) html += `<div class="vv-pt-set-desc">${_vvSetEsc(fld.desc)}</div>`;
html += isArr
? `<textarea ${attrs} rows="${Math.min(16, (fld.value.match(/\n/g) || []).length + 2)}">${_vvSetEsc(fld.value)}</textarea>`
: `<input type="text" ${attrs} value="${_vvSetEsc(fld.value)}">`;
html += `</div>`;
}
html += `</div></div>`;
}
return html || '<div style="color:#555;font-size:12px;padding:8px 0;">No 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 ──────────────────────────────────────────────────────────
// ── Data transferred ──────────────────────────────────────────────────────────
//
// Tailscale's per-peer counters, so this is every byte across the link — rsync, SSH, the arr
// APIs, conf pushes, the Unraid API — not one tool's log of itself.
//
// A window says how far its samples actually reach. The sampler starts collecting the first
// minute this ships, so "30 days" means thirty days of history only after thirty days of it;
// until then the figure is real but covers less than the label, and saying which is the
// difference between a number and a claim.
function _vvBytes(b) {
if (!b) return '0 B';
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];
}
function _vvDur(s) {
if (!s) return '';
if (s < 3600) return Math.round(s / 60) + 'm';
if (s < 86400) return (s / 3600).toFixed(1) + 'h';
return (s / 86400).toFixed(1) + 'd';
}
function _renderXfer(x) {
const el = document.getElementById('vv-pt-xfer-body');
if (!el) return;
const peers = (x && x.peers) || {};
const names = Object.keys(peers);
if (!names.length) {
el.innerHTML = `<div style="font-size:11px;color:#444;">No samples yet — the mesh sampler
records once a minute. Figures appear within a minute or two.</div>`;
return;
}
let html = '';
for (const p of names) {
const d = peers[p];
const live = d.live;
html += `<div style="margin-bottom:6px;">
<div style="display:flex;justify-content:space-between;align-items:baseline;gap:8px;
padding-bottom:4px;border-bottom:1px solid #1b1b1b;">
<span style="font-size:11px;color:#888;font-family:monospace;">${vvEscHtml(p)}</span>
<span style="font-size:10px;color:${live && (live.tx_bps + live.rx_bps) > 1024 ? '#4caf50' : '#3a3a3a'};font-family:monospace;">
${live ? '↑ ' + _vvBytes(live.tx_bps) + '/s ↓ ' + _vvBytes(live.rx_bps) + '/s' : 'idle'}
</span>
</div>`;
const rows = [['Last 24 hours','24h'], ['Last 7 days','7d'], ['Last 30 days','30d']];
for (const [label, key] of rows) {
const w = (d.windows || {})[key] || {};
const total = (w.tx || 0) + (w.rx || 0);
// A window whose samples do not span it yet is labelled with what it does cover, rather
// than presenting a partial figure under a full-window heading.
const partial = w.covers && w.covers < ({'24h':86400,'7d':604800,'30d':2592000}[key]) * 0.9;
html += `<div style="display:flex;justify-content:space-between;align-items:baseline;gap:10px;
padding:4px 0;border-bottom:1px solid #151515;">
<span style="color:#666;font-size:11px;">${label}${partial
? ` <span style="color:#3a3a3a;font-size:9px;">only ${_vvDur(w.covers)} recorded</span>` : ''}</span>
<span style="font-family:monospace;font-size:11px;color:${total ? '#4caf50' : '#333'};">
${total ? '↑ ' + _vvBytes(w.tx) + ' ↓ ' + _vvBytes(w.rx) : 'nothing'}
</span></div>`;
}
const s = d.session || {};
html += `<div style="display:flex;justify-content:space-between;align-items:baseline;gap:10px;padding:4px 0;">
<span style="color:#666;font-size:11px;">Since tailscaled start</span>
<span style="font-family:monospace;font-size:11px;color:#7a7a7a;">
↑ ${_vvBytes(s.tx)} ↓ ${_vvBytes(s.rx)}</span>
</div></div>`;
}
el.innerHTML = html;
}
//
// 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.
// ── 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
_renderXfer(data.xfer || {});
// 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);
// 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
// ── Mesh chat ─────────────────────────────────────────────────────────────────
// Kept apart from vvPtLoad's 10s poll: that redraws the whole page, and a redraw mid-sentence
// would take the textarea with it.
let _vvNc = { ch: '', me: '', msgs: [], lastRead: 0, chans: [] };
function vvNcChans() {
fetch('/plugins/varaverk/api/node_chat.php?_=' + Date.now())
.then(r => r.json())
.then(d => {
if (!d.ok) return;
_vvNc.me = d.me; _vvNc.chans = d.channels || [];
const sel = document.getElementById('vv-nc-ch');
if (!sel) return;
const keep = _vvNc.ch || (_vvNc.chans[0] || {}).id || '';
sel.innerHTML = _vvNc.chans.map(c =>
`<option value="${vvEscAttr(c.id)}"${c.id === keep ? ' selected' : ''}>`
+ `${vvEscHtml(c.label)}${c.unread ? ' (' + c.unread + ')' : ''}</option>`).join('');
if (!_vvNc.ch) vvNcSelect(keep); else vvNcLoad();
})
.catch(() => {});
}
function vvNcSelect(ch) { _vvNc.ch = ch; vvNcLoad(); }
function vvNcLoad() {
if (!_vvNc.ch) return;
fetch('/plugins/varaverk/api/node_chat.php?channel=' + encodeURIComponent(_vvNc.ch) + '&_=' + Date.now())
.then(r => r.json())
.then(d => { if (d.ok) { _vvNc.msgs = d.messages || []; _vvNc.lastRead = d.last_read || 0; vvNcRender(); } })
.catch(() => {});
}
function vvNcRender() {
const box = document.getElementById('vv-nc-log');
if (!box) return;
const unreadOnly = document.getElementById('vv-nc-unread')?.checked;
let msgs = _vvNc.msgs;
if (unreadOnly) msgs = msgs.filter(m => (m.ts || 0) > _vvNc.lastRead && m.from !== _vvNc.me);
if (!msgs.length) {
box.innerHTML = `<div style="color:#3a3a3a;font-size:11px;padding:8px 0;">`
+ (unreadOnly ? 'Nothing new.' : 'No messages yet — say hello.') + `</div>`;
return;
}
const fonts = { mono: 'monospace', sans: 'system-ui,sans-serif', serif: 'Georgia,serif' };
box.innerHTML = msgs.map(m => {
const mine = m.from === _vvNc.me;
const col = (m.style && m.style.color) || (mine ? '#7a9a7a' : '#9aa');
const font = fonts[(m.style && m.style.font) || ''] || 'inherit';
const fresh = (m.ts || 0) > _vvNc.lastRead && !mine;
return `<div style="padding:4px 6px;margin-bottom:3px;border-left:2px solid ${fresh ? '#4caf50' : '#232323'};
background:${fresh ? 'rgba(76,175,80,.04)' : 'transparent'};border-radius:0 3px 3px 0;">
<div style="display:flex;align-items:baseline;gap:8px;">
<span style="font-size:10px;font-weight:600;color:${mine ? '#4caf50' : '#7a8fa6'};">${vvEscHtml(m.from)}</span>
<span style="font-size:9px;color:#333;">${new Date((m.ts || 0) * 1000).toLocaleString()}</span>
<span style="margin-left:auto;font-size:10px;color:#333;cursor:pointer;"
title="Delete on this machine only" onclick="vvNcDel('${vvEscAttr(m.id)}')">×</span>
</div>
<div style="color:${col};font-family:${font};white-space:pre-wrap;word-break:break-word;
font-size:12px;margin-top:1px;">${vvEscHtml(m.text)}</div>
</div>`;
}).join('');
box.scrollTop = box.scrollHeight;
// Marking read is what makes "unread only" mean anything next time, so it happens on view —
// but not while the filter is on, or the list would empty itself as you read it.
if (!unreadOnly && msgs.length) {
fetch('/plugins/varaverk/api/node_chat.php', {
method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({ csrf_token: typeof csrf_token !== 'undefined' ? csrf_token : '',
action: 'read', channel: _vvNc.ch })
}).catch(() => {});
}
}
function vvNcEmoji(sel) {
const t = document.getElementById('vv-nc-text');
if (t && sel.value) { t.value += sel.value; t.focus(); }
sel.selectedIndex = 0;
}
function vvNcSend() {
const t = document.getElementById('vv-nc-text');
const text = (t.value || '').trim();
if (!text) return;
const st = document.getElementById('vv-nc-status');
st.textContent = 'sending…';
fetch('/plugins/varaverk/api/node_chat.php', {
method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({
csrf_token: typeof csrf_token !== 'undefined' ? csrf_token : '',
action: 'send', channel: _vvNc.ch, text,
color: document.getElementById('vv-nc-color').value,
font: document.getElementById('vv-nc-font').value,
})
}).then(r => r.json()).then(d => {
if (!d.ok) { st.textContent = d.error || 'failed'; return; }
t.value = '';
// Queued is not failed. A partner that is asleep gets it on the next flush, and saying so is
// the difference between "it did not send" and "they are not awake".
st.textContent = d.queued ? `queued for ${d.queued} offline node(s)` : 'sent';
setTimeout(() => { st.textContent = ''; }, 4000);
vvNcLoad();
}).catch(e => { st.textContent = 'error'; });
}
function vvNcDel(id) {
fetch('/plugins/varaverk/api/node_chat.php', {
method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({ csrf_token: typeof csrf_token !== 'undefined' ? csrf_token : '',
action: 'delete', channel: _vvNc.ch, id })
}).then(() => vvNcLoad()).catch(() => {});
}
vvNcChans();
setInterval(vvNcChans, 15000);
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>