Files
Varaverk/Plugin/unraid/include/confui.php
T
Gmer4Lfe 67510b8fad Draw the Scheduler's settings with the same renderer as everything else
It drew its own, so 329 of its 704 fields were text boxes that should have been
switches, numbers or lists, and 48 credentials rendered legibly.
2026-08-11 20:47:49 -04:00

338 lines
15 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
// ═══════════════════════════════════════════════════════════════════════════════════════════════
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
}