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.
This commit is contained in:
@@ -131,17 +131,40 @@ function vv_conf_ui_assets(): void {
|
||||
</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;
|
||||
const gutter = Array.from({ length: n }, (_, i) => i + 1).join('\n');
|
||||
return `<div class="vv-cf-arraywrap">
|
||||
<pre class="vv-cf-lines">${gutter}</pre>
|
||||
<textarea class="vv-cf-array" rows="${Math.min(Math.max(n, 3), 18)}"${attrs(f)}
|
||||
<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)}
|
||||
@@ -152,12 +175,18 @@ function vv_conf_ui_assets(): void {
|
||||
const CONTROLS = { bool: boolCtl, int: intCtl, enum: enumCtl, secret: secretCtl,
|
||||
lines: linesCtl, path: textCtl, text: textCtl };
|
||||
|
||||
function fieldHtml(f) {
|
||||
// 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" data-field="${escA(f.key)}">
|
||||
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>
|
||||
@@ -167,8 +196,11 @@ function vv_conf_ui_assets(): void {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function groupHtml(g) {
|
||||
const fields = (g.fields || []).map(fieldHtml).join('');
|
||||
// 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 || '')}
|
||||
@@ -186,11 +218,32 @@ function vv_conf_ui_assets(): void {
|
||||
}
|
||||
|
||||
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 = (groups || []).map(groupHtml).join('');
|
||||
const html = this.html(groups);
|
||||
box.innerHTML = html || '<p class="vv-cf-empty">Nothing configurable here.</p>';
|
||||
this.hydrate(box);
|
||||
return box;
|
||||
},
|
||||
|
||||
@@ -232,7 +285,16 @@ function vv_conf_ui_assets(): void {
|
||||
if (onChange) onChange();
|
||||
};
|
||||
|
||||
box.addEventListener('input', e => { if (e.target.dataset.ctl) refresh(e.target); });
|
||||
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;
|
||||
|
||||
@@ -68,6 +68,10 @@ require_once dirname(__DIR__) . '/include/ai_profiles.php';
|
||||
// The chat component itself now, not only the store: the assistant panel in the right-hand pane
|
||||
// is an instance of it rather than a second implementation.
|
||||
require_once dirname(__DIR__) . '/include/ai_chat.php';
|
||||
// The shared settings renderer. This page used to draw conf fields itself, which meant every
|
||||
// boolean was a text box and every credential was legible — 329 of its 704 fields deserved a
|
||||
// better control than the one they got.
|
||||
require_once dirname(__DIR__) . '/include/confui.php';
|
||||
|
||||
// Live values for the `$VAR` markers in pages/readme/*.md. Conf variables, plus the derived
|
||||
// path constants — those are not conf keys, but they are exactly what a reader needs resolved
|
||||
@@ -1083,6 +1087,7 @@ Still the same two servers, two households, the same media stack running itself.
|
||||
vv_ai_profiles_script();
|
||||
vv_ai_chat_store_script();
|
||||
vv_ai_chat_assets();
|
||||
vv_conf_ui_assets();
|
||||
?>
|
||||
<!-- The same component the AI tab and the Monitor card render, at this panel's size. It
|
||||
used to be a second implementation with its own bar, send loop and poll — which is how
|
||||
@@ -2181,89 +2186,45 @@ function vvEditConf(id) {
|
||||
fetch('/plugins/varaverk/api/confform.php?id=' + encodeURIComponent(id))
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
cf.innerHTML = (!d.ok || !d.groups || d.groups.length === 0)
|
||||
? '<p class="vv-cf-empty">No configurable settings found for this host.</p>'
|
||||
: vvRenderConfForm(d.groups);
|
||||
vvCfInitArrays(cf);
|
||||
if (!d.ok || !d.groups || d.groups.length === 0) {
|
||||
cf.innerHTML = '<p class="vv-cf-empty">No configurable settings found for this host.</p>';
|
||||
} else {
|
||||
VvConfUI.render(cf, d.groups);
|
||||
// Delegated, so it survives the innerHTML above being replaced on the next script.
|
||||
VvConfUI.wire(cf, vvConfDirty);
|
||||
vvConfDirty();
|
||||
}
|
||||
requestAnimationFrame(vvFitRight);
|
||||
})
|
||||
.catch(() => { cf.innerHTML = '<p class="vv-cf-empty">Failed to load configuration.</p>'; });
|
||||
}
|
||||
|
||||
function vvRenderConfForm(groups) {
|
||||
const esc = s => String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
let html = '';
|
||||
// Continuous numbering across the whole panel, not restarting per group. It mirrors the
|
||||
// editor's line gutter, and it gives every setting one unambiguous handle — "number 12"
|
||||
// beats "the third one under Docker Watchdog" when someone is reading it back to you.
|
||||
// The stripe is driven by this counter rather than :nth-child, because the group header is
|
||||
// also a child and would throw the parity off inside every section.
|
||||
let n = 0;
|
||||
for (const g of groups) {
|
||||
html += '<div class="vv-cf-group">';
|
||||
html += '<div class="vv-cf-group-header">' + esc(g.subsection)
|
||||
+ ' <span class="vv-cf-file">' + esc(g.file) + '</span></div>';
|
||||
for (const f of g.fields) {
|
||||
n++;
|
||||
html += '<div class="vv-cf-field' + (n % 2 === 0 ? ' vv-cf-alt' : '') + '">';
|
||||
html += '<span class="vv-cf-num">' + n + '</span>';
|
||||
html += '<div class="vv-cf-body">';
|
||||
html += '<div class="vv-cf-key">' + esc(f.key) + '</div>';
|
||||
if (f.desc) html += '<div class="vv-cf-desc">' + esc(f.desc) + '</div>';
|
||||
if (f.type === 'scalar') {
|
||||
html += '<input class="vv-cf-input vv-cf-scalar" type="text"'
|
||||
+ ' data-key="' + esc(f.key) + '" data-file="' + esc(f.file) + '" data-type="scalar"'
|
||||
+ ' value="' + esc(f.value) + '">';
|
||||
} else {
|
||||
// Arrays get a numbered gutter and striped rows. DAILY_MAINTENANCE_SCRIPTS is 25 lines
|
||||
// of script paths and interleaved comment blocks; as a bare textarea it reads as one
|
||||
// block of text and you cannot tell where an entry ends. The stripes do the separating
|
||||
// and the numbers give each line something to be referred to by.
|
||||
const rows = Math.min(20, (f.value.match(/\n/g) || []).length + 3);
|
||||
html += '<div class="vv-cf-arraywrap">'
|
||||
+ '<pre class="vv-cf-lines" aria-hidden="true"></pre>'
|
||||
+ '<textarea class="vv-cf-input vv-cf-array"'
|
||||
+ ' data-key="' + esc(f.key) + '" data-file="' + esc(f.file) + '" data-type="' + esc(f.type) + '"'
|
||||
+ ' oninput="vvCfLines(this)" onscroll="vvCfLineScroll(this)"'
|
||||
+ ' rows="' + rows + '">' + esc(f.value) + '</textarea>'
|
||||
+ '</div>';
|
||||
}
|
||||
html += '</div></div>';
|
||||
}
|
||||
html += '</div>';
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
// Line numbers for an array field. The gutter is a plain <pre> scrolled in step with the
|
||||
// textarea — same shape as the main editor's gutter, minus the highlight overlay, because these
|
||||
// stay ordinary editable textareas and nothing here needs syntax colouring.
|
||||
function vvCfLines(ta) {
|
||||
const g = ta.parentElement.querySelector('.vv-cf-lines');
|
||||
if (!g) return;
|
||||
const n = ta.value.split('\n').length;
|
||||
let s = '';
|
||||
for (let i = 1; i <= n; i++) s += i + '\n';
|
||||
g.textContent = s;
|
||||
g.scrollTop = ta.scrollTop;
|
||||
}
|
||||
function vvCfLineScroll(ta) {
|
||||
const g = ta.parentElement.querySelector('.vv-cf-lines');
|
||||
if (g) g.scrollTop = ta.scrollTop;
|
||||
}
|
||||
// Numbers cannot be produced server-side: the count follows the textarea's value, which the user
|
||||
// is about to change. Called once after any render that can contain conf fields.
|
||||
function vvCfInitArrays(root) {
|
||||
(root || document).querySelectorAll('.vv-cf-array').forEach(vvCfLines);
|
||||
// Dirty tracking for the conf form. Both surfaces that draw settings — the Config panel and the
|
||||
// advanced script view — report through here, so the Save button reflects whichever one is open.
|
||||
// The field rendering itself lives in include/confui.php now: this page drew its own for a long
|
||||
// time, which is why 329 of its 704 fields were text boxes that should have been switches,
|
||||
// numbers or lists, and why 48 credentials rendered legibly.
|
||||
function vvConfDirty() {
|
||||
const n = VvConfUI.dirtyCount('vv-confform') + VvConfUI.dirtyCount('vv-si-view');
|
||||
const btn = document.getElementById('vv-save-conf-btn');
|
||||
if (btn && btn.textContent === 'Save Config') btn.disabled = (n === 0);
|
||||
return n;
|
||||
}
|
||||
|
||||
async function vvSaveConf() {
|
||||
if (!vvConfId) return;
|
||||
const _cfName = vvConfId.replace(/\.sh$/, '').split('/').pop();
|
||||
if (!await vvConfirm('Save configuration changes for "' + _cfName + '"?')) return;
|
||||
const inputs = document.querySelectorAll('#vv-confform .vv-cf-input, #vv-si-view .vv-cf-input');
|
||||
const changes = [];
|
||||
inputs.forEach(el => changes.push({key: el.dataset.key, file: el.dataset.file, type: el.dataset.type, value: el.value}));
|
||||
// Only what the operator actually changed, from whichever surface is open. This used to submit
|
||||
// every field on the panel, so changing one threshold rewrote all forty lines of the section —
|
||||
// and each of those rewrites is a chance to lose a comment the conf depends on for its meaning.
|
||||
//
|
||||
// Collected before the confirmation, so an accidental press is answered with "nothing to save"
|
||||
// rather than a dialog asking permission to do nothing.
|
||||
const changes = VvConfUI.collect('vv-confform').concat(VvConfUI.collect('vv-si-view'));
|
||||
if (!changes.length) { vvAlert('No changes to save.'); return; }
|
||||
const n = changes.length;
|
||||
if (!await vvConfirm('Save ' + n + ' change' + (n > 1 ? 's' : '')
|
||||
+ ' for "' + _cfName + '"?')) return;
|
||||
const btn = document.getElementById('vv-save-conf-btn');
|
||||
btn.disabled = true; btn.textContent = 'Saving…';
|
||||
vvPost('/plugins/varaverk/api/confform.php', {id: vvConfId, changes: JSON.stringify(changes)})
|
||||
@@ -2271,7 +2232,12 @@ async function vvSaveConf() {
|
||||
btn.disabled = false;
|
||||
if (d.ok) {
|
||||
btn.textContent = '✓ Saved';
|
||||
setTimeout(() => { btn.textContent = 'Save Config'; }, 2500);
|
||||
// Rebased rather than refetched, so the fields the operator is looking at stay put and
|
||||
// the next collect() measures against what is now on disk. Left dirty on failure: a
|
||||
// refused write changed nothing, and clearing the marks would claim it had.
|
||||
VvConfUI.commit('vv-confform');
|
||||
VvConfUI.commit('vv-si-view');
|
||||
setTimeout(() => { btn.textContent = 'Save Config'; vvConfDirty(); }, 2500);
|
||||
} else {
|
||||
btn.textContent = 'Save Config';
|
||||
vvAlert('Save failed: ' + (d.error ?? 'Unknown error'));
|
||||
@@ -4454,7 +4420,7 @@ function vvShowScriptInfoMode(name, hdr, id) {
|
||||
if (conf.ok && conf.groups?.length) {
|
||||
html += '<div class="vv-sinfo-block">'
|
||||
+ '<div class="vv-sinfo-lbl">Config</div>'
|
||||
+ vvRenderConfForm(conf.groups)
|
||||
+ VvConfUI.html(conf.groups)
|
||||
+ '</div>';
|
||||
vvConfId = id;
|
||||
document.getElementById('vv-cancel-edit-btn').style.display = '';
|
||||
@@ -4471,7 +4437,11 @@ function vvShowScriptInfoMode(name, hdr, id) {
|
||||
}
|
||||
|
||||
si.innerHTML = html || '<p class="vv-cf-empty">No additional information found.</p>';
|
||||
vvCfInitArrays(si);
|
||||
// The Config block was composed into a larger string above, so the DOM half of rendering
|
||||
// still has to happen here.
|
||||
VvConfUI.hydrate(si);
|
||||
VvConfUI.wire(si, vvConfDirty);
|
||||
vvConfDirty();
|
||||
requestAnimationFrame(vvFitRight);
|
||||
}).catch(() => { si.innerHTML = '<p class="vv-cf-empty">Failed to load info.</p>'; });
|
||||
|
||||
|
||||
Reference in New Issue
Block a user