diff --git a/Plugin/unraid/pages/watchdog.php b/Plugin/unraid/pages/watchdog.php index 28e1194..8223f3a 100644 --- a/Plugin/unraid/pages/watchdog.php +++ b/Plugin/unraid/pages/watchdog.php @@ -5,16 +5,24 @@ // action levels, paused and stopped containers, restart history, OOM and reboot counts. // // DESIGN PRINCIPLES -// Pure view. No PHP logic and no include/ dependency — the whole page is markup plus a -// poll against api/watchdog.php. All parsing lives in include/watchdog.php behind that -// endpoint, so the page cannot hold a second opinion about what a strike means. +// State is a pure view. Everything the watchdogs have recorded arrives from one poll against +// api/watchdog.php, and all parsing lives in include/watchdog.php behind that endpoint, so the +// page cannot hold a second opinion about what a strike means. +// +// Configuration is not, and reads conf directly. The container lists and the settings card are +// rendered server-side from host*.conf at page load rather than from the polled payload: that +// payload is cached for up to 300s and drops the per-entry comments, which are where the reason +// for every number in these lists is written down. // // Shows every node, not just this one. A watchdog page that only covered the host you -// happened to open it on would miss exactly the node in trouble. +// happened to open it on would miss exactly the node in trouble. Editing is local-host only +// though — the partner owns its own host*.conf. // // OPERATIONAL SAFEGUARDS -// Strictly read-only. There is no control on this page that clears a strike, restarts a -// container, or resets a counter — those belong to the watchdog that owns the state. +// Read-only about state. There is no control on this page that clears a strike, restarts a +// container, or resets a counter — those belong to the watchdog that owns the state. Changing +// which containers a watchdog acts on is a conf edit and goes through the guarded write path +// with everyone else's; it is not a way to reach into what a watchdog has already decided. // // That holds for the assistant too, and is why the Why? buttons open troubleshoot rather than // repair. troubleshoot may read the machine and the orchestrator's log; it holds no conf_write @@ -27,11 +35,13 @@ // RENDERS // Per-node cards: resource / docker / system / storage / network watchdog state, // strike pills, restart history, reboot and OOM counts +// Editable container lists for this host: Tier 1 memory limits, the two pressure lists, Required // Assistant card, scoped to the Watchdog tab // Watchdog conf sections, via the shared settings renderer // // DEPENDS ON -// api/watchdog.php polled every 30s → include/watchdog.php +// api/watchdog.php polled every 30s → include/watchdog.php (served from a 300s cache) +// api/confform.php POST → the guarded conf write path, for the list editors // include/ai_chat.php → the shared chat component require_once dirname(__DIR__) . '/include/confui.php'; require_once dirname(__DIR__) . '/include/ai_chat.php'; @@ -67,6 +77,34 @@ require_once dirname(__DIR__) . '/include/ai_chat.php'; .vv-wd-ctr-lim { font-size:11px;color:#555; } .vv-wd-pressure{ grid-column:1/-1;border-color:#3a2000;background:#1a1000; } +/* ── Editable container lists ──────────────────────────────────────────────── + Rows, not a textarea. The same values are reachable through the settings card below as raw + bash array bodies, which is fine for reading and unforgiving for changing one number. */ +.vv-wl-card { background:#161616;border:1px solid #2a2a2a;border-radius:6px;padding:10px;min-width:0; + display:flex;flex-direction:column; } +.vv-wl-h { display:flex;align-items:baseline;gap:8px;margin-bottom:2px; } +.vv-wl-t { font-size:10px;font-weight:bold;color:#666;letter-spacing:.07em;text-transform:uppercase; } +.vv-wl-n { font-size:10px;color:#3a3a3a;margin-left:auto; } +.vv-wl-hint { font-size:10px;color:#3f3f3f;line-height:1.45;margin-bottom:8px; } +.vv-wl-row { display:flex;align-items:center;gap:5px;margin:2px 0; } +.vv-wl-row.gone .vv-wl-name { text-decoration:line-through;opacity:.4; } +.vv-wl-name { flex:1;min-width:0;background:#0d0d0d;border:1px solid #242424;color:#bbb; + font-size:11px;padding:3px 6px;border-radius:3px;font-family:inherit; } +.vv-wl-val { width:74px;flex-shrink:0;background:#0d0d0d;border:1px solid #242424;color:#ddd; + font-size:11px;padding:3px 6px;border-radius:3px;text-align:right; } +.vv-wl-unit { font-size:10px;color:#3a3a3a;flex-shrink:0;width:20px; } +.vv-wl-gb { font-size:10px;color:#4a4a4a;flex-shrink:0;width:48px;text-align:right; } +.vv-wl-x { background:none;border:none;color:#4a4a4a;cursor:pointer;font-size:13px; + line-height:1;padding:0 3px;flex-shrink:0; } +.vv-wl-x:hover{ color:#ef5350; } +.vv-wl-row.gone .vv-wl-x { color:#ef5350; } +.vv-wl-note { font-size:10px;color:#3a3a3a;margin:0 0 4px 6px;line-height:1.4; } +.vv-wl-foot { display:flex;align-items:center;gap:6px;margin-top:auto;padding-top:8px; } +.vv-wl-sum { font-size:10px;color:#3a3a3a;flex:1;min-width:0; } +.vv-wl-sum.ok { color:#4caf50; } +.vv-wl-sum.err{ color:#ef5350; } +.vv-wl-dirty { border-color:#3a4a6a !important; } + /* One host per row — inner grid sizes all cards equally */ .vv-wd-host-row { grid-column: 1 / -1; @@ -89,6 +127,57 @@ require_once dirname(__DIR__) . '/include/ai_chat.php'; + $_vv_wd_me . '_WATCHDOG_CONTAINERS', 'kind' => 'map', + 'title' => 'Memory limits (Tier 1)', 'unit' => 'MB', + 'hint' => 'Restarted when it holds more than this. Tier 1 is per container and acts before ' + . 'any pressure tier does.'], + ['key' => $_vv_wd_me . '_RW_PAUSE_CONTAINERS', 'kind' => 'list', + 'title' => 'Pause at medium pressure', 'unit' => '', + 'hint' => 'Paused — not stopped — while RAM sits below the medium threshold, and resumed on ' + . 'recovery. Safe for anything that can lose a few minutes.'], + ['key' => $_vv_wd_me . '_RW_STOP_CONTAINERS', 'kind' => 'list', + 'title' => 'Stop at hard pressure', 'unit' => '', + 'hint' => 'Stopped outright at the hard threshold, and not restarted automatically. The last ' + . 'step before the memory shutdown.'], + ['key' => $_vv_wd_me . '_WATCHDOG_REQUIRED_CONTAINERS', 'kind' => 'list', + 'title' => 'Required', 'unit' => '', + 'hint' => 'Must be running. Missing is a strike whether or not the container is being ' + . 'watched for anything else.'], +]; +$_vv_wd_fields = []; +foreach (vv_conf_all_groups($_vv_wd_file) as $_g) { + foreach ($_g['fields'] as $_f) $_vv_wd_fields[$_f['key']] = $_f; +} +$_vv_wd_lists = []; +foreach ($_vv_wd_defs as $_d) { + $_f = $_vv_wd_fields[$_d['key']] ?? null; + if (!$_f) continue; // key absent from this host's conf — draw nothing + $_vv_wd_lists[] = $_d + [ + 'file' => $_f['file'] ?? $_vv_wd_file, + 'type' => $_f['type'], // assoc_array | array — the writer splices on this + 'body' => $_f['value'], + ]; +} +?> + +
+ + +
{ + const head = kind === 'map' ? `["${r.name}"]=${r.value}` : `"${r.name}"`; + return INDENT + (r.note ? head + ' # ' + r.note : head); + }).join('\n'); + } + + VvWdLists.forEach((def, idx) => { + const card = document.createElement('div'); + card.className = 'vv-wl-card'; + HOST.appendChild(card); + + let rows = parseBody(def.body, def.kind); + const original = buildBody(rows, def.kind); + + function draw() { + card.innerHTML = ''; + const h = document.createElement('div'); + h.className = 'vv-wl-h'; + h.innerHTML = `${vvEscHtml(def.title)}` + + `${rows.filter(r => !r.gone).length}`; + card.appendChild(h); + + const hint = document.createElement('div'); + hint.className = 'vv-wl-hint'; + hint.textContent = def.hint; + card.appendChild(hint); + + rows.forEach((r, i) => { + const el = document.createElement('div'); + el.className = 'vv-wl-row' + (r.gone ? ' gone' : ''); + const gb = def.kind === 'map' && +r.value > 0 + ? `${(+r.value / 1024).toFixed(1)} GB` : ''; + el.innerHTML = + `` + + (def.kind === 'map' + ? `` + + `${vvEscHtml(def.unit)}${gb}` + : '') + + `'; + + const [nameEl, valEl] = [el.querySelector('.vv-wl-name'), el.querySelector('.vv-wl-val')]; + nameEl.addEventListener('input', () => { r.name = nameEl.value.trim(); mark(); }); + if (valEl) valEl.addEventListener('input', () => { r.value = valEl.value.trim(); mark(); }); + // Marked rather than spliced out, so a mis-click is one click back and the reason written + // beside the entry is still there to read while deciding. + el.querySelector('.vv-wl-x').addEventListener('click', () => { + rows[i].gone = !rows[i].gone; draw(); + }); + card.appendChild(el); + if (r.note) { + const n = document.createElement('div'); + n.className = 'vv-wl-note'; + n.textContent = r.note; + card.appendChild(n); + } + }); + + const foot = document.createElement('div'); + foot.className = 'vv-wl-foot'; + foot.innerHTML = `` + + `` + + ``; + card.appendChild(foot); + foot.querySelector('[data-a="add"]').addEventListener('click', () => { + rows.push({ name: '', value: def.kind === 'map' ? '1024' : '', note: '' }); + draw(); + const inputs = card.querySelectorAll('.vv-wl-name'); + if (inputs.length) inputs[inputs.length - 1].focus(); + }); + foot.querySelector('[data-a="save"]').addEventListener('click', save); + mark(); + } + + function live() { return rows.filter(r => !r.gone && r.name !== ''); } + + function mark() { + const changed = buildBody(live(), def.kind) !== original; + const btn = card.querySelector('[data-a="save"]'); + const sum = card.querySelector('.vv-wl-sum'); + if (btn) btn.disabled = !changed; + if (sum && !sum.classList.contains('ok') && !sum.classList.contains('err')) + sum.textContent = changed ? 'unsaved' : ''; + if (sum && changed) { sum.className = 'vv-wl-sum'; sum.textContent = 'unsaved'; } + } + + function save() { + const keep = live(); + const sum = card.querySelector('.vv-wl-sum'); + const btn = card.querySelector('[data-a="save"]'); + // Names are what get written between quotes in a bash array, so anything that could close + // one early or start a substitution is refused here rather than sent and rejected downstream. + const bad = keep.find(r => !/^[A-Za-z0-9._-]+$/.test(r.name)) + || (def.kind === 'map' ? keep.find(r => !/^\d+$/.test(r.value)) : null); + if (bad) { + sum.className = 'vv-wl-sum err'; + sum.textContent = 'check "' + bad.name + '"'; + return; + } + const dup = keep.map(r => r.name).find((n, i, a) => a.indexOf(n) !== i); + if (dup) { sum.className = 'vv-wl-sum err'; sum.textContent = dup + ' listed twice'; return; } + + btn.disabled = true; + sum.className = 'vv-wl-sum'; + sum.textContent = 'saving…'; + // URLSearchParams, never 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([{ + file: def.file, key: def.key, type: def.type, value: buildBody(keep, def.kind), + }]) }) }) + .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'); + rows = keep.map(r => ({ ...r })); + def.body = buildBody(rows, def.kind); + draw(); + const s = card.querySelector('.vv-wl-sum'); + s.className = 'vv-wl-sum ok'; + s.textContent = 'saved — takes effect next cycle'; + }) + .catch(e => { + const s = card.querySelector('.vv-wl-sum'); + s.className = 'vv-wl-sum err'; + s.textContent = String(e.message || e); + card.querySelector('[data-a="save"]').disabled = false; + }); + } + + draw(); + }); +})(); + })();