Tell the assistant where the settings are, not just what they are called

The index reads tracked files and page markup is not one, so it could name a
conf key and never say there was a button for it.
This commit is contained in:
Gmer4Lfe
2026-08-11 22:07:10 -04:00
parent 999dbcfdf3
commit 240876e710
4 changed files with 1711 additions and 1 deletions
+217
View File
@@ -0,0 +1,217 @@
<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// Writes pages/readme/ui-map.md — where every conf setting lives in the web UI, and the route
// to reach it. Generated so the assistant can answer "how do I change X" with a path through
// the pages instead of an instruction to open master.conf.
//
// WHY THE ASSISTANT NEEDS THIS AT ALL
// The retrieval index reads git-tracked files. PHP body markup is not indexed and would be
// useless if it were — a page is a pile of divs, not a description of itself — so the assistant
// has never had any way to know the UI exists. It could name a conf key and nothing more.
// pages/readme/*.md is the one directory the chunker classifies as kind='ui', which is why the
// output lands there and not in docs/.
//
// WHY IT IS GENERATED
// A hand-written map is a second description of the pages, and the moment a card moves it
// starts lying with total confidence — which is worse than saying nothing, because the
// assistant will repeat it. Everything here is derived from the same registries the pages
// themselves are built from: VV_SCRIPT_CONF_SECTIONS for what the Scheduler shows per script,
// VV_UI_SECTION_SURFACES for the pages that show sections by subject, and the conf files for
// the settings and their controls.
//
// OPERATIONAL MODEL
// Hand-run, and re-run after adding a conf section, a script mapping or a settings surface.
// Writes exactly one file and nothing else.
//
// php Tools/ui_map_build.php write the map
// php Tools/ui_map_build.php --check report what it would change, write nothing
//
// Only sections that are genuinely reachable are listed. A section no page renders is reported
// at the end as unreachable rather than silently omitted — a setting with no route is a real
// finding, and the map is the only place that would notice.
//
// DEPENDS ON
// include/confform.php the section registries, the parser, and the inferred controls
// include/scheduler.php vv_pretty_label() — the same script naming the Scheduler cards use,
// so a route names the card the operator is actually looking for
// ═══════════════════════════════════════════════════════════════════════════════════════════════
require_once __DIR__ . '/../include/confform.php';
require_once __DIR__ . '/../include/scheduler.php';
$check = in_array('--check', $argv, true);
$outRel = 'Plugin/unraid/pages/readme/ui-map.md';
$outAbs = dirname(__DIR__) . '/pages/readme/ui-map.md';
// ── Gather every section in every conf file this host can see ────────────────────────────────
$sections = []; // "file\0subsection" => ['file','subsection','fields']
foreach (vv_get_conf_files() as $file) {
foreach (vv_conf_all_groups($file) as $g) {
if (empty($g['fields'])) continue;
$sections[$file . "\0" . $g['subsection']] = $g;
}
}
// ── Route 1: the Scheduler, one script at a time ─────────────────────────────────────────────
// A section may be reached through several scripts — a shared threshold belongs to whichever
// scripts read it — so routes accumulate rather than overwrite.
$routes = []; // section key => list of human routes
foreach (VV_SCRIPT_CONF_SECTIONS as $script => $subs) {
$label = vv_pretty_label(basename($script, '.sh'));
foreach ((array) $subs as $sub) {
foreach ($sections as $k => $g) {
if (strcasecmp($g['subsection'], $sub) !== 0) continue;
$routes[$k][] = "Scheduler tab → **{$label}** → Config → *{$g['subsection']}*";
}
}
}
// ── Route 2: pages that show sections by subject ─────────────────────────────────────────────
foreach (VV_UI_SECTION_SURFACES as $surface) {
$re = '/\b' . preg_quote((string) $surface['match'], '/') . '\b/i';
foreach ($sections as $k => $g) {
if (!preg_match($re, (string) $g['subsection'])) continue;
$routes[$k][] = $surface['route'] . " → *{$g['subsection']}*";
}
}
// ── Route 3: pages with a purpose-built control for one named setting ────────────────────────
// Read out of the page source rather than declared, so a control that is added or removed moves
// the map with it. The pattern is the literal key in a change payload or a toggle call — the one
// shape these pages have in common. Keys assembled at runtime are invisible here and correctly
// fall through to the conf-only list rather than being guessed at.
// Every key this host's confs actually define, so a declared route can be checked against
// reality rather than trusted.
$known = [];
foreach ($sections as $g) foreach ($g['fields'] as $f) $known[$f['key']] = true;
// This machine's host slot, for substituting HOSTN in declared keys.
$slot = 'HOST1';
foreach (vv_get_conf_files() as $f) {
if (preg_match('/^host(\d+)\.conf$/i', $f, $hm)) { $slot = 'HOST' . $hm[1]; break; }
}
$keyRoutes = []; // KEY => list of routes
$stale = [];
foreach (VV_UI_PAGE_ROUTES as $page => $spec) {
$route = is_array($spec) ? $spec['route'] : $spec;
$src = @file_get_contents(dirname(__DIR__) . '/pages/' . $page);
if ($src === false) { fwrite(STDERR, "note: $page not found, skipped\n"); continue; }
if (preg_match_all('/(?:key|name)\s*:\s*\'([A-Z][A-Z0-9_]{3,})\'|\(this,\s*\'([A-Z][A-Z0-9_]{3,})\'\)/',
$src, $m, PREG_SET_ORDER)) {
foreach ($m as $hit) {
$key = $hit[1] !== '' ? $hit[1] : ($hit[2] ?? '');
if ($key !== '' && isset($known[$key])) $keyRoutes[$key][] = $route;
}
}
foreach ((array) (is_array($spec) ? ($spec['also'] ?? []) : []) as $decl) {
$key = str_replace('HOSTN', $slot, $decl);
if (isset($known[$key])) { $keyRoutes[$key][] = $route; continue; }
$stale[] = "$page declares $decl (→ $key) which no conf defines";
}
}
// ── How each control is described to someone who has to find it ──────────────────────────────
const UI_CONTROL_WORDS = [
'bool' => 'a switch',
'int' => 'a number box',
'enum' => 'a dropdown',
'secret' => 'a masked box with a **Show** button',
'lines' => 'a list, one entry per line',
'path' => 'a text box',
'text' => 'a text box',
];
$md = "# Where every setting lives in the web UI\n\n";
$md .= "Generated by `Tools/ui_map_build.php` — do not edit by hand.\n\n";
$md .= "Every setting below can be changed in the browser. Nothing here needs a conf file opened\n"
. "over SSH, and the routes are what to tell someone who asks where a setting is.\n\n";
$md .= "Two surfaces show settings, and which one holds a given section depends on what the\n"
. "section is about:\n\n";
$md .= "- **The Scheduler tab** shows the settings belonging to one script. Pick the script, open\n"
. " **Config**, and its sections appear there.\n";
$md .= "- **The AI tab** shows the AI sections together under **Settings → Configuration**.\n\n";
$md .= "Both write through the same guarded path: the change is validated, the conf is backed up,\n"
. "the result is syntax-checked and read back, and a bad write is rolled back.\n\n";
$md .= "A setting is edited by finding its row and changing the control described below. The Save\n"
. "button sends only what was actually changed.\n\n---\n";
// A section with no section-level route may still have per-key routes, if a page carries a
// purpose-built control for some of its settings. That is a reachable section — just one whose
// route is stated per row rather than once at the top.
foreach ($sections as $k => $g) {
if (!empty($routes[$k])) continue;
foreach ($g['fields'] as $f) {
if (!empty($keyRoutes[$f['key']])) { $routes[$k][] = '__perkey__'; break; }
}
}
$reachable = 0; $unreachable = [];
ksort($sections);
foreach ($sections as $k => $g) {
if (empty($routes[$k])) { $unreachable[] = $g; continue; }
$reachable++;
$md .= "\n## " . $g['subsection'] . "\n\n";
$md .= "In `" . $g['file'] . "`.\n\n";
$seen = array_values(array_diff(array_unique($routes[$k]), ['__perkey__']));
if (!$seen) {
$md .= "No single page shows this section. Individual settings below carry their own route.\n\n";
} else {
$md .= count($seen) === 1 ? "Route: " . $seen[0] . "\n\n"
: "Reachable from:\n\n" . implode("\n", array_map(fn($r) => "- $r", $seen)) . "\n\n";
}
$md .= "| Setting | Control | Where | What it does |\n|---|---|---|---|\n";
foreach ($g['fields'] as $f) {
$ctl = UI_CONTROL_WORDS[$f['widget'] ?? 'text'] ?? 'a text box';
if (($f['widget'] ?? '') === 'enum' && !empty($f['choices'])) {
$ctl .= ' (' . implode(', ', array_map(fn($c) => $c['value'], $f['choices'])) . ')';
}
if (!empty($f['unit'])) $ctl .= ', in ' . $f['unit'];
if (isset($f['min'])) $ctl .= ', ' . $f['min'] . '' . $f['max'];
// The conf's own comment. Newlines and pipes would break the table row.
$desc = trim(preg_replace('/\s+/', ' ', (string) ($f['desc'] ?? '')));
$desc = str_replace('|', '\\|', $desc);
if (mb_strlen($desc) > 400) $desc = mb_substr($desc, 0, 397) . '…';
// A per-key route wins for that row: a purpose-built control is a better answer than
// "somewhere in this section", and it is often on a different page entirely.
$where = !empty($keyRoutes[$f['key']])
? implode(', ', array_unique($keyRoutes[$f['key']]))
: ($seen ? 'in this section' : '—');
$md .= '| `' . $f['key'] . '` | ' . $ctl . ' | ' . $where . ' | '
. ($desc !== '' ? $desc : '—') . " |\n";
}
}
if ($unreachable) {
$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"
. "the conf file. If one of these is asked about, say so plainly rather than inventing a\n"
. "route — mapping it into the Scheduler is a code change, not a setting.\n\n";
foreach ($unreachable as $g) {
$keys = implode(', ', array_map(fn($f) => '`' . $f['key'] . '`', $g['fields']));
$md .= '- **' . $g['subsection'] . '** (`' . $g['file'] . "`) — $keys\n";
}
}
$existing = is_readable($outAbs) ? file_get_contents($outAbs) : null;
$same = $existing !== null && $existing === $md;
foreach ($stale as $s) fwrite(STDERR, "STALE ROUTE: $s\n");
printf("%d sections reachable, %d with no UI route\n", $reachable, count($unreachable));
printf("%d settings documented\n", array_sum(array_map(
fn($k) => empty($routes[$k]) ? 0 : count($sections[$k]['fields']), array_keys($sections))));
if ($check) {
echo $same ? "up to date\n" : "OUT OF DATE — re-run without --check\n";
exit($same ? 0 : 1);
}
if ($same) { echo "no change\n"; exit(0); }
if (@file_put_contents($outAbs, $md) === false) {
fwrite(STDERR, "could not write $outAbs\n");
exit(1);
}
printf("wrote %s (%d bytes)\n", $outRel, strlen($md));
+44
View File
@@ -94,6 +94,50 @@ require_once __DIR__ . '/config.php';
// confform.php — script→conf-section mapping, field parsing, and write-back. // confform.php — script→conf-section mapping, field parsing, and write-back.
// Which page shows conf sections by subject rather than by script, and the route a person would
// be told to follow to reach it. Declared once because two things need it and they must agree:
// the page itself builds its ?sections= query from this, and Tools/ui_map_build.php turns it into
// navigation instructions the assistant can give. A route written down in only one of those two
// places is a route that goes stale the first time a card moves.
//
// `match` is the whole-word needle matched against section headers — see api/confform.php.
const VV_UI_SECTION_SURFACES = [
[
'match' => 'ai',
'tab' => 'AI',
'route' => 'AI tab → Settings → Configuration',
],
[
'match' => 'partnership',
'tab' => 'Partnership',
// Array fields only — the card is one collapsible block per list, because these are
// container lists dozens of lines long and a flat form would be unreadable.
'route' => 'Partnership tab → Array Settings',
],
];
// 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
// read out of the page source by Tools/ui_map_build.php, so a control added or removed changes
// the map without anyone remembering to update a list.
//
// A page missing from here is not broken; its settings simply appear as conf-only in the map,
// which is the honest answer until someone gives it a route.
// `also` lists keys the page assembles at runtime, which no amount of reading the source will
// reveal — settings.php writes HOST1_DISCORD_WEBHOOK through a PHP variable holding the host
// slot. Written with HOSTN, substituted per machine, the same convention conf_upgrade uses. The
// generator checks each one exists and complains if it does not, so a stale entry is loud rather
// than a route to a control that was removed.
const VV_UI_PAGE_ROUTES = [
'settings.php' => ['route' => 'Settings tab',
'also' => ['HOSTN_DISCORD_WEBHOOK', 'HOSTN_STORAGE_MODE_INTERNAL']],
'fallback.php' => ['route' => 'Fallback tab'],
'rsync.php' => ['route' => 'Rsync tab'],
'arrs.php' => ['route' => 'Arrs tab'],
'watchdog.php' => ['route' => 'Watchdog tab'],
'docker.php' => ['route' => 'Docker tab'],
];
// Map: script relative id → subsection names (must match # ━━━ Name ━━━ or # ── Name ── headers). // Map: script relative id → subsection names (must match # ━━━ Name ━━━ or # ── Name ── headers).
const VV_SCRIPT_CONF_SECTIONS = [ const VV_SCRIPT_CONF_SECTIONS = [
// Orchestrators // Orchestrators
+5 -1
View File
@@ -461,6 +461,10 @@ vv_ai_chat_markup('vv-ai', [
// the Scheduler uses, so there is one allowlist and one write path rather than an AI-shaped // the Scheduler uses, so there is one allowlist and one write path rather than an AI-shaped
// copy of both — api/ai.php deliberately has no conf-writing action at all. // copy of both — api/ai.php deliberately has no conf-writing action at all.
const API_CONF = '/plugins/varaverk/api/confform.php'; const API_CONF = '/plugins/varaverk/api/confform.php';
// Which sections this tab claims, from the one registry that also generates the UI map. Hard
// coding "ai" here again would be a second place for the answer to live, and the map would go
// on describing a route this page had stopped taking.
const CONF_SECTIONS = <?= json_encode(VV_UI_SECTION_SURFACES[0]['match'] ?? 'ai') ?>;
// The conversation itself — profiles, transcript, composer, source viewer, storage — is // The conversation itself — profiles, transcript, composer, source viewer, storage — is
// include/ai_chat.php. What remains on this page is everything that surrounds it and exists // include/ai_chat.php. What remains on this page is everything that surrounds it and exists
@@ -1098,7 +1102,7 @@ vv_ai_chat_markup('vv-ai', [
function loadConf() { function loadConf() {
if (confLoaded) return; if (confLoaded) return;
confLoaded = true; confLoaded = true;
fetch(API_CONF + '?sections=ai').then(r => r.json()) fetch(API_CONF + '?sections=' + encodeURIComponent(CONF_SECTIONS)).then(r => r.json())
.then(d => { .then(d => {
if (!d.ok) { $('vv-ai-conf').innerHTML = `<div class="vv-ai-none">${esc(d.error || 'could not be read')}</div>`; return; } if (!d.ok) { $('vv-ai-conf').innerHTML = `<div class="vv-ai-none">${esc(d.error || 'could not be read')}</div>`; return; }
VvConfUI.render('vv-ai-conf', d.groups || []); VvConfUI.render('vv-ai-conf', d.groups || []);
File diff suppressed because it is too large Load Diff