570 lines
31 KiB
Plaintext
570 lines
31 KiB
Plaintext
Menu="Tasks:95"
|
||
Title="Varaverk"
|
||
Icon="varaverk.png"
|
||
---
|
||
<?php
|
||
$plugin = 'varaverk';
|
||
$docroot = $docroot ?? $_SERVER['DOCUMENT_ROOT'] ?: '/usr/local/emhttp';
|
||
$pluginDir = "$docroot/plugins/$plugin";
|
||
|
||
require_once "$pluginDir/include/config.php";
|
||
?>
|
||
|
||
<script>
|
||
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
||
// CSRF token propagation — must run before any page JS.
|
||
//
|
||
// Unraid enforces CSRF centrally: /etc/php.ini sets auto_prepend_file to webGui's
|
||
// local_prepend.php, which terminates *every* POST that does not carry a valid token, before a
|
||
// single line of endpoint code runs. It accepts the token as a `csrf_token` POST field or as an
|
||
// X-CSRF-Token header.
|
||
//
|
||
// The webGUI's own injector is jQuery-only ($.ajaxPrefilter in HeadInlineJS.php). Varaverk's
|
||
// pages use native fetch(), which that prefilter does not touch — so without this shim every
|
||
// mutating request in the plugin is silently killed by the platform. Silently, because
|
||
// csrf_terminate() exits with no body: the fetch resolves, r.json() throws on the empty
|
||
// response, and the page's own .catch() swallows it.
|
||
//
|
||
// Setting the header rather than appending a body field is deliberate. It works identically for
|
||
// FormData, URLSearchParams and raw JSON bodies, so no call site has to know about it and a new
|
||
// endpoint cannot forget to include it. The header is only ever attached to same-origin
|
||
// Varaverk URLs; a cross-origin page cannot set a custom header without a preflight it will
|
||
// fail, which is precisely what makes this a CSRF defence rather than a formality.
|
||
//
|
||
// Inline, and above the tab content, because pages/*.php carry their own inline fetch calls and
|
||
// some fire on load — an external script could not be guaranteed to install first.
|
||
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
||
(function () {
|
||
if (window.__vvCsrfInstalled) return;
|
||
window.__vvCsrfInstalled = true;
|
||
|
||
var nativeFetch = window.fetch.bind(window);
|
||
|
||
window.fetch = function (input, init) {
|
||
var url = (typeof input === 'string') ? input : (input && input.url) || '';
|
||
|
||
if (url.indexOf('/plugins/varaverk/') !== -1 &&
|
||
typeof csrf_token !== 'undefined' && csrf_token) {
|
||
init = init || {};
|
||
var headers = new Headers(init.headers || (typeof input === 'object' && input.headers) || {});
|
||
if (!headers.has('X-CSRF-Token')) headers.set('X-CSRF-Token', csrf_token);
|
||
init = Object.assign({}, init, { headers: headers });
|
||
}
|
||
|
||
return nativeFetch(input, init);
|
||
};
|
||
})();
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
||
// Escaping helpers — shared, because every page builds HTML strings and assigns them to innerHTML.
|
||
//
|
||
// Defined here rather than per page for the reason the CSRF shim is: only one pages/*.php is ever
|
||
// included per request, so a helper defined inside one page does not exist for any other. That is
|
||
// not a hypothetical — these lived in pages/scheduler.php, and pages/monitor.php rendered media
|
||
// titles, partner hostnames and docker folder names straight into innerHTML with no escaping
|
||
// available to it at all.
|
||
//
|
||
// Not in js/varaverk.js, which would otherwise be the obvious home: that file is loaded by a
|
||
// src-tag *below* the tab include, so it is not defined yet while a page's inline script is
|
||
// running. The shared formatters there survive only because every caller is inside a fetch
|
||
// callback. An escaping helper must be callable from the first synchronous line of a page.
|
||
//
|
||
// Never write a literal script tag inside this block, not even in a comment. The tab body is
|
||
// injected into the page by the webGUI rather than parsed as part of the document, and the
|
||
// opening sequence ends this element early wherever it appears — the whole file then renders as
|
||
// visible text and every tab loads dead. That is not theoretical: the comment above said it with
|
||
// real angle brackets and took the monitor tab down on 2026-08-07.
|
||
//
|
||
// String() rather than assuming a string: these are fed payload fields that are frequently
|
||
// numbers, and sometimes null or undefined. A helper that throws on a number is a helper call
|
||
// sites will skip.
|
||
//
|
||
// Two functions because the contexts differ, and using the wrong one is silent:
|
||
// vvEscHtml text between tags. Leaves " alone — harmless there.
|
||
// vvEscAttr text inside an attribute. Escapes " as well, because a quote inside a
|
||
// double-quoted attribute ends it early and destroys the rest of the handler.
|
||
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
||
function vvEscHtml(s) {
|
||
return String(s ?? '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||
}
|
||
function vvEscAttr(s) {
|
||
return String(s ?? '').replace(/&/g,'&').replace(/"/g,'"')
|
||
.replace(/</g,'<').replace(/>/g,'>');
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
||
// What a failed fetch says.
|
||
//
|
||
// Every fetch in this plugin used to end in `.catch(() => {})` — 24 of them. That is not error
|
||
// handling, it is error deletion: the request fails, nothing renders, nothing is logged, and the
|
||
// surface either sits on "Loading…" forever or silently keeps showing stale numbers. The mesh chat
|
||
// spent an unknown amount of time "taking a minute to load" because a ReferenceError was thrown on
|
||
// every render and swallowed here; the fault named itself the moment a catch reported it.
|
||
//
|
||
// Console always, because a poller that drops one tick should not shout on screen. A target
|
||
// element when the caller has one, because a panel that will otherwise never fill has to say why.
|
||
//
|
||
// Global for the same reason vvEscHtml is: pages/*.php are included one at a time and each would
|
||
// otherwise carry its own copy, which is the arrangement that lets two of them drift.
|
||
function vvFetchErr(where, e, el) {
|
||
const msg = (e && e.message) ? e.message : String(e || 'request failed');
|
||
try { console.warn('[varaverk] ' + where + ' — ' + msg, e); } catch (_) {}
|
||
if (el) {
|
||
const n = (typeof el === 'string') ? document.getElementById(el) : el;
|
||
if (n) { n.textContent = where + ' failed: ' + msg; n.style.color = '#a05a2c'; }
|
||
}
|
||
}
|
||
|
||
// Throws on a non-2xx instead of handing HTML to JSON.parse. Unraid answers an expired session
|
||
// with a 302 to the login page, so without this the reported error is "Unexpected token '<'",
|
||
// which names the symptom and hides the cause.
|
||
function vvJson(r) {
|
||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||
return r.json();
|
||
}
|
||
|
||
// A URL about to be put in href/src or handed to window.open. Anything that is not plainly http,
|
||
// https or a site-relative path becomes empty — javascript: is the one that matters, and an
|
||
// allowlist is the only way to say that without chasing encodings. include/docs.php applies the
|
||
// same rule to markdown links; container WebUI values, which come from template XML, had no such
|
||
// check before reaching window.open().
|
||
function vvSafeUrl(u) {
|
||
const s = String(u ?? '').trim();
|
||
if (s === '') return '';
|
||
if (/^https?:\/\//i.test(s)) return s;
|
||
if (/^\/(?!\/)/.test(s)) return s;
|
||
return '';
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
||
// DIALOGS — never the browser's own
|
||
//
|
||
// confirm(), alert() and prompt() all carry a checkbox inside the dialog reading "prevent this
|
||
// page from creating additional dialogs". The moment it is ticked, every later call from that
|
||
// document returns false and draws nothing. Guards written as `if (!confirm(x)) return;` then
|
||
// decline silently and the button reads as dead — and because the webGUI swaps tabs by AJAX
|
||
// without tearing down the document, the suppression follows the operator across tabs and
|
||
// survives until a full reload. That happened on 2026-08-09: dismissing several repair findings
|
||
// in a row produced the checkbox, and the AI tab's buttons went dead until history was cleared.
|
||
//
|
||
// swal is Unraid's own dialog, defined in webGui/javascript/dynamix.js and loaded on every page
|
||
// by DefaultPageLayout — core calls it around 370 times and uses confirm() exactly never. So
|
||
// this is not a new dependency, and these dialogs look like the rest of the machine.
|
||
//
|
||
// THE TRAP, and why the callback below is not an arrow function:
|
||
// On cancel, SweetAlert 1.x only invokes the callback if the callback's own source declares a
|
||
// parameter — it literally does String(fn).replace(/\s/g,'') and checks that it starts with
|
||
// "function(" and that the next character is not ")". An arrow function stringifies as "ok=>…"
|
||
// and fails that test, so cancel would never resolve this promise and the awaiting caller
|
||
// would hang forever with no dialog on screen. Which is the exact bug this file is replacing,
|
||
// wearing a different hat. Classic function expression, one named parameter, deliberately.
|
||
function vvConfirm(text, opts) {
|
||
const o = opts || {};
|
||
return new Promise(function (resolve) {
|
||
// Without swal there is nothing better than the browser's own dialog. It carries the bug
|
||
// described above, but the alternative is a guard that answers neither yes nor no.
|
||
if (typeof swal !== 'function') { resolve(window.confirm(text)); return; }
|
||
swal({
|
||
title: o.title || 'Are you sure?',
|
||
text: String(text ?? ''),
|
||
type: o.type || 'warning',
|
||
showCancelButton: true,
|
||
confirmButtonText: o.confirmText || 'Yes',
|
||
cancelButtonText: o.cancelText || 'Cancel',
|
||
confirmButtonColor: o.danger === false ? '#3085d6' : '#d9534f',
|
||
closeOnConfirm: true,
|
||
}, function (ok) { resolve(ok !== false); });
|
||
});
|
||
}
|
||
|
||
// Telling the operator something, with no question attached. Returns a promise so a caller can
|
||
// sequence on it, but nothing has to await it — which is what let the fifty-odd alert() sites
|
||
// become this by rename alone, with no function above them turning async.
|
||
//
|
||
// The icon is read off the message when the caller does not say. Almost every one of these
|
||
// reports a failure — "Save failed: …", "Error: …" — and asking fifty call sites to each classify
|
||
// themselves would mean fifty chances to disagree about what counts as an error. The caller can
|
||
// still pass type explicitly and that always wins.
|
||
function vvAlertType(text) {
|
||
const s = String(text ?? '');
|
||
if (/\b(fail|failed|error|denied|invalid|refused|cannot|could not|unable)\b/i.test(s)) return 'error';
|
||
if (/\b(warn|warning|already|must be)\b/i.test(s)) return 'warning';
|
||
return 'info';
|
||
}
|
||
function vvAlert(text, opts) {
|
||
const o = opts || {};
|
||
return new Promise(function (resolve) {
|
||
if (typeof swal !== 'function') { window.alert(text); resolve(true); return; }
|
||
swal({
|
||
title: o.title || '',
|
||
text: String(text ?? ''),
|
||
type: o.type || vvAlertType(text),
|
||
confirmButtonText: o.confirmText || 'OK',
|
||
}, function (ok) { resolve(true); });
|
||
});
|
||
}
|
||
|
||
// Asking for a value. Resolves to the string, or null when cancelled — prompt()'s own contract,
|
||
// so call sites keep reading the same way. The empty string is a real answer and is not null.
|
||
function vvPrompt(text, def, opts) {
|
||
const o = opts || {};
|
||
return new Promise(function (resolve) {
|
||
if (typeof swal !== 'function') { resolve(window.prompt(text, def || '')); return; }
|
||
swal({
|
||
title: o.title || '',
|
||
text: String(text ?? ''),
|
||
type: 'input',
|
||
inputValue: def || '',
|
||
inputPlaceholder: o.placeholder || '',
|
||
showCancelButton: true,
|
||
confirmButtonText: o.confirmText || 'OK',
|
||
cancelButtonText: o.cancelText || 'Cancel',
|
||
closeOnConfirm: true,
|
||
}, function (val) { resolve(val === false ? null : String(val)); });
|
||
});
|
||
}
|
||
|
||
// ── Mirror onboarding, shared by the Partnership tab and the first-run wizard ─────────────────
|
||
//
|
||
// Here for the reason stated above: only one pages/*.php is included per request, and both the
|
||
// wizard and the Partnership tab need to render the same panel and run the same job. A second
|
||
// copy in setup.php would drift from the one in partnership.php, and the panel encodes a detail
|
||
// that is easy to get wrong on a copy — the terminal command must carry the SERVING host's
|
||
// SCRIPTS_DIR, because it is pasted into a terminal on that machine.
|
||
//
|
||
// Not in js/varaverk.js: that loads below the tab include, and the wizard returns before it.
|
||
|
||
// Where Varaverk is installed on THIS host, read live rather than baked into the page.
|
||
//
|
||
// Two reasons it cannot be a render-time constant. The wizard can move it: choosing appdata in
|
||
// step 1 triggers a migration and step 2 renders in the same page load, so PHP's value names the
|
||
// pre-migration location. And each host chooses independently — the owner may be on flash while
|
||
// the mirror is on appdata — so a panel rendered on either side must ask, not assume.
|
||
//
|
||
// It matters because the value ends up in a command the operator pastes into a root terminal.
|
||
// api/setup.php?action=detect re-reads varaverk.cfg. Cached; the panels re-render often.
|
||
let _vvScriptsDir = null;
|
||
function vvScriptsDir(fallback) {
|
||
if (_vvScriptsDir) return Promise.resolve(_vvScriptsDir);
|
||
return fetch('/plugins/varaverk/api/setup.php?action=detect&_=' + Date.now())
|
||
.then(r => r.json())
|
||
.then(d => (_vvScriptsDir = (d && d.scripts_dir) || fallback))
|
||
.catch(() => (_vvScriptsDir = fallback));
|
||
}
|
||
|
||
// api/run.php answers when a job is LAUNCHED, not finished. An empty body is never success —
|
||
// Unraid's CSRF guard exits with one, and so does a PHP fatal.
|
||
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()) throw new Error('Empty response — request rejected before it reached run.php');
|
||
return JSON.parse(text);
|
||
});
|
||
}
|
||
|
||
function _vvJobProgressEl(btn) {
|
||
let el = btn.parentElement.querySelector('.vv-jobprog');
|
||
if (!el) {
|
||
el = document.createElement('div');
|
||
el.className = 'vv-jobprog';
|
||
el.style.cssText = 'font-size:10px;margin-top:5px;white-space:nowrap;';
|
||
btn.parentElement.appendChild(el);
|
||
}
|
||
el.innerHTML = '<span style="color:#4a9eff;">⟳ starting…</span>';
|
||
return el;
|
||
}
|
||
|
||
// Polls the job's stat for liveness and its log for the step banner, so a launched job shows
|
||
// where it is instead of appearing to do nothing for minutes.
|
||
const _vvJobPoll = {};
|
||
function vvPtWatchJob(id, mountEl) {
|
||
if (_vvJobPoll[id]) clearInterval(_vvJobPoll[id]);
|
||
const enc = encodeURIComponent(id);
|
||
const tick = () => {
|
||
fetch(`/plugins/varaverk/api/status.php?id=${enc}&_=${Date.now()}`)
|
||
.then(r => r.json())
|
||
.then(s => {
|
||
if (!s.ok) return;
|
||
if (s.status === 'running') {
|
||
return fetch(`/plugins/varaverk/api/log.php?id=${enc}&_=${Date.now()}`)
|
||
.then(r => r.json())
|
||
.then(l => {
|
||
const lines = (l.content || '').split('\n');
|
||
let step = '';
|
||
for (let i = lines.length - 1; i >= 0; i--) {
|
||
const m = lines[i].match(/━━━\s*(?:[^\s]+\s+)?(Step [^━]+?)\s*━━━/);
|
||
if (m) { step = m[1].trim(); break; }
|
||
}
|
||
mountEl.innerHTML = `<span style="color:#4a9eff;">⟳ running</span>`
|
||
+ (step ? ` <span style="color:#666;">· ${vvEscHtml(step)}</span>` : '');
|
||
});
|
||
}
|
||
clearInterval(_vvJobPoll[id]); delete _vvJobPoll[id];
|
||
const col = s.status === 'ok' ? '#4caf50' : (s.status === 'warn' ? '#ff9800' : '#f44336');
|
||
const lbl = s.status === 'ok' ? 'complete ✅'
|
||
: (s.status === 'never_run' ? 'did not start ⚠' : `${s.status} (exit ${s.exit ?? '?'})`);
|
||
mountEl.innerHTML = `<span style="color:${col};">${lbl}</span>`
|
||
+ ` <a href="?tab=scheduler" class="localURL" style="color:#556;margin-left:6px;">log</a>`;
|
||
if (typeof _vvPtReload === 'function') _vvPtReload();
|
||
// The wizard does have something to refresh, and saying it did not is why the mirror's
|
||
// Join button stayed greyed at "Running" after the run finished. Worse, the mirror's own
|
||
// job takes about three seconds — it only notifies the owner — while the Phase 2 it
|
||
// triggers runs for minutes on the far side. So "this job is done" is not "the
|
||
// partnership is done", and a single reload here would still show an unfinished wizard.
|
||
// vvOnJobDone polls until the checklist actually turns.
|
||
if (typeof vvOnJobDone === 'function') vvOnJobDone(id, s);
|
||
})
|
||
.catch(() => {});
|
||
};
|
||
tick();
|
||
_vvJobPoll[id] = setInterval(tick, 4000);
|
||
}
|
||
|
||
async function vvPtOnboard(btn) {
|
||
if (!await vvConfirm('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…';
|
||
// Stays disabled while it runs. A timed re-enable invited the second click whose lock refusal
|
||
// overwrote the live run's job record.
|
||
const prog = _vvJobProgressEl(btn);
|
||
_vvPtRun('Partnership/partnership_onboard.sh')
|
||
.then(d => {
|
||
if (!d.ok) throw new Error(d.error ?? 'Unknown error');
|
||
btn.textContent = '⟳ Running…';
|
||
vvPtWatchJob('Partnership/partnership_onboard.sh', prog);
|
||
})
|
||
.catch(e => {
|
||
prog.innerHTML = `<span style="color:#f44336;">failed to start — ${vvEscHtml(String(e.message || e))}</span>`;
|
||
btn.disabled = false;
|
||
btn.textContent = '▶ Onboard';
|
||
});
|
||
}
|
||
|
||
// The mirror's two-step join. Step 1 is a terminal step on purpose: ssh_setup.sh runs ssh-copy-id,
|
||
// which prompts for the owner's root password on a first install, and a WebGUI button cannot
|
||
// answer a password prompt. Offering only a button here was offering the one route that cannot
|
||
// work — it failed on ssh-copy-id every time.
|
||
// opts: {ownerName, termBase, termCmd, phase, hasPartner}
|
||
function vvRenderMirrorOnboard(opts) {
|
||
const owner = vvEscHtml(opts.ownerName || 'the owner');
|
||
const dis = opts.hasPartner === false ? 'disabled style="opacity:.35;cursor:default;"' : '';
|
||
|
||
// Collapses at phase 2, not phase 1.
|
||
//
|
||
// HOST<n>_PHASE1_DONE means the OWNER finished its Phase 1 — conf pushed, network created,
|
||
// confs cached. It says nothing about whether THIS host's key was ever installed on the owner,
|
||
// which is the only thing Step 1 does. Gating on it hid the terminal command at precisely the
|
||
// moment the mirror still needed it: flag set by the owner's push, key not installed, panel
|
||
// showing a button that cannot work.
|
||
//
|
||
// Phase 2 means the partnership is actually established, so the instructions have genuinely
|
||
// stopped being needed. Until then showing them costs nothing — a step already done reads as a
|
||
// reminder, a step still needed and hidden is a dead end.
|
||
// Phase 2 runs on the OWNER and takes minutes — 4m05s on a measured run, longer as the arr
|
||
// library grows. The mirror's own job finishes in about three seconds, because all it does is
|
||
// send the notification, so for the rest of that window the screen showed the unchanged
|
||
// phase-1 panel with a greyed-out button and no statement that anything was happening
|
||
// elsewhere. That reads as a hang, and the reasonable response to a hang is to start clicking.
|
||
if ((opts.phase ?? 0) < 2 && opts.waiting) {
|
||
const mins = opts.waitingSince
|
||
? Math.floor((Date.now() - opts.waitingSince) / 60000) : 0;
|
||
const secs = opts.waitingSince
|
||
? Math.floor(((Date.now() - opts.waitingSince) % 60000) / 1000) : 0;
|
||
return `<div style="padding:12px 14px;background:#1a1200;border:1px solid #3a2800;border-radius:4px;">
|
||
<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
|
||
<span style="font-size:18px;line-height:1;color:#ff9800;">⟳</span>
|
||
<div style="flex:1;min-width:220px;">
|
||
<div style="color:#ff9800;font-size:12px;font-weight:600;">
|
||
Onboard in progress — please wait</div>
|
||
<div style="color:#8a6a2a;font-size:10px;margin-top:3px;line-height:1.5;">
|
||
${owner} is running Phase 2: deploying containers, syncing auth data and
|
||
bootstrapping the arr libraries. This normally takes 4–6 minutes and nothing is
|
||
needed from you here. This page updates itself when it finishes.</div>
|
||
${opts.waitingSince ? `<div style="color:#6a5020;font-size:10px;margin-top:4px;
|
||
font-family:monospace;">elapsed ${mins}m ${String(secs).padStart(2,'0')}s</div>` : ''}
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
if ((opts.phase ?? 0) >= 2) {
|
||
// Done state. This used to return the same "Join partnership · ▶ Onboard" panel as the
|
||
// not-started state, under a comment saying it collapsed — so a fully onboarded mirror
|
||
// rendered as one that had never run, and the only honest reading of the screen was that
|
||
// nothing had happened. The button is gone rather than disabled: there is nothing left for
|
||
// the mirror to initiate, and Phase 2 is re-run from the owner.
|
||
return `<div style="padding:10px 12px;background:#0a0f0a;border:1px solid #1a3a1a;border-radius:4px;">
|
||
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
|
||
<span style="font-size:10px;font-weight:600;color:#4caf50;background:#0a1a0a;
|
||
padding:2px 9px;border-radius:10px;border:1px solid #1a4a1a;">Partnership active</span>
|
||
<span style="font-size:9px;color:#444;">onboarded by ${owner} · nothing further to do here</span>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
return `<div style="padding:10px 12px;background:#0d0d0d;border:1px solid #1e3a5a;border-radius:4px;">
|
||
<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 ${owner}</span>
|
||
</div>
|
||
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:4px;">
|
||
<a href="${opts.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('${opts.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">${opts.termCmd}</code>
|
||
</div>
|
||
<div style="font-size:9px;color:#2a2a2a;">Enter ${owner} root password when prompted · a button cannot answer that prompt</div>
|
||
</div>
|
||
<div style="border-top:1px solid #1a1a1a;padding-top:10px;">
|
||
<div style="display:flex;align-items:center;justify-content:space-between;gap:10px;">
|
||
<div>
|
||
<div style="display:flex;align-items:center;gap:6px;margin-bottom:2px;">
|
||
<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;">Join partnership</span>
|
||
</div>
|
||
<div style="font-size:9px;color:#333;">Notifies ${owner} to run Phase 2 · if key already installed</div>
|
||
</div>
|
||
<button class="vv-pt-action-btn run" onclick="vvPtOnboard(this)" ${dis}
|
||
style="font-size:11px;white-space:nowrap;">▶ Onboard</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
</script>
|
||
|
||
<?php
|
||
// First-run check — show setup wizard if HOST1 is blank OR local host.conf is missing
|
||
$_master = vv_read_conf_raw('master.conf');
|
||
preg_match('/^\s*HOST1\s*=\s*"([^"]*)"/m', $_master, $_h1m);
|
||
$_host1_blank = empty(trim($_h1m[1] ?? ''));
|
||
$_my_hostid = vv_detect_host();
|
||
$_conf_missing = $_my_hostid !== 'unknown'
|
||
&& !file_exists(CONF_DIR . '/' . $_my_hostid . '.conf');
|
||
if ($_host1_blank || $_conf_missing) {
|
||
include "$pluginDir/pages/setup.php";
|
||
return;
|
||
}
|
||
unset($_master, $_h1m, $_host1_blank, $_my_hostid, $_conf_missing);
|
||
|
||
// Determine active tab
|
||
$tab = $_GET['tab'] ?? 'monitor';
|
||
$validTabs = ['monitor', 'scheduler', 'watchdog', 'partnership', 'fallback', 'arrs', 'rsync', 'auth', 'settings'];
|
||
|
||
// The AI tab exists only on the AI owner, and only while AI_ENABLED is true. Appended to
|
||
// $validTabs rather than filtered out of it, so the check below rejects ?tab=ai server-side as
|
||
// well — omitting the link is presentation, not access control, and api/ai.php refuses the
|
||
// owner-only actions independently.
|
||
//
|
||
// The host half is a deliberate split, not a technical limit. The mesh shares one AI: every node
|
||
// reaches the owner's model through include/ai_rpc.php, so an assistant works everywhere. What
|
||
// does not travel is this tab — it carries the bug reports, the index and the model configuration,
|
||
// the surface where a wrong answer is expensive and the vocabulary assumes you built the thing.
|
||
// Someone running two containers on a node they were handed gets the assistant, not the machinery
|
||
// behind it.
|
||
//
|
||
// Assistant docks elsewhere use vv_ai_ui_on(), which every node in the mesh passes.
|
||
$_vv_ai = vv_ai_owner_ui_on();
|
||
if ($_vv_ai) $validTabs[] = 'ai';
|
||
|
||
// ── Local pages ───────────────────────────────────────────────────────────────────────────────
|
||
// pages/local/ is gitignored, so whatever is in it belongs to this installation alone and never
|
||
// reaches the public mirror. This loader is the tracked half: a generic extension point that
|
||
// knows nothing about what it is loading.
|
||
//
|
||
// It exists because the alternative — a tracked `if (file_exists(pages/thing.php))` per private
|
||
// page — puts the name and purpose of every private page into the public repo, which defeats the
|
||
// point of keeping the page out of it.
|
||
//
|
||
// Discovered rather than configured: a conf key listing local pages would itself be a tracked
|
||
// file naming them, and an untracked one would be a second thing to keep in sync with the
|
||
// directory. The directory is the declaration.
|
||
//
|
||
// The label comes from a `// vv-local-page: Name` line in the first 2KB of the file, falling back
|
||
// to the capitalised id. Reading it out of the file keeps the page self-describing — nothing
|
||
// outside it has to be edited to add one.
|
||
$localPages = [];
|
||
foreach (glob("$pluginDir/pages/local/*.php") ?: [] as $_lp) {
|
||
$_id = basename($_lp, '.php');
|
||
// Ids are restricted and collisions rejected: $tab is user input that becomes an include
|
||
// path below, and a local page must never be able to shadow a real tab.
|
||
if (!preg_match('/^[a-z0-9][a-z0-9_-]{0,31}$/', $_id)) continue;
|
||
if (in_array($_id, $validTabs, true)) continue;
|
||
$_lbl = ucfirst($_id);
|
||
if (preg_match('/^\s*(?:\/\/|#)\s*vv-local-page:\s*(.+)$/m', (string)@file_get_contents($_lp, false, null, 0, 2048), $_m)) {
|
||
$_lbl = trim($_m[1]);
|
||
}
|
||
$localPages[$_id] = ['path' => $_lp, 'label' => $_lbl];
|
||
$validTabs[] = $_id;
|
||
}
|
||
unset($_lp, $_id, $_lbl, $_m);
|
||
|
||
if (!in_array($tab, $validTabs)) $tab = 'monitor';
|
||
$tabLabels = ['monitor' => 'Monitor', 'scheduler' => 'Scheduler', 'watchdog' => 'Watchdog', 'partnership' => 'Partnership', 'fallback' => 'FallBack', 'arrs' => 'Media Stack', 'rsync' => 'Rsync', 'auth' => 'Auth Stack', 'settings' => 'Settings', 'ai' => 'AI'];
|
||
foreach ($localPages as $_id => $_lp) $tabLabels[$_id] = $_lp['label'];
|
||
unset($_id, $_lp);
|
||
|
||
// Cache stamp for the stylesheet and script below. Both are served straight off the plugin
|
||
// directory at a path that never changes, so a browser holding an old copy keeps using it after
|
||
// a git pull. This page's own markup is generated fresh every request, so the two fall out of
|
||
// step: markup from the new commit rendering against a stylesheet from the old one. That is not
|
||
// a theoretical failure — it broke the Monitor AI row on 2026-08-09, when placement moved from
|
||
// inline spans into the stylesheet and only half of that arrived in the browser.
|
||
//
|
||
// mtime rather than a hand-bumped version: it changes on exactly the event that matters, a pull
|
||
// rewriting the file, and cannot be forgotten. Falling back to time() when the stat fails errs
|
||
// toward re-fetching rather than toward the stale copy that caused the problem.
|
||
$_vv_asset_rev = [];
|
||
foreach (['css/varaverk.css', 'js/varaverk.js'] as $_vv_a) {
|
||
$_vv_asset_rev[$_vv_a] = @filemtime(__DIR__ . '/' . $_vv_a) ?: time();
|
||
}
|
||
?>
|
||
|
||
<link rel="stylesheet" href="/plugins/<?=$plugin?>/css/varaverk.css?v=<?=$_vv_asset_rev['css/varaverk.css']?>">
|
||
|
||
<div id="varaverk-wrap" class="unapi">
|
||
|
||
<!-- Tab bar -->
|
||
<div id="vv-tabs">
|
||
<?php foreach ($validTabs as $t): ?>
|
||
<a href="?tab=<?=$t?>" class="localURL vv-tab<?= $t === $tab ? ' active' : '' ?>">
|
||
<?= $tabLabels[$t] ?? ucfirst($t) ?>
|
||
</a>
|
||
<?php endforeach; ?>
|
||
<div style="margin-left:auto;display:flex;align-items:center;gap:2px;flex-shrink:0;">
|
||
<a href="https://github.com/FailedProxy/Varaverk" target="_blank"
|
||
style="padding:0 10px;font-size:10px;color:#333;text-decoration:none;
|
||
display:flex;align-items:center;letter-spacing:.03em;"
|
||
title="GitHub — source, issues, changelog">
|
||
⎋ GitHub
|
||
</a>
|
||
<button id="vv-expand-btn" type="button" onclick="vvToggleExpand()" title="Expand">⤢</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Tab content -->
|
||
<div id="vv-content">
|
||
<?php
|
||
// Local pages resolve from the map built above, never by composing a path out of $tab —
|
||
// the map's keys were validated against a strict pattern, so nothing user-supplied
|
||
// reaches an include.
|
||
$page = isset($localPages[$tab]) ? $localPages[$tab]['path'] : "$pluginDir/pages/$tab.php";
|
||
if (file_exists($page)) include $page;
|
||
else echo "<p>Page not found: " . htmlspecialchars($tab, ENT_QUOTES) . "</p>";
|
||
?>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<script src="/plugins/<?=$plugin?>/js/varaverk.js?v=<?=$_vv_asset_rev['js/varaverk.js']?>"></script>
|