Files
Varaverk/Plugin/unraid/Tools/ui_map_build.php
T
Gmer4Lfe 240876e710 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.
2026-08-11 22:07:10 -04:00

218 lines
12 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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));