vv_is_ai_host() was `=== 'host1'`, which made a physical fact — that is where the GPU is — look like a rule. AI_OWNER_HOST declares it, so the card can move to a rebuilt host3 or a friend's spare. The gate was also doing two jobs. Assistant docks and findings strips now ask whether a model is reachable, so a node without a GPU gets them by borrowing; the AI tab asks whether this is the owner, because that page carries the bug reports, the index and the model configuration — the surface where the vocabulary assumes you built the mesh. Resolution is local, then owner, then anyone else declaring a model, pinned once it answers. Pinned rather than re-derived per call: a mesh that re-decides every request eventually decides differently mid-conversation, and a chat whose second turn lands on another machine has no history there. Cleared only on a transport failure, and only when there is somewhere else to go — a single-node mesh whose model is down should say so, not report AI as unconfigured.
283 lines
15 KiB
Plaintext
283 lines
15 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,'>');
|
|
}
|
|
|
|
// 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)); });
|
|
});
|
|
}
|
|
</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', 'docker', 'watchdog', 'partnership', 'fallback', 'arrs', 'rsync', 'auth', 'settings'];
|
|
|
|
// The AI tab exists only on HOST1, 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 every action
|
|
// on the same two conditions independently.
|
|
//
|
|
// The host half is not a preference: HOST1 owns the GPU, the Ollama process and the index, and
|
|
// include/ai.php only ever reads the *local* {HOST}_OLLAMA_URL — there is no Tailscale resolver
|
|
// in the PHP layer the way there is in the shell. On any other host the tab could only render
|
|
// and then fail its own health check.
|
|
// The tab is the owner-only surface — it carries the bug reports, the index and the model
|
|
// configuration. Assistant docks elsewhere use vv_ai_ui_on(), which every node with a reachable
|
|
// model passes.
|
|
$_vv_ai = vv_ai_owner_ui_on();
|
|
if ($_vv_ai) $validTabs[] = 'ai';
|
|
|
|
if (!in_array($tab, $validTabs)) $tab = 'monitor';
|
|
$tabLabels = ['monitor' => 'Monitor', 'scheduler' => 'Scheduler', 'docker' => 'Docker', 'watchdog' => 'Watchdog', 'partnership' => 'Partnership', 'fallback' => 'FallBack', 'arrs' => 'Media Stack', 'rsync' => 'Rsync', 'auth' => 'Auth Stack', 'settings' => 'Settings', 'ai' => 'AI'];
|
|
|
|
// 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
|
|
$page = "$pluginDir/pages/$tab.php";
|
|
if (file_exists($page)) include $page;
|
|
else echo "<p>Page not found: $tab</p>";
|
|
?>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<script src="/plugins/<?=$plugin?>/js/varaverk.js?v=<?=$_vv_asset_rev['js/varaverk.js']?>"></script>
|