275 lines
15 KiB
PHP
275 lines
15 KiB
PHP
<?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.
|
||
//
|
||
// 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 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.
|
||
//
|
||
// 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));
|