Files
Varaverk/Plugin/unraid/include/confui.php
T
Gmer4Lfe 20c17648e6 Require the registries this renderer's callers read
The AI tab included this without confform.php and referenced a constant from
it, which is a fatal, which is a blank page.
2026-08-12 17:09:03 -04:00

344 lines
16 KiB
PHP

<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// Draws a parsed conf field as the control it deserves, and reads the edits back out. One
// implementation for every page that edits settings, so a switch looks and behaves the same
// wherever it is met.
//
// WHY THIS EXISTS
// Ten pages edited settings and nine had written their own vocabulary to do it — nine sets of
// inline CSS, six different toggle switches, and two pages that had both claimed the vv-set-*
// prefix with disjoint class names, so it read as shared and was not. Unraid swaps tabs by AJAX
// without unloading, so two of those stylesheets can be live in one document at once.
//
// Each of them started for the same reason: the shared set could render a conf field as a text
// box and nothing else, and every page eventually needed one control it did not have. The fix
// is not a convention that asks people not to do that again — it is having the control here.
//
// WHAT DECIDES A CONTROL
// Not this file. confform.php infers a widget from the conf itself — the value's shape and the
// comments already written above it — and this renders whatever it was told. That split is
// deliberate: the schema lives in the conf, the appearance lives here, and neither can quietly
// start deciding the other.
//
// DESIGN PRINCIPLES
// The control is a view of the value, never a second copy of it.
// Every control carries data-key, data-file, data-type and data-orig, and the payload is
// rebuilt by reading the DOM at save time. There is no parallel model to fall out of step
// with what is on screen.
//
// Only what changed is sent.
// A field whose value still equals data-orig is omitted. A settings page that submits all
// four hundred fields rewrites four hundred lines to change one, and every one of those
// rewrites is a chance to lose a comment.
//
// A value is returned exactly as it was typed.
// No trimming beyond the edges, no case folding, no normalising true to 1. The conf is
// shell source and the consuming script decides what it means.
//
// OPERATIONAL SAFEGUARDS
// Everything is escaped on the way in.
// Values come from conf files that hold paths, globs and quotes. Rendering goes through
// one escape helper and attributes through another — vvEscHtml does not escape quotes, so
// an attribute needs vvEscAttr or a path containing one closes it and the rest executes.
//
// Secrets render masked and are never pre-filled from a stale read.
// A masked field left untouched sends nothing, so a save cannot round-trip a credential
// back through the browser.
//
// Nothing here writes. The payload goes to api/confform.php, which owns the allowlist, and
// from there to vv_conf_edit(), which owns the lock, the backup and the rollback.
//
// EXPORTS
// vv_conf_ui_assets() the renderer, emitted once per page
//
// DEPENDS ON
// include/confform.php the widget each field was inferred to want
// api/confform.php ?sections= to read, POST changes to write
// css/varaverk.css the vv-cf-* family these classes belong to
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// Required, not assumed. This file emits the renderer for fields that confform.php parses, and
// the pages that include it also read its registries — VV_UI_SECTION_SURFACES among them. Left to
// whichever page happened to have pulled it in first, that works everywhere it was tested and
// fatals on the one page that included this without it, which is what took the AI tab blank.
require_once __DIR__ . '/confform.php';
function vv_conf_ui_assets(): void {
static $done = false;
if ($done) return;
$done = true;
?>
<script>
(function () {
if (window.VvConfUI) return;
// The page's own escape helpers where they exist. They are global in Varaverk.page for exactly
// this reason, and a second private copy here is a second thing to fix when one of them is
// found to be wrong.
const esc = s => (window.vvEscHtml ? vvEscHtml(s) : String(s == null ? '' : s)
.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'));
const escA = s => (window.vvEscAttr ? vvEscAttr(s) : String(s == null ? '' : s)
.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
.replace(/"/g,'&quot;').replace(/'/g,'&#39;'));
// Shared by every control. data-orig is what "changed" is measured against, and it is the
// parsed value rather than anything the page derived — so a control that renders a value
// differently than it stores it still compares against the truth.
function attrs(f) {
return ` data-key="${escA(f.key)}" data-file="${escA(f.file)}"`
+ ` data-type="${escA(f.type || 'scalar')}" data-orig="${escA(f.value == null ? '' : f.value)}"`;
}
function boolCtl(f) {
const on = String(f.value).toLowerCase() === 'true';
return `<div class="vv-cf-ctl">
<label class="vv-cf-toggle">
<span class="vv-cf-track${on ? ' on' : ''}"></span>
<input type="checkbox" hidden${attrs(f)} data-ctl="bool"${on ? ' checked' : ''}>
<span class="vv-cf-state">${on ? 'true' : 'false'}</span>
</label>
</div>`;
}
function intCtl(f) {
const min = (f.min !== undefined && f.min !== null) ? ` min="${escA(f.min)}"` : '';
const max = (f.max !== undefined && f.max !== null) ? ` max="${escA(f.max)}"` : '';
const unit = f.unit ? `<span class="vv-cf-unit">${esc(f.unit)}</span>` : '';
return `<div class="vv-cf-ctl">
<input type="number" class="vv-cf-scalar vv-cf-int"${min}${max}
value="${escA(f.value)}"${attrs(f)} data-ctl="int">${unit}
</div>`;
}
function enumCtl(f) {
const cur = String(f.value);
const opts = (f.choices || []).map(c =>
`<option value="${escA(c.value)}"${c.value === cur ? ' selected' : ''}>${esc(c.value)}</option>`
).join('');
// The hint belongs to the selected choice, so it is rebuilt on change rather than listing
// every option's explanation at once — which on a five-way choice is a paragraph.
const hints = {};
(f.choices || []).forEach(c => { if (c.hint) hints[c.value] = c.hint; });
const curHint = hints[cur] || '';
return `<div class="vv-cf-ctl">
<select class="vv-cf-sel"${attrs(f)} data-ctl="enum"
data-hints="${escA(JSON.stringify(hints))}">${opts}</select>
</div><div class="vv-cf-opthint">${esc(curHint)}</div>`;
}
function secretCtl(f) {
// Rendered from the real value so the field is genuinely editable, but typed as a password so
// it is not read over a shoulder or captured in a screenshot of the tab.
return `<div class="vv-cf-ctl">
<input type="password" class="vv-cf-scalar vv-cf-secret" autocomplete="new-password"
value="${escA(f.value)}"${attrs(f)} data-ctl="text">
<button type="button" class="vv-cf-reveal" data-reveal>Show</button>
</div>`;
}
// The gutter is seeded here and then kept in step by the input/scroll handlers in wire().
// It cannot be produced once and left: the numbers follow the textarea's value, which is the
// thing the operator is about to change.
function linesCtl(f) {
const v = String(f.value == null ? '' : f.value);
const n = v.split('\n').length;
return `<div class="vv-cf-arraywrap">
<pre class="vv-cf-lines" aria-hidden="true">${gutterFor(v)}</pre>
<textarea class="vv-cf-array" rows="${Math.min(Math.max(n, 3), 20)}"${attrs(f)}
data-ctl="text" spellcheck="false">${esc(v)}</textarea>
</div>`;
}
function gutterFor(v) {
const n = String(v).split('\n').length;
let s = '';
for (let i = 1; i <= n; i++) s += i + '\n';
return s;
}
// Kept together, because they are two halves of one illusion: the gutter is a separate element
// that has to be renumbered when the text changes and scrolled when the text scrolls, or the
// numbers drift away from the lines they belong to.
function syncGutter(ta) {
const g = ta.parentElement && ta.parentElement.querySelector('.vv-cf-lines');
if (!g) return;
g.textContent = gutterFor(ta.value);
g.scrollTop = ta.scrollTop;
}
function scrollGutter(ta) {
const g = ta.parentElement && ta.parentElement.querySelector('.vv-cf-lines');
if (g) g.scrollTop = ta.scrollTop;
}
function textCtl(f) {
return `<div class="vv-cf-ctl">
<input type="text" class="vv-cf-scalar" value="${escA(f.value)}"${attrs(f)}
data-ctl="text" spellcheck="false">
</div>`;
}
const CONTROLS = { bool: boolCtl, int: intCtl, enum: enumCtl, secret: secretCtl,
lines: linesCtl, path: textCtl, text: textCtl };
// n is the running number across the whole panel, not the position within a group. It gives
// every setting one unambiguous handle — "number 12" beats "the third one under Docker
// Watchdog" when someone is reading it back to you over the phone. The stripe is driven by the
// same counter rather than :nth-child, because the group header is also a child and would
// throw the parity off inside every section.
function fieldHtml(f, n) {
const draw = CONTROLS[f.widget] || textCtl;
// The description is the conf's own comment. It is the only documentation most of these
// settings have, and it is why the form is readable at all.
const desc = f.desc ? `<div class="vv-cf-desc">${esc(f.desc)}</div>` : '';
return `<div class="vv-cf-field${n % 2 === 0 ? ' vv-cf-alt' : ''}" data-field="${escA(f.key)}">
<span class="vv-cf-num">${n}</span>
<span class="vv-cf-dot"></span>
<div class="vv-cf-body">
<div class="vv-cf-key">${esc(f.key)}</div>
${desc}
${draw(f)}
</div>
</div>`;
}
// Takes the counter by reference so numbering continues across groups. Returning it would have
// worked too, but every caller would then be responsible for threading it correctly and one of
// them eventually would not.
function groupHtml(g, counter) {
const fields = (g.fields || []).map(f => fieldHtml(f, ++counter.n)).join('');
if (!fields) return '';
return `<div class="vv-cf-group">
<div class="vv-cf-group-header">${esc(g.subsection || g.name || '')}
<span class="vv-cf-file">${esc(g.file || '')}</span>
</div>
${fields}
</div>`;
}
// Reading a control back. Kept beside the drawing so a new control cannot be added without a
// matching way to read it — the two halves drifting is how a field silently stops saving.
function readCtl(el) {
if (el.dataset.ctl === 'bool') return el.checked ? 'true' : 'false';
return el.value;
}
window.VvConfUI = {
// The markup on its own, for a caller composing it into something larger — the Scheduler's
// advanced view wraps it in a Config block alongside the header and README sections. Whoever
// uses this owes the DOM a hydrate() afterwards.
html(groups) {
const counter = { n: 0 };
return (groups || []).map(g => groupHtml(g, counter)).join('');
},
// Everything that can only be done once the markup is in the document. Separate from html()
// because a caller that built a bigger string still needs this half, and separate from wire()
// because wire() is idempotent per container while this must run after every re-render.
hydrate(into) {
const box = typeof into === 'string' ? document.getElementById(into) : into;
if (!box) return;
// Seeded from the rendered DOM rather than at build time, so a textarea the browser sized
// differently than expected still gets a gutter matching what is on screen.
box.querySelectorAll('.vv-cf-array').forEach(syncGutter);
return box;
},
render(into, groups) {
const box = typeof into === 'string' ? document.getElementById(into) : into;
if (!box) return;
const html = this.html(groups);
box.innerHTML = html || '<p class="vv-cf-empty">Nothing configurable here.</p>';
this.hydrate(box);
return box;
},
// Only what actually differs from what was parsed. See the note on partial saves above.
collect(into) {
const box = typeof into === 'string' ? document.getElementById(into) : into;
if (!box) return [];
const out = [];
box.querySelectorAll('[data-ctl]').forEach(el => {
const now = readCtl(el);
if (now === el.dataset.orig) return;
out.push({ file: el.dataset.file, key: el.dataset.key,
type: el.dataset.type || 'scalar', value: now });
});
return out;
},
dirtyCount(into) { return this.collect(into).length; },
// Called after a successful save so the next collect() measures against what is now on disk,
// without a refetch. A reload would also work and would repaint every control the operator
// is looking at, including the one they just changed.
commit(into) {
const box = typeof into === 'string' ? document.getElementById(into) : into;
if (!box) return;
box.querySelectorAll('[data-ctl]').forEach(el => { el.dataset.orig = readCtl(el); });
box.querySelectorAll('.vv-cf-field.dirty').forEach(f => f.classList.remove('dirty'));
},
// One delegated listener for a whole container, however many fields it holds.
wire(into, onChange) {
const box = typeof into === 'string' ? document.getElementById(into) : into;
if (!box || box.dataset.vvWired === '1') return;
box.dataset.vvWired = '1';
const refresh = el => {
const field = el.closest('.vv-cf-field');
if (field) field.classList.toggle('dirty', readCtl(el) !== el.dataset.orig);
if (onChange) onChange();
};
box.addEventListener('input', e => {
if (!e.target.dataset.ctl) return;
if (e.target.classList.contains('vv-cf-array')) syncGutter(e.target);
refresh(e.target);
});
// Capture: scroll does not bubble, so a listener on the container never sees a textarea
// scrolling inside it any other way.
box.addEventListener('scroll', e => {
if (e.target.classList && e.target.classList.contains('vv-cf-array')) scrollGutter(e.target);
}, true);
box.addEventListener('change', e => {
const el = e.target;
if (!el.dataset.ctl) return;
if (el.dataset.ctl === 'bool') {
// The switch and the word beside it are both views of the checkbox, which is the only
// thing actually holding the state.
const wrap = el.closest('.vv-cf-toggle');
if (wrap) {
const track = wrap.querySelector('.vv-cf-track');
const state = wrap.querySelector('.vv-cf-state');
if (track) track.classList.toggle('on', el.checked);
if (state) state.textContent = el.checked ? 'true' : 'false';
}
}
if (el.dataset.ctl === 'enum') {
const hint = el.closest('.vv-cf-body')?.querySelector('.vv-cf-opthint');
if (hint) {
let hints = {};
try { hints = JSON.parse(el.dataset.hints || '{}'); } catch (_) {}
hint.textContent = hints[el.value] || '';
}
}
refresh(el);
});
box.addEventListener('click', e => {
const b = e.target.closest('[data-reveal]');
if (!b) return;
const inp = b.closest('.vv-cf-ctl')?.querySelector('input');
if (!inp) return;
const shown = inp.type === 'text';
inp.type = shown ? 'password' : 'text';
b.textContent = shown ? 'Show' : 'Hide';
});
},
};
})();
</script>
<?php
}