From e64d26af6c47365d54bac9318d845a5248573277 Mon Sep 17 00:00:00 2001 From: Gmer4Lfe Date: Wed, 12 Aug 2026 19:55:51 -0400 Subject: [PATCH] Put each page's settings on that page A watchdog threshold was reachable only from the bottom of a list of a hundred and twenty on another tab. --- Plugin/unraid/Tools/ui_map_build.php | 7 +- Plugin/unraid/api/confform.php | 8 +- Plugin/unraid/css/varaverk.css | 29 ++++++ Plugin/unraid/include/confform.php | 14 +++ Plugin/unraid/include/confui.php | 130 ++++++++++++++++++++++++ Plugin/unraid/pages/arrs.php | 8 ++ Plugin/unraid/pages/docker.php | 8 ++ Plugin/unraid/pages/fallback.php | 8 ++ Plugin/unraid/pages/partnership.php | 8 ++ Plugin/unraid/pages/readme/ui-map.md | 55 ++++++++-- Plugin/unraid/pages/rsync.php | 8 ++ Plugin/unraid/pages/settings.php | 144 +-------------------------- Plugin/unraid/pages/watchdog.php | 8 ++ 13 files changed, 284 insertions(+), 151 deletions(-) diff --git a/Plugin/unraid/Tools/ui_map_build.php b/Plugin/unraid/Tools/ui_map_build.php index b76adf4..5e0a6f2 100644 --- a/Plugin/unraid/Tools/ui_map_build.php +++ b/Plugin/unraid/Tools/ui_map_build.php @@ -71,8 +71,13 @@ 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' . preg_quote((string) $surface['match'], '/') . '\b/i'; + $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; diff --git a/Plugin/unraid/api/confform.php b/Plugin/unraid/api/confform.php index 1843b60..e66e10f 100644 --- a/Plugin/unraid/api/confform.php +++ b/Plugin/unraid/api/confform.php @@ -107,8 +107,14 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') { // with boundaries around it rather than as a pattern a caller could widen to everything. // "*" is every section this host has. Deliberately not the same as an empty needle, which // would match everything by accident rather than on purpose. + // Several words, pipe-separated, each still matched whole and still quoted as a literal. + // One word was not enough: the Arr sections are named Arr Sync, Lidarr, Sonarr and + // Radarr, and no single stem catches those without also catching "Array Start", which is + // the Unraid disk array and nothing to do with them. $all = ($match === '*'); - $re = $all ? '' : '/\b' . preg_quote($match, '/') . '\b/i'; + $re = $all ? '' : '/\b(' . implode('|', array_map( + fn($w) => preg_quote(trim($w), '/'), + array_filter(explode('|', $match), fn($w) => trim($w) !== ''))) . ')\b/i'; $out = []; foreach (vv_get_conf_files() as $f) { foreach (vv_conf_all_groups($f) as $g) { diff --git a/Plugin/unraid/css/varaverk.css b/Plugin/unraid/css/varaverk.css index 685bc0d..3377cfc 100644 --- a/Plugin/unraid/css/varaverk.css +++ b/Plugin/unraid/css/varaverk.css @@ -559,6 +559,35 @@ body.vv-fullscreen #displaybox { padding-left: 1rem !important; padding-top: .5r padding: 3px 8px; border-radius: 3px; cursor: pointer; flex-shrink: 0; } .vv-cf-reveal:hover { color: #aaa; border-color: #4a4a4a; } +/* ── The settings card ──────────────────────────────────────────────────────── + Emitted by vv_conf_ui_card(), so every page that shows settings shows the same object. Lives + here rather than in each page's inline block for the reason nine page-private vocabularies + existed in the first place: a card defined where it is used is a card that drifts. */ +.vv-cf-card { background:#161616; border:1px solid #2a2a2a; border-radius:6px; + padding:12px 14px; margin-bottom:14px; } +.vv-cf-card-h { display:flex; align-items:center; gap:10px; cursor:pointer; user-select:none; + flex-wrap:wrap; } +.vv-cf-card-c { color:#333; font-size:9px; transition:transform .12s; } +.vv-cf-card-h.open .vv-cf-card-c { transform:rotate(90deg); } +.vv-cf-card-l { font-size:11px; font-weight:700; color:#666; text-transform:uppercase; + letter-spacing:.07em; } +.vv-cf-card-f { max-width:240px; cursor:text; background:#0d0d0d; border:1px solid #2a2a2a; + border-radius:3px; color:#bbb; font-size:12px; padding:4px 8px; outline:none; + font-family:inherit; } +.vv-cf-card-f:focus { border-color:#444; } +.vv-cf-card-s { margin-left:auto; font-size:10px; color:#444; font-family:monospace; } +.vv-cf-card-s.warn { color:#ffb74d; } +.vv-cf-card-s.ok { color:#4caf50; } +.vv-cf-card-s.bad { color:#ef5350; } +.vv-cf-card-save { background:#1a2a1a; border:1px solid #2d4a2d; color:#4caf50; font-size:11px; + padding:5px 14px; border-radius:3px; cursor:pointer; font-family:inherit; } +.vv-cf-card-save:hover:not(:disabled) { background:#223a22; } +.vv-cf-card-save:disabled { opacity:.4; cursor:default; } +/* Scrolls itself rather than growing the page past the viewport, so Save stays reachable. */ +.vv-cf-card [id$="-fields"] { max-height:60vh; overflow-y:auto; margin-top:10px; padding-right:4px; } +/* Hidden by the filter, not removed — see the note in VvConfCard. */ +.vv-cf-group.vv-filtered { display:none; } + /* Touched but not yet saved. The row says so itself rather than relying on a Save button somewhere else having become enabled. */ .vv-cf-field.dirty { border-left-color: #6a5228; background: #14110a; } diff --git a/Plugin/unraid/include/confform.php b/Plugin/unraid/include/confform.php index f4adfa2..8db3519 100644 --- a/Plugin/unraid/include/confform.php +++ b/Plugin/unraid/include/confform.php @@ -107,6 +107,20 @@ const VV_UI_SECTION_SURFACES = [ 'tab' => 'AI', 'route' => 'AI tab → Settings → Configuration', ], + // Subject pages first, so a setting named after the page you are already on is found there + // rather than at the bottom of a list of a hundred and twenty. The catch-all below still + // carries all of them, and the map lists both routes because both are true. + ['match' => 'watchdog', 'tab' => 'Watchdog', + 'route' => 'Watchdog tab → Watchdog settings'], + ['match' => 'rsync', 'tab' => 'Rsync', + 'route' => 'Rsync tab → Rsync settings'], + ['match' => 'fallback|tier', 'tab' => 'Fallback', + 'route' => 'Fallback tab → Fallback settings'], + ['match' => 'arr|sonarr|radarr|lidarr', 'tab' => 'Arrs', + 'route' => 'Arrs tab → Arr settings'], + ['match' => 'docker', 'tab' => 'Docker', + 'route' => 'Docker tab → Docker settings'], + // Everything that is not structurally excluded. The catch-all exists so no ordinary setting // is reachable only by editing a file — a settings page whose answer to a third of the conf // is "open it over SSH" is not a settings page. diff --git a/Plugin/unraid/include/confui.php b/Plugin/unraid/include/confui.php index 4ba773d..52ea3f8 100644 --- a/Plugin/unraid/include/confui.php +++ b/Plugin/unraid/include/confui.php @@ -64,6 +64,42 @@ // fatals on the one page that included this without it, which is what took the AI tab blank. require_once __DIR__ . '/confform.php'; +// A whole settings card: disclosure, filter, save, and the fields for one subject. Emitted by a +// page in one line rather than assembled there. +// +// This exists because the alternative was six copies. The AI tab, the Scheduler and the Settings +// page each grew their own load/filter/dirty/save cycle, and the third one was written by copying +// the second — which is exactly how this plugin ended up with nine settings vocabularies and six +// toggles. Everything subtle here is subtle because it already cost something once: the filter +// hides rather than removes so a pending edit survives it, the save rebases instead of refetching +// so the field under the cursor does not repaint, and a refused write leaves the row dirty +// because it genuinely did not happen. +// +// $prefix composes every id, so a page may hold more than one card +// $match which sections — a whole word matched against section headers, or "*" for all +// $title the card's heading +function vv_conf_ui_card(string $prefix, string $match, string $title = 'Settings'): void { + vv_conf_ui_assets(); + $p = htmlspecialchars($prefix, ENT_QUOTES); + ?> +
+
+ + + + + +
+ +
+ + document.getElementById(prefix + '-' + s); + const API = '/plugins/varaverk/api/confform.php'; + let loaded = false; + + const dirty = () => { + const n = VvConfUI.dirtyCount(prefix + '-fields'); + $('save').disabled = (n === 0); + $('sum').textContent = n ? (n + ' unsaved') : ''; + $('sum').className = 'vv-cf-card-s' + (n ? ' warn' : ''); + }; + + function load() { + if (loaded) return; + loaded = true; + fetch(API + '?sections=' + encodeURIComponent(match)) + .then(r => r.json()) + .then(d => { + if (!d.ok) throw new Error(d.error || 'could not be read'); + VvConfUI.render(prefix + '-fields', d.groups || []); + VvConfUI.wire(prefix + '-fields', dirty); + dirty(); + }) + .catch(e => { + // Retryable — closing and reopening tries again rather than leaving a permanent error. + loaded = false; + $('fields').innerHTML = '

could not be read: ' + + String(e.message || e) + '

'; + }); + } + + $('t').addEventListener('click', e => { + // The filter and the save live in the header; clicking either must not collapse the card. + if (e.target.closest('#' + prefix + '-filter, #' + prefix + '-save')) return; + const open = $('body').style.display === 'none'; + $('body').style.display = open ? '' : 'none'; + $('t').classList.toggle('open', open); + $('filter').style.display = open ? '' : 'none'; + $('save').style.display = open ? '' : 'none'; + // First open only, so collapsing to look at something else does not discard edits. + if (open) load(); + }); + + // Hides whole sections, never individual fields — a setting means little without the heading + // saying what it belongs to. display:none rather than removal, so a field edited before + // filtering is still dirty and still saved: a filter that silently dropped pending edits + // would be a data-loss bug wearing a search box. + $('filter').addEventListener('input', () => { + const q = $('filter').value.trim().toLowerCase(); + $('fields').querySelectorAll('.vv-cf-group').forEach(g => { + g.classList.toggle('vv-filtered', q !== '' && !g.textContent.toLowerCase().includes(q)); + }); + }); + + $('save').addEventListener('click', () => { + const changes = VvConfUI.collect(prefix + '-fields'); + if (!changes.length) return; + $('save').disabled = true; + $('sum').textContent = 'saving…'; + $('sum').className = 'vv-cf-card-s'; + + // URLSearchParams, not FormData — a multipart POST to this plugin's endpoints hangs with + // no status ever returned. + fetch(API, { method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' }, + body: new URLSearchParams({ changes: JSON.stringify(changes) }) }) + .then(r => r.text()) + .then(t => { + let d; + try { d = JSON.parse(t); } + catch (_) { + throw new Error(t.trim() ? 'unparseable response' + : 'empty response — rejected before the endpoint ran'); + } + if (!d.ok) throw new Error(d.error || 'save failed'); + // Rebased, not refetched: the values on screen are the values on disk now, and a + // reload would repaint the control under the cursor. + VvConfUI.commit(prefix + '-fields'); + dirty(); + $('sum').textContent = 'saved ' + changes.length + + ' change' + (changes.length > 1 ? 's' : ''); + $('sum').className = 'vv-cf-card-s ok'; + }) + .catch(e => { + // Left dirty on purpose. A refused write changed nothing, and clearing the marks + // would say it had. + $('sum').textContent = String(e.message || e); + $('sum').className = 'vv-cf-card-s bad'; + dirty(); + }); + }); + }; })();
@@ -238,29 +224,8 @@ vv_conf_ui_assets();

   
- -
-
- - All settings - - - -
-
@@ -530,111 +495,4 @@ function vvApiRenew() { // Auto-check on load vvApiCheck(); -// ── All settings ───────────────────────────────────────────────────────────── -// Every conf section that is not structurally excluded, drawn by the same renderer the AI tab -// and the Scheduler use and written through the same guarded path. Nothing about which sections -// exist or what control each field gets is decided here — it is read from the conf. -const VV_CF_ALL_API = '/plugins/varaverk/api/confform.php'; -let _vvCfAllLoaded = false; - -function vvCfAllToggle() { - const head = document.getElementById('vv-cf-all-t'); - const body = document.getElementById('vv-cf-all-body'); - const open = body.style.display === 'none'; - body.style.display = open ? '' : 'none'; - head.classList.toggle('open', open); - document.getElementById('vv-cf-all-filter').style.display = open ? '' : 'none'; - document.getElementById('vv-cf-all-save').style.display = open ? '' : 'none'; - // First open only. Reloading on every toggle would discard edits in progress the moment - // someone collapsed the card to look at something else. - if (open) vvCfAllLoad(); -} - -function vvCfAllLoad() { - if (_vvCfAllLoaded) return; - _vvCfAllLoaded = true; - fetch(VV_CF_ALL_API + '?sections=*') - .then(r => r.json()) - .then(d => { - if (!d.ok) throw new Error(d.error || 'could not be read'); - VvConfUI.render('vv-cf-all', d.groups || []); - VvConfUI.wire('vv-cf-all', vvCfAllDirty); - vvCfAllDirty(); - }) - .catch(e => { - // Retryable: the flag goes back, so closing and reopening tries again rather than - // leaving a permanent error where the settings should be. - _vvCfAllLoaded = false; - document.getElementById('vv-cf-all').innerHTML = - '

could not be read: ' + String(e.message || e) + '

'; - }); -} - -function vvCfAllDirty() { - const n = VvConfUI.dirtyCount('vv-cf-all'); - const btn = document.getElementById('vv-cf-all-save'); - const sum = document.getElementById('vv-cf-all-sum'); - btn.disabled = (n === 0); - sum.textContent = n ? (n + ' unsaved') : ''; - sum.style.color = n ? '#ffb74d' : '#444'; -} - -// Hides whole sections, never individual fields — a setting means little without the header that -// says what it belongs to. Matches the section name and the key names inside it, because people -// look for both. display:none rather than removal, so a field edited before filtering is still -// dirty and still saved: a filter that silently drops pending edits would be a data-loss bug -// wearing a search box. -function vvCfAllFilter() { - const q = document.getElementById('vv-cf-all-filter').value.trim().toLowerCase(); - document.querySelectorAll('#vv-cf-all .vv-cf-group').forEach(g => { - g.classList.toggle('vv-filtered', q !== '' && !g.textContent.toLowerCase().includes(q)); - }); -} - -function vvCfAllSave() { - const changes = VvConfUI.collect('vv-cf-all'); - if (!changes.length) return; - const btn = document.getElementById('vv-cf-all-save'); - const sum = document.getElementById('vv-cf-all-sum'); - btn.disabled = true; - sum.textContent = 'saving…'; - sum.style.color = '#444'; - - // URLSearchParams, not FormData — a multipart POST to this plugin's endpoints hangs with no - // status ever returned. - fetch(VV_CF_ALL_API, { method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' }, - body: new URLSearchParams({ changes: JSON.stringify(changes) }) }) - .then(r => r.text()) - .then(t => { - let d; - try { d = JSON.parse(t); } - catch (_) { - throw new Error(t.trim() ? 'unparseable response' - : 'empty response — rejected before the endpoint ran'); - } - if (!d.ok) throw new Error(d.error || 'save failed'); - // Rebased rather than refetched: the values on screen are now the values on disk, and a - // reload would repaint every control including the one just edited. - VvConfUI.commit('vv-cf-all'); - vvCfAllDirty(); - sum.textContent = 'saved ' + changes.length + ' change' + (changes.length > 1 ? 's' : ''); - sum.style.color = '#4caf50'; - }) - .catch(e => { - // Left dirty on purpose. A refused write changed nothing, and clearing the marks would - // say it had. - sum.textContent = String(e.message || e); - sum.style.color = '#ef5350'; - vvCfAllDirty(); - }); -} - -document.getElementById('vv-cf-all-t').addEventListener('click', e => { - // The filter and the save live in the header; clicking either must not collapse the card. - if (e.target.closest('#vv-cf-all-filter, #vv-cf-all-save')) return; - vvCfAllToggle(); -}); -document.getElementById('vv-cf-all-filter').addEventListener('input', vvCfAllFilter); -document.getElementById('vv-cf-all-save').addEventListener('click', vvCfAllSave); diff --git a/Plugin/unraid/pages/watchdog.php b/Plugin/unraid/pages/watchdog.php index 445a6f4..ab04717 100644 --- a/Plugin/unraid/pages/watchdog.php +++ b/Plugin/unraid/pages/watchdog.php @@ -25,6 +25,7 @@ // // DEPENDS ON // api/watchdog.php polled every 30s → include/watchdog.php +require_once dirname(__DIR__) . '/include/confui.php'; ?>