['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"; $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"; } } 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));