Files
Varaverk/Plugin/unraid/pages/partnership.php
T
Gmer4Lfe 4449bfc646 Partnership phase 0: terminal link + clickable command block
At phase 0, show below the buttons:
- 'Open Terminal' link using HOST1's Tailscale IP (/webterminal/ttyd/)
- Clickable command code block (click to copy) with the Phase 1 script path
- Note that tab auto-updates on next poll when the script completes

Tailscale IP comes from selfNode.ts_ip already in the API response.
2026-05-30 21:02:27 -04:00

498 lines
24 KiB
PHP

<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); }
}
</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>
<!-- 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>
<!-- Actions -->
<div class="vv-card" id="vv-pt-actions-card">
<h3>Actions</h3>
<div id="vv-pt-actions-body" style="color:#444;font-size:12px;">Loading…</div>
</div>
<script>
// ── Public action functions — defined at top level so onclick= can reach them ──
function _vvPtRun(id, extraArgs) {
const params = {id, manual: '1'};
if (extraArgs) params.extra_args = extraArgs;
return fetch('/plugins/varaverk/api/run.php', {
method: 'POST',
body: new URLSearchParams(params)
}).then(r => {
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.text();
}).then(text => {
if (!text.trim()) {
// Empty body — script likely started via nohup before response was sent.
// Treat as ok; check Scheduler log for confirmation.
return {ok: true, _empty_response: true};
}
return JSON.parse(text);
});
}
function vvPtPhase1(btn, hostId) {
if (!confirm(`Phase 1: SSH key exchange + conf push to ${hostId}?\n\nSafe to run before ${hostId} has Varaverk installed.`)) return;
btn.disabled = true;
btn.textContent = '⟳ Starting…';
_vvPtRun('Partnership/partnership_onboard.sh', '--phase1-only')
.then(d => { if (!d.ok) alert('Failed: ' + (d.error ?? 'Unknown error')); })
.catch(e => alert('Error: ' + e))
.finally(() => setTimeout(() => { btn.disabled = false; btn.textContent = '▶ Phase 1: SSH + Conf Push'; }, 3000));
}
function vvPtPhase2(btn, hostId) {
if (!confirm(`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) alert('Failed: ' + (d.error ?? 'Unknown error')); })
.catch(e => alert('Error: ' + e))
.finally(() => setTimeout(() => { btn.disabled = false; btn.textContent = '▶ Run Phase 2 Manually'; }, 3000));
}
function vvPtOnboard(btn) {
if (!confirm('Run full partnership_onboard.sh?\n\nRun on the MIRROR first, then on the OWNER.\n\nUse Phase 1 + Phase 2 buttons for step-by-step control.')) return;
btn.disabled = true;
btn.textContent = '⟳ Starting…';
_vvPtRun('Partnership/partnership_onboard.sh')
.then(d => { if (!d.ok) alert('Failed: ' + (d.error ?? 'Unknown error')); })
.catch(e => alert('Error: ' + e))
.finally(() => setTimeout(() => { btn.disabled = false; btn.textContent = '▶ Onboard (Mirror)'; }, 4000));
}
function vvPtPushConf(btn, hostId) {
if (!confirm(`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) alert('Failed: ' + (d.error ?? 'Unknown error')); })
.catch(e => alert('Error: ' + e))
.finally(() => setTimeout(() => { btn.disabled = false; btn.textContent = '▶ Push Conf (key installed)'; }, 4000));
}
function vvPtLocalSetup(btn) {
if (!confirm('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) alert('Failed: ' + (d.error ?? 'Unknown error')); })
.catch(e => alert('Error: ' + e))
.finally(() => setTimeout(() => { btn.disabled = false; btn.textContent = '▶ Complete HOST1 Setup'; }, 4000));
}
function vvPtCancel(btn, hostId) {
if (!confirm(`Cancel Phase 1 for ${hostId}?\n\nThis will:\n• Remove HOST1's SSH key from ${hostId}'s authorized_keys\n• Delete the local key pair\n• Reset phase state on both hosts\n\nContinue?`)) return;
btn.disabled = true;
btn.textContent = '⟳ Cancelling…';
_vvPtRun('Partnership/onboard_cancel.sh')
.then(d => { if (!d.ok) alert('Failed: ' + (d.error ?? 'Unknown error')); })
.catch(e => alert('Error: ' + e))
.finally(() => setTimeout(() => { btn.disabled = false; btn.textContent = '✕ Cancel'; }, 4000));
}
function vvPtOffboard(btn) {
if (btn.style.opacity === '0.35' || btn.style.cursor === 'default') return;
if (!confirm('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) alert('Failed: ' + (d.error ?? 'Unknown error')); })
.catch(e => alert('Error: ' + e))
.finally(() => setTimeout(() => { btn.disabled = false; btn.textContent = '▶ Offboard'; }, 4000));
}
// ── Private page logic ─────────────────────────────────────────────────────────
(function() {
// ── Helpers ───────────────────────────────────────────────────────────────────
function _relTime(ts) {
if (!ts) return '—';
const d = Math.floor(Date.now() / 1000) - ts;
if (d < 60) return 'just now';
if (d < 3600) return Math.floor(d / 60) + 'm ago';
if (d < 86400) return Math.floor(d / 3600) + 'h ago';
if (d < 172800) return 'yesterday';
return Math.floor(d / 86400) + 'd ago';
}
function _uptime(sec) {
if (!sec) return '—';
const d = Math.floor(sec / 86400);
const h = Math.floor((sec % 86400) / 3600);
const m = Math.floor((sec % 3600) / 60);
return d > 0 ? `${d}d ${h}h ${m}m` : h > 0 ? `${h}h ${m}m` : `${m}m`;
}
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>`;
}
// ── 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;
const phaseHtml = node.is_me
? (node.local_done
? `<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 · waiting for partner</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>`);
let body = '';
body += phaseHtml;
// 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 remotes = nodes.filter(n => !n.is_me);
const hasPartner = remotes.length > 0 && remotes.some(n => n.hostname);
let html = '';
// ── HOST1 local setup button (owner only, before local_done) ──────────────
const selfNode = nodes.find(n => n.is_me);
if (isOwner && selfNode && !selfNode.local_done && remotes.some(r => (r.onboard_phase ?? 0) >= 1)) {
html += `<div style="margin-bottom:12px;padding-bottom:12px;border-bottom:1px solid #1e1e1e;">
<div style="font-size:11px;color:#555;margin-bottom:6px;">${selfNode.id} — ${selfNode.hostname} <span style="color:#333;">(this server)</span></div>
<div class="vv-pt-actions">
<button class="vv-pt-action-btn run" onclick="vvPtLocalSetup(this)"
title="Run HOST1-local onboard steps (FolderView3, setup state) without needing HOST2 to be present">
▶ Complete HOST1 Setup
</button>
</div>
<div style="font-size:10px;color:#444;margin-top:6px;">
Configures HOST1's local side independently — FolderView3 folder, state flags.
Does not require HOST2 to be online.
</div>
</div>`;
}
// ── Per-partner onboard phase actions (owner only) ─────────────────────────
if (isOwner && hasPartner) {
for (const remote of remotes) {
const phase = remote.onboard_phase ?? 0;
html += `<div style="margin-bottom:12px;padding-bottom:12px;border-bottom:1px solid #1e1e1e;">`;
html += `<div style="font-size:11px;color:#555;margin-bottom:6px;">${remote.id} — ${remote.hostname}</div>`;
html += '<div class="vv-pt-actions">';
if (phase === 0) {
// Not yet provisioned — offer Phase 1 (safe before HOST2 has Varaverk)
html += `<button class="vv-pt-action-btn run" onclick="vvPtPhase1(this, '${remote.id}')"
title="Generate SSH key + install on ${remote.hostname} + push conf. First run needs terminal access to ${remote.hostname} for password — subsequent runs auto-detect the installed key.">
▶ Phase 1: SSH + Conf Push
</button>`;
if (remote.key_ready) {
html += `<button class="vv-pt-action-btn run" onclick="vvPtPushConf(this, '${remote.id}')"
title="Key is already installed on ${remote.hostname} — push conf files and complete Phase 1 without re-running SSH setup">
▶ Push Conf (key installed)
</button>`;
}
html += `<button class="vv-pt-action-btn info" onclick="vvPtOnboard(this)"
title="Run full onboard (Phase 1 + 2 together) — requires ${remote.id} to already have Varaverk installed"
style="opacity:.5;">
▶ Full Onboard
</button>`;
html += `<button class="vv-pt-action-btn warn" onclick="vvPtCancel(this, '${remote.id}')"
title="Remove SSH keys from both sides and reset phase state — use if a partial Phase 1 left a stale key"
style="opacity:.7;">
✕ Cancel
</button>`;
} else if (phase === 1) {
// Phase 1 done — waiting for HOST2 to onboard and trigger Phase 2
html += `<span style="font-size:11px;color:#ff9800;align-self:center;">⏳ Waiting for ${remote.id} to onboard…</span>`;
html += `<button class="vv-pt-action-btn info" onclick="vvPtPhase2(this, '${remote.id}')"
title="Manually trigger Phase 2 — deploy containers, arr stack, and establish partnership on ${remote.hostname}. Normally triggered automatically when ${remote.id} completes its onboard.">
▶ Run Phase 2 Manually
</button>`;
html += `<button class="vv-pt-action-btn warn" onclick="vvPtCancel(this, '${remote.id}')"
title="Remove SSH keys from both sides and reset to phase 0 — ${remote.id}'s authorized_keys will be cleaned up before the key is deleted">
✕ Cancel
</button>`;
} else {
// Phase 2 done — fully onboarded
html += `<span style="font-size:11px;color:#4caf50;align-self:center;">✅ Partnership established</span>`;
html += `<button class="vv-pt-action-btn info" onclick="vvPtPhase2(this, '${remote.id}')"
title="Re-run Phase 2 — redeploy containers and re-establish partnership on ${remote.hostname}"
style="opacity:.5;">
↻ Re-run Phase 2
</button>`;
}
html += '</div>';
if (phase === 0) {
const termUrl = selfNode && selfNode.ts_ip ? `https://${selfNode.ts_ip}/webterminal/ttyd/` : null;
const termCmd = `bash /mnt/user/appdata/Varaverk/Partnership/partnership_onboard.sh --phase1-only`;
html += `<div style="margin-top:8px;font-size:10px;color:#555;">
<div style="margin-bottom:5px;">First run requires terminal access for SSH key password.</div>
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
${termUrl ? `<a href="${termUrl}" target="_blank"
style="font-size:10px;padding:3px 10px;background:#1a2a3a;color:#7ab;border:1px solid #2e4a6b;
border-radius:3px;text-decoration:none;white-space:nowrap;">🖥 Open Terminal</a>` : ''}
<code style="font-size:9px;color:#666;background:#111;padding:3px 7px;border-radius:3px;
border:1px solid #222;flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;
white-space:nowrap;cursor:pointer;"
onclick="navigator.clipboard.writeText('${termCmd}').then(()=>{this.style.color='#4caf50';setTimeout(()=>this.style.color='',1200)})"
title="Click to copy">${termCmd}</code>
</div>
<div style="margin-top:4px;color:#333;">Tab auto-updates when complete · or click Push Conf if key already installed.</div>
</div>`;
} else if (phase === 1) {
html += `<div style="font-size:10px;color:#444;margin-top:6px;">
When ${remote.id} completes its onboard (Mirror path), it will SSH here and trigger Phase 2 automatically.
Use "Run Phase 2 Manually" if that notification didn't arrive.
</div>`;
}
html += '</div>';
}
} else if (!hasPartner) {
html += `<div style="font-size:11px;color:#444;margin-bottom:12px;">
No partner hostname configured. Edit master.conf (Scheduler tab) and set HOST2.
</div>`;
}
// ── Bottom row: Offboard + mirror Onboard ──────────────────────────────────
html += '<div class="vv-pt-actions">';
if (!isOwner) {
// Mirror: just runs its own path (SSH key + notifies owner)
html += `<button class="vv-pt-action-btn run" onclick="vvPtOnboard(this)"
${!hasPartner ? 'disabled style="opacity:.35;cursor:default;" title="Add partner hostname to master.conf first"' :
'title="Run Mirror onboard: SSH key setup + notify owner to run Phase 2"'}>
▶ Onboard (Mirror)
</button>`;
}
html += `<button class="vv-pt-action-btn warn" onclick="vvPtOffboard(this)"
${!cfg.enabled ? 'title="No active partnership to offboard" style="opacity:.35;cursor:default;"' : ''}>
▶ Offboard
</button>`;
html += '</div>';
// Transfer — show manual command, too destructive to one-click
if (cfg.enabled && isOwner) {
const confirmStr = 'i-understand-this-transfers-ownership';
html += `<div style="margin-top:14px;padding-top:10px;border-top:1px solid #1e1e1e;">
<span style="font-size:11px;color:#555;">Transfer ownership — run manually from the current owner:</span>
<div class="vv-pt-transfer-note" style="margin-top:4px;padding:6px 10px;background:#111;border-radius:4px;color:#666;">
bash Partnership/partnership_transfer.sh --confirm=${confirmStr}
</div>
</div>`;
}
return html;
}
// ── Main render ───────────────────────────────────────────────────────────────
function _render(data) {
const cfg = data.config || {};
const nodes = data.nodes || [];
// Config bar
document.getElementById('vv-pt-config-body').innerHTML = _renderConfig(cfg);
// 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);
// 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(() => {});
}
vvPtLoad();
setInterval(vvPtLoad, 10000);
})();
</script>