Give the rest of the conf a place to be changed

A third of the settings had no control anywhere, so the answer to "where do I
change this" was to open a file over SSH.
This commit is contained in:
Gmer4Lfe
2026-08-12 19:41:01 -04:00
parent ec3084db12
commit 8ca52c64b9
5 changed files with 889 additions and 128 deletions
+33 -8
View File
@@ -68,10 +68,16 @@ foreach (VV_SCRIPT_CONF_SECTIONS as $script => $subs) {
// ── Route 2: pages that show sections by subject ───────────────────────────────────────────── // ── Route 2: pages that show sections by subject ─────────────────────────────────────────────
foreach (VV_UI_SECTION_SURFACES as $surface) { foreach (VV_UI_SECTION_SURFACES as $surface) {
$re = '/\b' . preg_quote((string) $surface['match'], '/') . '\b/i'; // "*" is the catch-all, matched the same way api/confform.php matches it. preg_quote would
// turn it into \* and quietly match nothing, which is how the map went on reporting a third
// of the conf as unreachable after the page that reaches it had shipped.
$all = ((string) $surface['match'] === '*');
$re = $all ? '' : '/\b' . preg_quote((string) $surface['match'], '/') . '\b/i';
foreach ($sections as $k => $g) { foreach ($sections as $k => $g) {
if (!preg_match($re, (string) $g['subsection'])) continue; $name = (string) $g['subsection'];
$routes[$k][] = $surface['route'] . " → *{$g['subsection']}*"; if (isset(VV_UI_SECTION_EXCLUDE[$name])) continue;
if (!$all && !preg_match($re, $name)) continue;
$routes[$k][] = $surface['route'] . " → *{$name}*";
} }
} }
@@ -194,12 +200,31 @@ foreach ($sections as $k => $g) {
} }
} }
if ($unreachable) { // Two different kinds of "no route", and conflating them was unhelpful. One is a gap; the other
// is a decision, and the decision has a reason worth repeating to whoever asks.
$excluded = array_filter($unreachable, fn($g) => isset(VV_UI_SECTION_EXCLUDE[$g['subsection']]));
$gaps = array_filter($unreachable, fn($g) => !isset(VV_UI_SECTION_EXCLUDE[$g['subsection']]));
if ($excluded) {
$md .= "\n---\n\n## Settings deliberately kept out of the UI\n\n";
$md .= "These have no control on purpose. They hold the machine's identity and the roots\n"
. "everything else is derived from, and a text box beside a Save button is the wrong\n"
. "shape for a value that decides whether the server recognises itself on next boot.\n\n"
. "If one is asked about, give the reason and say it is edited in the conf file directly.\n"
. "Do not describe a route — there is none, and that is the point.\n\n";
foreach ($excluded as $g) {
$keys = implode(', ', array_map(fn($f) => '`' . $f['key'] . '`', $g['fields']));
$md .= '- **' . $g['subsection'] . '** (`' . $g['file'] . "`) — $keys \n"
. ' ' . VV_UI_SECTION_EXCLUDE[$g['subsection']] . "\n";
}
}
if ($gaps) {
$md .= "\n---\n\n## Settings with no route through the UI\n\n"; $md .= "\n---\n\n## Settings with no route through the UI\n\n";
$md .= "These sections are not rendered by any page, so they can only be changed by editing\n" $md .= "Not a decision, just not built yet: no page renders these, so they can only be changed\n"
. "the conf file. If one of these is asked about, say so plainly rather than inventing a\n" . "by editing the conf file. If one of these is asked about, say so plainly rather than\n"
. "route — mapping it into the Scheduler is a code change, not a setting.\n\n"; . "inventing a route.\n\n";
foreach ($unreachable as $g) { foreach ($gaps as $g) {
$keys = implode(', ', array_map(fn($f) => '`' . $f['key'] . '`', $g['fields'])); $keys = implode(', ', array_map(fn($f) => '`' . $f['key'] . '`', $g['fields']));
$md .= '- **' . $g['subsection'] . '** (`' . $g['file'] . "`) — $keys\n"; $md .= '- **' . $g['subsection'] . '** (`' . $g['file'] . "`) — $keys\n";
} }
+10 -2
View File
@@ -105,11 +105,19 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') {
// //
// preg_quote first: the needle arrives from a query string, so it is matched as a literal // preg_quote first: the needle arrives from a query string, so it is matched as a literal
// with boundaries around it rather than as a pattern a caller could widen to everything. // with boundaries around it rather than as a pattern a caller could widen to everything.
$re = '/\b' . preg_quote($match, '/') . '\b/i'; // "*" is every section this host has. Deliberately not the same as an empty needle, which
// would match everything by accident rather than on purpose.
$all = ($match === '*');
$re = $all ? '' : '/\b' . preg_quote($match, '/') . '\b/i';
$out = []; $out = [];
foreach (vv_get_conf_files() as $f) { foreach (vv_get_conf_files() as $f) {
foreach (vv_conf_all_groups($f) as $g) { foreach (vv_conf_all_groups($f) as $g) {
if (preg_match($re, (string) ($g['subsection'] ?? ''))) $out[] = $g; $name = (string) ($g['subsection'] ?? '');
// Excluded everywhere, not only from the catch-all. A section kept out of forms
// because a text box is the wrong control for it does not become the right
// control because a different page asked.
if (isset(VV_UI_SECTION_EXCLUDE[$name])) continue;
if ($all || preg_match($re, $name)) $out[] = $g;
} }
} }
echo json_encode(['ok' => true, 'groups' => $out]); echo json_encode(['ok' => true, 'groups' => $out]);
+32
View File
@@ -107,6 +107,14 @@ const VV_UI_SECTION_SURFACES = [
'tab' => 'AI', 'tab' => 'AI',
'route' => 'AI tab → Settings → Configuration', 'route' => 'AI tab → Settings → Configuration',
], ],
// Everything that is not structurally excluded. The catch-all exists so no ordinary setting
// is reachable only by editing a file — a settings page whose answer to a third of the conf
// is "open it over SSH" is not a settings page.
[
'match' => '*',
'tab' => 'Settings',
'route' => 'Settings tab → All settings',
],
[ [
'match' => 'partnership', 'match' => 'partnership',
'tab' => 'Partnership', 'tab' => 'Partnership',
@@ -116,6 +124,30 @@ const VV_UI_SECTION_SURFACES = [
], ],
]; ];
// Sections deliberately kept out of every settings surface, and why. Not a security boundary —
// vv_conf_edit() still guards the write and the raw editor can still reach them. This is about
// what belongs in a form: these hold the machine's identity and the roots everything else is
// derived from, and a text box next to a Save button is the wrong shape for a value that decides
// whether the server recognises itself on next boot.
//
// The reason travels with the exclusion because the UI map prints it. "No route" invites someone
// to add one; "no route, because changing this moves the entire data tree" does not.
const VV_UI_SECTION_EXCLUDE = [
'HOST IDENTITIES' =>
'HOST1 and HOST2 are what detect_hosts() matches the local hostname against. Editing one '
. 'here would change which machine this believes it is.',
'SHARED HOST CONFIGURATION' =>
'DATA_DIR is the single on-disk root — move it and the whole tree follows, including the '
. 'state the running scripts are holding open.',
'Cache Roots' =>
'The tmpfs half. These must never point at flash, and nothing in a form conveys that.',
'State Files' =>
'Paths to live state databases, derived from STATE_DIR. Repointing one orphans the state '
. 'a watchdog is mid-way through writing.',
'GIT / REPO' =>
'Where the plugin updates itself from. Wrong here means the next pull is the last one.',
];
// Pages that edit named keys rather than whole sections — a purpose-built control for one // Pages that edit named keys rather than whole sections — a purpose-built control for one
// setting, not a form over a conf section. Only the route is declared: the keys themselves are // setting, not a form over a conf section. Only the route is declared: the keys themselves are
// read out of the page source by Tools/ui_map_build.php, so a control added or removed changes // read out of the page source by Tools/ui_map_build.php, so a control added or removed changes
File diff suppressed because it is too large Load Diff
+151
View File
@@ -49,6 +49,10 @@ $_discordHook = $_vars[$_discordKey] ?? '';
$_aiHost = vv_is_ai_host(); $_aiHost = vv_is_ai_host();
$_aiEnabled = strtolower(trim($_vars['AI_ENABLED'] ?? 'false')) === 'true'; $_aiEnabled = strtolower(trim($_vars['AI_ENABLED'] ?? 'false')) === 'true';
// The shared settings renderer, for the All-settings card at the foot of the page.
require_once dirname(__DIR__) . '/include/confui.php';
vv_conf_ui_assets();
?> ?>
<style> <style>
.vv-set-card { background:#161616;border:1px solid #2a2a2a;border-radius:6px;padding:14px 16px;margin-bottom:14px; } .vv-set-card { background:#161616;border:1px solid #2a2a2a;border-radius:6px;padding:14px 16px;margin-bottom:14px; }
@@ -83,6 +87,20 @@ $_aiEnabled = strtolower(trim($_vars['AI_ENABLED'] ?? 'false')) === 'true';
font-size:12px;padding:5px 8px;outline:none;width:100%;box-sizing:border-box; font-size:12px;padding:5px 8px;outline:none;width:100%;box-sizing:border-box;
font-family:monospace; } font-family:monospace; }
.vv-set-inp:focus{ border-color:#444; } .vv-set-inp:focus{ border-color:#444; }
/* The All-settings header. A row rather than a heading because it carries the disclosure, the
filter and the save together — the filter is only meaningful while the card is open, and the
save only while something is dirty, so both appear with what they act on. */
.vv-cf-all-h { display:flex;align-items:center;gap:10px;cursor:pointer;user-select:none;
flex-wrap:wrap; }
.vv-cf-all-c { color:#333;font-size:9px;transition:transform .12s; }
.vv-cf-all-h.open .vv-cf-all-c { transform:rotate(90deg); }
.vv-cf-all-f { max-width:260px;cursor:text; }
/* Scrolls itself rather than growing the page past the viewport — 124 sections is far taller
than a screen, and the Save button belongs where it can still be reached. */
#vv-cf-all { max-height:60vh;overflow-y:auto;margin-top:10px;padding-right:4px; }
/* Hidden by the filter. display:none rather than removal, so the controls keep their state and
a field edited before filtering is still dirty and still saved. */
.vv-cf-group.vv-filtered { display:none; }
</style> </style>
<div style="max-width:740px;margin:0 auto;"> <div style="max-width:740px;margin:0 auto;">
@@ -220,6 +238,31 @@ $_aiEnabled = strtolower(trim($_vars['AI_ENABLED'] ?? 'false')) === 'true';
<pre class="vv-set-out" id="vv-api-out"></pre> <pre class="vv-set-out" id="vv-api-out"></pre>
</div> </div>
<!-- Everything else the conf holds. The cards above are purpose-built for the few settings that
needed a workflow — a migration, a renewal, a webhook test. This is the rest: a third of the
configuration had no control anywhere, so the answer to "where do I change X" was "open a
file over SSH", which is not an answer a settings page should give.
Collapsed and loaded on first open. 124 sections is real work to assemble and the card is
shut almost every time the page is drawn.
Five sections are deliberately absent — the machine's identity and the roots everything
derives from. See VV_UI_SECTION_EXCLUDE for which, and why each one. -->
<div class="vv-set-card">
<div class="vv-cf-all-h" id="vv-cf-all-t">
<span class="vv-cf-all-c">▶</span>
<span class="vv-set-hdr" style="margin-bottom:0;">All settings</span>
<input type="text" class="vv-set-inp vv-cf-all-f" id="vv-cf-all-filter"
placeholder="filter by name or setting…" style="display:none">
<span class="vv-set-sum" id="vv-cf-all-sum" style="margin-left:auto"></span>
<button class="vv-set-btn primary" id="vv-cf-all-save" type="button"
style="display:none" disabled>Save</button>
</div>
<div id="vv-cf-all-body" style="display:none">
<div id="vv-cf-all"><p class="vv-cf-empty">opens when you do</p></div>
</div>
</div>
</div> </div>
<script> <script>
@@ -486,4 +529,112 @@ function vvApiRenew() {
// Auto-check on load // Auto-check on load
vvApiCheck(); vvApiCheck();
// ── All settings ─────────────────────────────────────────────────────────────
// Every conf section that is not structurally excluded, drawn by the same renderer the AI tab
// and the Scheduler use and written through the same guarded path. Nothing about which sections
// exist or what control each field gets is decided here — it is read from the conf.
const VV_CF_ALL_API = '/plugins/varaverk/api/confform.php';
let _vvCfAllLoaded = false;
function vvCfAllToggle() {
const head = document.getElementById('vv-cf-all-t');
const body = document.getElementById('vv-cf-all-body');
const open = body.style.display === 'none';
body.style.display = open ? '' : 'none';
head.classList.toggle('open', open);
document.getElementById('vv-cf-all-filter').style.display = open ? '' : 'none';
document.getElementById('vv-cf-all-save').style.display = open ? '' : 'none';
// First open only. Reloading on every toggle would discard edits in progress the moment
// someone collapsed the card to look at something else.
if (open) vvCfAllLoad();
}
function vvCfAllLoad() {
if (_vvCfAllLoaded) return;
_vvCfAllLoaded = true;
fetch(VV_CF_ALL_API + '?sections=*')
.then(r => r.json())
.then(d => {
if (!d.ok) throw new Error(d.error || 'could not be read');
VvConfUI.render('vv-cf-all', d.groups || []);
VvConfUI.wire('vv-cf-all', vvCfAllDirty);
vvCfAllDirty();
})
.catch(e => {
// Retryable: the flag goes back, so closing and reopening tries again rather than
// leaving a permanent error where the settings should be.
_vvCfAllLoaded = false;
document.getElementById('vv-cf-all').innerHTML =
'<p class="vv-cf-empty">could not be read: ' + String(e.message || e) + '</p>';
});
}
function vvCfAllDirty() {
const n = VvConfUI.dirtyCount('vv-cf-all');
const btn = document.getElementById('vv-cf-all-save');
const sum = document.getElementById('vv-cf-all-sum');
btn.disabled = (n === 0);
sum.textContent = n ? (n + ' unsaved') : '';
sum.style.color = n ? '#ffb74d' : '#444';
}
// Hides whole sections, never individual fields — a setting means little without the header that
// says what it belongs to. Matches the section name and the key names inside it, because people
// look for both. display:none rather than removal, so a field edited before filtering is still
// dirty and still saved: a filter that silently drops pending edits would be a data-loss bug
// wearing a search box.
function vvCfAllFilter() {
const q = document.getElementById('vv-cf-all-filter').value.trim().toLowerCase();
document.querySelectorAll('#vv-cf-all .vv-cf-group').forEach(g => {
g.classList.toggle('vv-filtered', q !== '' && !g.textContent.toLowerCase().includes(q));
});
}
function vvCfAllSave() {
const changes = VvConfUI.collect('vv-cf-all');
if (!changes.length) return;
const btn = document.getElementById('vv-cf-all-save');
const sum = document.getElementById('vv-cf-all-sum');
btn.disabled = true;
sum.textContent = 'saving…';
sum.style.color = '#444';
// URLSearchParams, not FormData — a multipart POST to this plugin's endpoints hangs with no
// status ever returned.
fetch(VV_CF_ALL_API, { method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
body: new URLSearchParams({ changes: JSON.stringify(changes) }) })
.then(r => r.text())
.then(t => {
let d;
try { d = JSON.parse(t); }
catch (_) {
throw new Error(t.trim() ? 'unparseable response'
: 'empty response — rejected before the endpoint ran');
}
if (!d.ok) throw new Error(d.error || 'save failed');
// Rebased rather than refetched: the values on screen are now the values on disk, and a
// reload would repaint every control including the one just edited.
VvConfUI.commit('vv-cf-all');
vvCfAllDirty();
sum.textContent = 'saved ' + changes.length + ' change' + (changes.length > 1 ? 's' : '');
sum.style.color = '#4caf50';
})
.catch(e => {
// Left dirty on purpose. A refused write changed nothing, and clearing the marks would
// say it had.
sum.textContent = String(e.message || e);
sum.style.color = '#ef5350';
vvCfAllDirty();
});
}
document.getElementById('vv-cf-all-t').addEventListener('click', e => {
// The filter and the save live in the header; clicking either must not collapse the card.
if (e.target.closest('#vv-cf-all-filter, #vv-cf-all-save')) return;
vvCfAllToggle();
});
document.getElementById('vv-cf-all-filter').addEventListener('input', vvCfAllFilter);
document.getElementById('vv-cf-all-save').addEventListener('click', vvCfAllSave);
</script> </script>