The comments above a setting already state its choices, units and bounds, so the form can read them instead of asking for the file to be annotated first.
211 lines
12 KiB
PHP
211 lines
12 KiB
PHP
<?php
|
|
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
|
// PURPOSE
|
|
// Checks how the settings UI will draw every conf field, and proves the inference rules against
|
|
// the conventions the conf files actually use. Two modes in one tool: fixed assertions over
|
|
// synthetic snippets, and a report of what this host's real master.conf infers to.
|
|
//
|
|
// OPERATIONAL MODEL
|
|
// Hand-run, reads only, writes nothing — the same shape as Tools/ai_log_check.sh. Run it after
|
|
// touching _vv_conf_widget(), after adding a conf convention, or when a setting draws as the
|
|
// wrong control and the question is whether the rule or the comment above it is at fault.
|
|
//
|
|
// php Tools/conf_widget_check.php assertions, then the live summary
|
|
// php Tools/conf_widget_check.php --list every live field and its inferred control
|
|
//
|
|
// WHY IT ASSERTS AGAINST SNIPPETS AND NOT THE LIVE CONF
|
|
// The live conf is the thing being described, so it cannot also be the thing that proves the
|
|
// description right — an inference rule that silently stopped matching would keep passing as
|
|
// the conf drifted to suit it. The snippets are frozen copies of each convention as written,
|
|
// so a rule change that breaks one shows up here rather than as a wrong control on a page.
|
|
//
|
|
// WHAT AN INFERENCE IS NOT
|
|
// Consistent with confform.php, none of this validates. A number field carrying min and max is
|
|
// a courtesy to whoever is typing, not a promise the value is sensible — the consuming script
|
|
// still owns that question.
|
|
//
|
|
// DEPENDS ON
|
|
// include/confform.php _vv_conf_parse_field_range(), vv_conf_key_is_secret()
|
|
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
|
require_once __DIR__ . '/../include/confform.php';
|
|
|
|
$pass = 0; $fail = 0; $failed = [];
|
|
function check(string $name, $got, $want): void {
|
|
global $pass, $fail, $failed;
|
|
if ($got === $want) { $pass++; return; }
|
|
$fail++;
|
|
$failed[] = sprintf("%-42s got %s want %s", $name, json_encode($got), json_encode($want));
|
|
}
|
|
|
|
// Parse one snippet and return its fields keyed by name.
|
|
function vv_wc_fields(string $snippet): array {
|
|
$lines = explode("\n", $snippet);
|
|
$out = [];
|
|
foreach (_vv_conf_parse_field_range($lines, 0, count($lines), 'master.conf') as $f) {
|
|
$out[$f['key']] = $f;
|
|
}
|
|
return $out;
|
|
}
|
|
|
|
// ── Booleans and numbers ─────────────────────────────────────────────────────────────────────
|
|
$f = vv_wc_fields(<<<'CONF'
|
|
AI_ENABLED=true
|
|
AI_OFF=false
|
|
AI_QUOTED_BOOL="true"
|
|
AI_CONNECT_TIMEOUT=5 # seconds — probe when resolving which node has Ollama
|
|
AI_CHAT_HISTORY_MAX=10 # conversations kept — clamped to 1-50
|
|
AI_PLAIN_NUMBER=32
|
|
AI_NEGATIVE=-1
|
|
CONF);
|
|
check('bool true', $f['AI_ENABLED']['widget'], 'bool');
|
|
check('bool false', $f['AI_OFF']['widget'], 'bool');
|
|
check('bool quoted', $f['AI_QUOTED_BOOL']['widget'], 'bool');
|
|
check('int', $f['AI_CONNECT_TIMEOUT']['widget'], 'int');
|
|
check('int unit', $f['AI_CONNECT_TIMEOUT']['unit'] ?? null, 'seconds');
|
|
check('int without a unit', $f['AI_PLAIN_NUMBER']['unit'] ?? null, null);
|
|
check('int range min', $f['AI_CHAT_HISTORY_MAX']['min'] ?? null, 1);
|
|
check('int range max', $f['AI_CHAT_HISTORY_MAX']['max'] ?? null, 50);
|
|
check('int range keeps unit', $f['AI_CHAT_HISTORY_MAX']['unit'] ?? null, 'conversations');
|
|
check('negative int', $f['AI_NEGATIVE']['widget'], 'int');
|
|
|
|
// ── File modes must never become spinners ────────────────────────────────────────────────────
|
|
// A spinner invites arrowing 755 to 756, and a number input normalises a leading zero away.
|
|
$f = vv_wc_fields(<<<'CONF'
|
|
PERMISSIONS_DIR_MODE="755" # directories
|
|
PERMISSIONS_FILE_MODE="664" # files
|
|
TRANSCODE_CHMOD="755"
|
|
LEADING_ZERO_MODE="0755"
|
|
TRANSCODE_MANAGER_MODE="smart"
|
|
SOME_TIMEOUT_MODE=30 # seconds
|
|
CONF);
|
|
check('dir mode is text', $f['PERMISSIONS_DIR_MODE']['widget'], 'text');
|
|
check('file mode is text', $f['PERMISSIONS_FILE_MODE']['widget'], 'text');
|
|
check('chmod is text', $f['TRANSCODE_CHMOD']['widget'], 'text');
|
|
check('octal mode is text', $f['LEADING_ZERO_MODE']['widget'], 'text');
|
|
check('worded mode is text', $f['TRANSCODE_MANAGER_MODE']['widget'], 'text');
|
|
// The guard is about octal, not about the word MODE — a real duration is still a number.
|
|
check('non-octal MODE is int', $f['SOME_TIMEOUT_MODE']['widget'], 'int');
|
|
|
|
// ── Enum, quoted-line convention ─────────────────────────────────────────────────────────────
|
|
$f = vv_wc_fields(<<<'CONF'
|
|
# Controls behaviour when versions differ.
|
|
# "warn" — log warning and continue
|
|
# "abort" — refuse to continue (strict)
|
|
UNRAID_VERSION_MISMATCH_ACTION="warn"
|
|
CONF);
|
|
check('quoted-line enum', $f['UNRAID_VERSION_MISMATCH_ACTION']['widget'], 'enum');
|
|
check('enum choices', array_column($f['UNRAID_VERSION_MISMATCH_ACTION']['choices'], 'value'),
|
|
['warn', 'abort']);
|
|
check('enum keeps its hints', $f['UNRAID_VERSION_MISMATCH_ACTION']['choices'][1]['hint'],
|
|
'refuse to continue (strict)');
|
|
|
|
// One quoted comment line is a quotation, not a choice list of one.
|
|
$f = vv_wc_fields("# \"off\" means something here\n SOMETHING=\"off\"");
|
|
check('lone quoted line', $f['SOMETHING']['widget'], 'text');
|
|
|
|
// A value outside its own documented list is surfaced, never dropped to tidy the list — dropping
|
|
// it would change the setting the moment the form saved.
|
|
$f = vv_wc_fields(<<<'CONF'
|
|
# "warn" — log and continue
|
|
# "abort" — refuse
|
|
DRIFTED="something_else"
|
|
CONF);
|
|
check('drifted value stays enum', $f['DRIFTED']['widget'], 'enum');
|
|
check('drifted value offered', $f['DRIFTED']['choices'][0]['value'], 'something_else');
|
|
check('drifted list intact', count($f['DRIFTED']['choices']), 3);
|
|
|
|
// ── Enum, inline pipe convention ─────────────────────────────────────────────────────────────
|
|
$f = vv_wc_fields(<<<'CONF'
|
|
DIGEST_PROFILE="weekly" # always | smart | weekly
|
|
SONARR_MONITOR="all" # monitor mode on add: all | future | first | none
|
|
PROSE_WITH_PIPE="x" # pipe the output through grep | sort
|
|
CONF);
|
|
check('inline enum', $f['DIGEST_PROFILE']['widget'], 'enum');
|
|
check('inline enum choices', array_column($f['DIGEST_PROFILE']['choices'], 'value'),
|
|
['always', 'smart', 'weekly']);
|
|
check('inline enum after colon',$f['SONARR_MONITOR']['widget'], 'enum');
|
|
// The value is not among them, so this is prose that happens to contain a pipe.
|
|
check('prose with a pipe', $f['PROSE_WITH_PIPE']['widget'], 'text');
|
|
|
|
// ── Secrets ──────────────────────────────────────────────────────────────────────────────────
|
|
$f = vv_wc_fields(<<<'CONF'
|
|
HOST1_NPM_PASS="hunter2"
|
|
HOST1_SONARR_API_KEY="abcdef"
|
|
WEBHOOK_SECRET="s3cr3t"
|
|
AI_TOKEN_DB="${AI_DATA_DIR}/ai_token_history.db"
|
|
AI_TOKEN_RETAIN_ROWS=20000
|
|
AI_TOKEN_SYNC_ENABLED=true
|
|
CONF);
|
|
check('password masked', $f['HOST1_NPM_PASS']['widget'], 'secret');
|
|
check('api key masked', $f['HOST1_SONARR_API_KEY']['widget'], 'secret');
|
|
check('secret masked', $f['WEBHOOK_SECRET']['widget'], 'secret');
|
|
// LLM token accounting is not a credential. Masking these was the bug this exception fixes.
|
|
check('AI_TOKEN_DB visible', $f['AI_TOKEN_DB']['widget'], 'path');
|
|
check('AI_TOKEN_RETAIN visible', $f['AI_TOKEN_RETAIN_ROWS']['widget'], 'int');
|
|
check('AI_TOKEN_SYNC visible', $f['AI_TOKEN_SYNC_ENABLED']['widget'], 'bool');
|
|
|
|
// ── Paths, text, arrays ──────────────────────────────────────────────────────────────────────
|
|
$f = vv_wc_fields(<<<'CONF'
|
|
AI_INDEX_DB="${AI_DATA_DIR}/ai_index.db"
|
|
SOME_ABS="/mnt/user/appdata"
|
|
AI_MODEL="qwen3:14b"
|
|
AI_PROFILES="*"
|
|
AI_CONF_WRITE_KEYS=()
|
|
LIST_MULTI=(
|
|
one
|
|
two
|
|
)
|
|
CONF);
|
|
check('templated path', $f['AI_INDEX_DB']['widget'], 'path');
|
|
check('absolute path', $f['SOME_ABS']['widget'], 'path');
|
|
check('plain text', $f['AI_MODEL']['widget'], 'text');
|
|
check('wildcard text', $f['AI_PROFILES']['widget'], 'text');
|
|
check('single-line array', $f['AI_CONF_WRITE_KEYS']['widget'], 'lines');
|
|
check('multi-line array', $f['LIST_MULTI']['widget'], 'lines');
|
|
// The scratch field the parser uses to reach the comment lines must not ride out to the browser.
|
|
check('no _lines leak', isset($f['AI_MODEL']['_lines']), false);
|
|
|
|
printf("assertions: %d passed, %d failed\n", $pass, $fail);
|
|
foreach ($failed as $l) echo " FAIL $l\n";
|
|
|
|
// ── What this host's conf actually infers to ─────────────────────────────────────────────────
|
|
$path = CONF_DIR . '/master.conf';
|
|
if (!is_readable($path)) {
|
|
echo "\nmaster.conf not readable at $path — skipping the live summary\n";
|
|
exit($fail ? 1 : 0);
|
|
}
|
|
$lines = explode("\n", (string) file_get_contents($path));
|
|
$hdrs = [];
|
|
foreach ($lines as $i => $l) {
|
|
if (preg_match('/^#\s*[━─]{2,}\s+([A-Za-z].+?)\s+[━─]{2,}/u', $l, $m)) $hdrs[] = [$i, trim($m[1])];
|
|
}
|
|
|
|
$list = in_array('--list', $argv, true);
|
|
$tally = []; $n = 0; $enums = []; $ranges = [];
|
|
foreach ($hdrs as $k => [$i, $name]) {
|
|
$end = $hdrs[$k + 1][0] ?? count($lines);
|
|
$rows = _vv_conf_parse_field_range($lines, $i + 1, $end, 'master.conf');
|
|
if ($list && $rows) printf("\n── %s\n", $name);
|
|
foreach ($rows as $x) {
|
|
$n++;
|
|
$w = $x['widget'] ?? '?';
|
|
$tally[$w] = ($tally[$w] ?? 0) + 1;
|
|
if ($w === 'enum') $enums[] = $x['key'] . ' = ' . implode(' | ', array_column($x['choices'], 'value'));
|
|
if (isset($x['min'])) $ranges[] = $x['key'] . ' ' . $x['min'] . '-' . $x['max'];
|
|
if ($list) {
|
|
$e = [];
|
|
if (isset($x['unit'])) $e[] = $x['unit'];
|
|
if (isset($x['min'])) $e[] = $x['min'] . '-' . $x['max'];
|
|
printf(" %-34s %-7s %s\n", $x['key'], $w, $e ? '[' . implode(' ', $e) . ']' : '');
|
|
}
|
|
}
|
|
}
|
|
|
|
printf("\nlive master.conf: %d fields across %d sections\n", $n, count($hdrs));
|
|
ksort($tally);
|
|
foreach ($tally as $k => $v) printf(" %-8s %d\n", $k, $v);
|
|
if ($enums) { echo "\nchoice lists found:\n"; foreach ($enums as $e) echo " $e\n"; }
|
|
if ($ranges) { echo "\nbounded numbers found:\n"; foreach ($ranges as $e) echo " $e\n"; }
|
|
|
|
exit($fail ? 1 : 0);
|