Files
Varaverk/Plugin/unraid/Tools/ui_map_build.php
T

275 lines
15 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.
//
// OPERATIONAL MODEL
// Reads the section registries and the conf files, resolves each setting to the page and card
// that renders it, and writes the whole map in one pass. Nothing is merged with what is
// already there — the output is derived entirely from the registries, so a stale entry cannot
// survive a rebuild.
//
// DESIGN PRINCIPLES
// The assistant cannot see the UI any other way.
// 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/.
//
// Generated, because a hand-written map lies with confidence.
// A second description of the pages starts being wrong the moment a card moves, and that
// 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.
//
// An unreachable section is reported, never dropped.
// A section no page renders is listed at the end rather than silently omitted. A setting
// with no route through the UI is a real finding, and this map is the only thing that
// would ever notice.
//
// OPERATIONAL SAFEGUARDS
// Writes exactly one file, pages/readme/ui-map.md, and nothing else. No conf is modified, no
// page is touched, and the registries it reads are only read.
//
// --check reports what would change and writes nothing, so the map can be verified current in
// a commit without regenerating it.
//
// Generated output only. Nothing hand-edited belongs in ui-map.md — an edit there is lost on
// the next run, which is the correct behaviour for a derived file and the reason the header
// says so.
//
// RUNTIME MODES
// php Tools/ui_map_build.php write the map
// php Tools/ui_map_build.php --check report what it would change, write nothing
//
// Hand-run. Re-run after adding a conf section, a script mapping or a settings surface.
//
// 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) {
// "*" 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.
// Same matching as api/confform.php, including the pipe-separated form — the map and the page
// must agree about which sections a surface shows, or the route it prints is fiction.
$all = ((string) $surface['match'] === '*');
$re = $all ? '' : '/\b(' . implode('|', array_map(
fn($w) => preg_quote(trim($w), '/'),
array_filter(explode('|', (string) $surface['match']), fn($w) => trim($w) !== '')))
. ')\b/i';
foreach ($sections as $k => $g) {
$name = (string) $g['subsection'];
if (isset(VV_UI_SECTION_EXCLUDE[$name])) continue;
if (!$all && !preg_match($re, $name)) continue;
$routes[$k][] = $surface['route'] . " → *{$name}*";
}
}
// ── 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";
$md .= "**When someone asks where a setting is, answer with the route.** Every setting here has a\n"
. "control in the browser, so the conf file it lands in is background rather than an\n"
. "instruction — telling someone to edit the file by hand is the wrong answer when a switch\n"
. "exists, and it is also the riskier one. The exception is the list at the very bottom:\n"
. "those settings genuinely have no control, and saying so is the right answer.\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";
// The route leads. Naming the conf file first invited answers that told the operator to edit
// host1.conf and mentioned the tab as an afterthought — which is the habit this file exists
// to break. The file is still stated, because "where does this end up" is a fair question,
// but it is stated last and as a fact rather than as an instruction.
$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 .= "Saved into `" . $g['file'] . "`, which does not need to be opened by hand.\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";
}
}
// 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 .= "Not a decision, just not built yet: no page renders these, so they can only be changed\n"
. "by editing the conf file. If one of these is asked about, say so plainly rather than\n"
. "inventing a route.\n\n";
foreach ($gaps 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));