Style validation moves to one shared function so the sender and receiver cannot drift on what a style is.
1591 lines
84 KiB
PHP
1591 lines
84 KiB
PHP
<?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>
|
||
<label class="vv-ai-cb" style="font-size:10px;"
|
||
title="Show the machine's hostname instead of its slot — unRAID-Jayred36 rather than HOST2.">
|
||
<input type="checkbox" id="vv-nc-names" onchange="vvNcPref('names');vvNcRender()"> <span>Hostnames</span>
|
||
</label>
|
||
<label class="vv-ai-cb" style="font-size:10px;"
|
||
title="Follow the newest message. Unticks when you scroll up, re-ticks at the bottom.">
|
||
<input type="checkbox" id="vv-nc-follow" checked onchange="vvNcPref('follow')"> <span>Auto Scroll</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;align-items:center;">
|
||
<!-- One button opens the formatting row, so the common case — type and send — is not
|
||
fronted by six controls nobody changes most of the time. -->
|
||
<button class="vv-pt-action-btn info" id="vv-nc-fmt-btn" style="font-size:10px;"
|
||
title="Text formatting" onclick="vvNcToggleFmt()">Aa ▸</button>
|
||
<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>
|
||
|
||
<!-- Formatting row, hidden until asked for. Choices persist per browser so a house style
|
||
does not have to be re-picked every message. -->
|
||
<div id="vv-nc-fmt" style="display:none;margin-top:6px;gap:5px;flex-wrap:wrap;align-items:center;">
|
||
<select id="vv-nc-color" class="vv-cf-sel" style="font-size:10px;padding:3px 6px;" title="Colour"
|
||
onchange="vvNcPref('color')">
|
||
<option value="">Colour</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>
|
||
<option value="#bdbdbd">Grey</option>
|
||
</select>
|
||
<select id="vv-nc-font" class="vv-cf-sel" style="font-size:10px;padding:3px 6px;" title="Font"
|
||
onchange="vvNcPref('font')">
|
||
<option value="">Font</option>
|
||
<option value="sans">Sans</option>
|
||
<option value="mono">Mono</option>
|
||
<option value="serif">Serif</option>
|
||
</select>
|
||
<select id="vv-nc-size" class="vv-cf-sel" style="font-size:10px;padding:3px 6px;" title="Size"
|
||
onchange="vvNcPref('size')">
|
||
<option value="">Size</option>
|
||
<option value="sm">Small</option>
|
||
<option value="lg">Large</option>
|
||
</select>
|
||
<button class="vv-btn-sm" id="vv-nc-bold" onclick="vvNcTog('bold')"
|
||
style="font-weight:700;min-width:26px;">B</button>
|
||
<button class="vv-btn-sm" id="vv-nc-italic" onclick="vvNcTog('italic')"
|
||
style="font-style:italic;min-width:26px;">I</button>
|
||
<button class="vv-btn-sm" id="vv-nc-underline" onclick="vvNcTog('underline')"
|
||
style="text-decoration:underline;min-width:26px;">U</button>
|
||
<span id="vv-nc-preview" style="margin-left:6px;font-size:11px;color:#444;">preview</span>
|
||
</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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||
}
|
||
|
||
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: [], hosts: {},
|
||
fmt: { bold: false, italic: false, underline: false } };
|
||
|
||
// Formatting and display choices are per browser, not per message — picking a house style once
|
||
// and having it forgotten on reload is the same complaint the web-search tick had.
|
||
const _VV_NC_PREF = 'vvNcPrefs';
|
||
function _vvNcLoadPrefs() {
|
||
let p = {};
|
||
try { p = JSON.parse(localStorage.getItem(_VV_NC_PREF) || '{}') || {}; } catch (_) {}
|
||
for (const k of ['color', 'font', 'size']) {
|
||
const el = document.getElementById('vv-nc-' + k);
|
||
if (el && p[k] != null) el.value = p[k];
|
||
}
|
||
for (const k of ['names', 'follow']) {
|
||
const el = document.getElementById('vv-nc-' + k);
|
||
if (el && p[k] != null) el.checked = !!p[k];
|
||
}
|
||
_vvNc.fmt = Object.assign(_vvNc.fmt, p.fmt || {});
|
||
_vvNcPaintFmt();
|
||
}
|
||
function vvNcPref() {
|
||
const p = { fmt: _vvNc.fmt };
|
||
for (const k of ['color', 'font', 'size']) p[k] = document.getElementById('vv-nc-' + k)?.value ?? '';
|
||
for (const k of ['names', 'follow']) p[k] = !!document.getElementById('vv-nc-' + k)?.checked;
|
||
try { localStorage.setItem(_VV_NC_PREF, JSON.stringify(p)); } catch (_) {}
|
||
_vvNcPaintFmt();
|
||
}
|
||
function vvNcTog(k) { _vvNc.fmt[k] = !_vvNc.fmt[k]; vvNcPref(); }
|
||
|
||
// The preview is the only way to see what B/I/U plus a colour actually look like together
|
||
// before committing them to a message everyone else keeps a copy of.
|
||
function _vvNcPaintFmt() {
|
||
for (const k of ['bold', 'italic', 'underline']) {
|
||
document.getElementById('vv-nc-' + k)?.classList.toggle('active', !!_vvNc.fmt[k]);
|
||
}
|
||
const pv = document.getElementById('vv-nc-preview');
|
||
if (!pv) return;
|
||
const s = _vvNcStyle({ style: {
|
||
color: document.getElementById('vv-nc-color')?.value || '',
|
||
font: document.getElementById('vv-nc-font')?.value || '',
|
||
size: document.getElementById('vv-nc-size')?.value || '',
|
||
bold: _vvNc.fmt.bold, italic: _vvNc.fmt.italic, underline: _vvNc.fmt.underline,
|
||
}}, false);
|
||
pv.setAttribute('style', 'margin-left:6px;' + s);
|
||
}
|
||
|
||
function vvNcToggleFmt() {
|
||
const row = document.getElementById('vv-nc-fmt');
|
||
const btn = document.getElementById('vv-nc-fmt-btn');
|
||
const open = row.style.display !== 'none';
|
||
row.style.display = open ? 'none' : 'flex';
|
||
btn.textContent = open ? 'Aa ▸' : 'Aa ▾';
|
||
}
|
||
|
||
// Style string for a message. Closed set on both ends — the server validates the same values,
|
||
// so nothing here can emit a property the store did not agree to.
|
||
function _vvNcStyle(m, mine) {
|
||
const st = m.style || {};
|
||
const sizes = { sm: '11px', lg: '14px' };
|
||
let s = `color:${st.color || (mine ? '#7a9a7a' : '#9aa')};`;
|
||
if (fonts[st.font]) s += `font-family:${fonts[st.font]};`;
|
||
s += `font-size:${sizes[st.size] || '12px'};`;
|
||
if (st.bold) s += 'font-weight:700;';
|
||
if (st.italic) s += 'font-style:italic;';
|
||
if (st.underline) s += 'text-decoration:underline;';
|
||
return s;
|
||
}
|
||
|
||
// HOST2 or unRAID-Jayred36, depending on the tick. The mapping comes from master.conf via the
|
||
// API rather than being reconstructed here.
|
||
function _vvNcWho(id) {
|
||
const full = document.getElementById('vv-nc-names')?.checked;
|
||
return full ? (_vvNc.hosts[id] || id) : id.toUpperCase();
|
||
}
|
||
|
||
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 || []; _vvNc.hosts = d.hostnames || {};
|
||
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 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(_vvNcWho(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="${_vvNcStyle(m, mine)}white-space:pre-wrap;word-break:break-word;margin-top:1px;">${vvEscHtml(m.text)}</div>
|
||
</div>`;
|
||
}).join('');
|
||
// Only when asked. Someone reading back through a thread must not be yanked to the bottom by
|
||
// the 15s poll landing a new line.
|
||
if (document.getElementById('vv-nc-follow')?.checked) 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,
|
||
size: document.getElementById('vv-nc-size').value,
|
||
bold: _vvNc.fmt.bold ? '1' : '',
|
||
italic: _vvNc.fmt.italic ? '1' : '',
|
||
underline: _vvNc.fmt.underline ? '1' : '',
|
||
})
|
||
}).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(() => {});
|
||
}
|
||
|
||
_vvNcLoadPrefs();
|
||
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>
|