Edit the watchdog container lists as rows instead of raw bash array bodies

Tier 1 limits, both pressure lists and Required get add, remove and adjust on
the page that shows what they did. Per-entry comments are carried through the
rebuild rather than regenerated — they hold the reason for every number.
This commit is contained in:
Gmer4Lfe
2026-08-14 10:53:40 -04:00
parent 1f4b751fbb
commit 57dad6d706
+270 -7
View File
@@ -5,16 +5,24 @@
// action levels, paused and stopped containers, restart history, OOM and reboot counts. // action levels, paused and stopped containers, restart history, OOM and reboot counts.
// //
// DESIGN PRINCIPLES // DESIGN PRINCIPLES
// Pure view. No PHP logic and no include/ dependency — the whole page is markup plus a // State is a pure view. Everything the watchdogs have recorded arrives from one poll against
// poll against api/watchdog.php. All parsing lives in include/watchdog.php behind that // api/watchdog.php, and all parsing lives in include/watchdog.php behind that endpoint, so the
// endpoint, so the page cannot hold a second opinion about what a strike means. // 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 // 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 // OPERATIONAL SAFEGUARDS
// Strictly read-only. There is no control on this page that clears a strike, restarts a // 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. // 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 // 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 // repair. troubleshoot may read the machine and the orchestrator's log; it holds no conf_write
@@ -27,11 +35,13 @@
// RENDERS // RENDERS
// Per-node cards: resource / docker / system / storage / network watchdog state, // Per-node cards: resource / docker / system / storage / network watchdog state,
// strike pills, restart history, reboot and OOM counts // 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 // Assistant card, scoped to the Watchdog tab
// Watchdog conf sections, via the shared settings renderer // Watchdog conf sections, via the shared settings renderer
// //
// DEPENDS ON // 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 // include/ai_chat.php → the shared chat component
require_once dirname(__DIR__) . '/include/confui.php'; require_once dirname(__DIR__) . '/include/confui.php';
require_once dirname(__DIR__) . '/include/ai_chat.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-ctr-lim { font-size:11px;color:#555; }
.vv-wd-pressure{ grid-column:1/-1;border-color:#3a2000;background:#1a1000; } .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 */ /* One host per row — inner grid sizes all cards equally */
.vv-wd-host-row { .vv-wd-host-row {
grid-column: 1 / -1; grid-column: 1 / -1;
@@ -89,6 +127,57 @@ require_once dirname(__DIR__) . '/include/ai_chat.php';
</div> </div>
<?php
// ── Editable container lists ─────────────────────────────────────────────────────────────────
// Read from conf at render time rather than from the watchdog payload, for two reasons. The
// payload is cached for up to 300s, so a list edited here would keep showing its old contents
// until the writer's next pass; and it drops the per-entry comments, which is where the reason for
// every one of these numbers is written down — "24GB, raised 2026-07-11, was creeping toward the
// ceiling" is the whole value of the row.
//
// Local host only. These live in host*.conf, and the partner owns its own: writing HOST2's limits
// from HOST1 produces a change on the wrong side of a conf sync that is currently switched off
// anyway. The remote node's lists stay visible in the inventory card below, read-only.
$_vv_wd_me = strtoupper(vv_detect_host());
$_vv_wd_file = strtolower($_vv_wd_me) . '.conf';
$_vv_wd_defs = [
['key' => $_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'],
];
}
?>
<?php if ($_vv_wd_lists): ?>
<div id="vv-wd-lists" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:12px;margin-top:12px;"></div>
<script>window.VvWdLists = <?= json_encode($_vv_wd_lists, JSON_UNESCAPED_SLASHES) ?>;</script>
<?php endif; ?>
<?php if (vv_ai_ui_on()): ?> <?php if (vv_ai_ui_on()): ?>
<div class="vv-card" id="vv-wd-ai-card" style="margin-top:12px;"> <div class="vv-card" id="vv-wd-ai-card" style="margin-top:12px;">
<?php <?php
@@ -699,5 +788,179 @@ if (document.getElementById('vv-wd-ai-chat')) {
vvWdLoad(); vvWdLoad();
setInterval(vvWdLoad, 30000); setInterval(vvWdLoad, 30000);
// ── Editable container lists ──────────────────────────────────────────────────
// Structured rows over the same guarded write path the settings card uses: the value posted is the
// array *body*, and vv_conf_write_file() splices it between the parens it already found. Nothing
// here writes conf directly, so the lock, the backup, the `bash -n` check, the read-back and the
// rollback all still apply exactly as they do to a typed edit.
//
// Comments are carried per row rather than regenerated. Every one of these entries has a reason
// written beside it and rebuilding the array from names and numbers alone would erase all of them
// in one save — the sort of loss that is invisible until the day someone asks why Emby is 24GB.
(function () {
const HOST = document.getElementById('vv-wd-lists');
if (!HOST || !window.VvWdLists) return;
const API = '/plugins/varaverk/api/confform.php';
const INDENT = ' ';
// ["Name"]=1234 # why — value may be quoted or bare, and the reason may be absent
const MAP_RE = /^\s*\["([^"]+)"\]\s*=\s*"?([^"#]*?)"?\s*(?:#\s*(.*))?$/;
// "Name" # why
const LIST_RE = /^\s*"([^"]+)"\s*(?:#\s*(.*))?$/;
function parseBody(body, kind) {
const out = [];
for (const raw of String(body || '').split('\n')) {
if (!raw.trim() || raw.trim().startsWith('#')) continue;
const m = raw.match(kind === 'map' ? MAP_RE : LIST_RE);
if (!m) continue;
out.push(kind === 'map'
? { name: m[1], value: (m[2] || '').trim(), note: (m[3] || '').trim() }
: { name: m[1], value: '', note: (m[2] || '').trim() });
}
return out;
}
function buildBody(rows, kind) {
return rows.map(r => {
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 = `<span class="vv-wl-t">${vvEscHtml(def.title)}</span>`
+ `<span class="vv-wl-n">${rows.filter(r => !r.gone).length}</span>`;
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
? `<span class="vv-wl-gb">${(+r.value / 1024).toFixed(1)} GB</span>` : '';
el.innerHTML =
`<input class="vv-wl-name" value="${vvEscAttr(r.name)}" spellcheck="false">`
+ (def.kind === 'map'
? `<input class="vv-wl-val" value="${vvEscAttr(r.value)}" inputmode="numeric">`
+ `<span class="vv-wl-unit">${vvEscHtml(def.unit)}</span>${gb}`
: '')
+ `<button class="vv-wl-x" title="${r.gone ? 'Keep this entry' : 'Remove this entry'}">`
+ (r.gone ? '↺' : '×') + '</button>';
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 = `<button class="vv-btn-sm" data-a="add">+ Add</button>`
+ `<span class="vv-wl-sum"></span>`
+ `<button class="vv-btn-sm" data-a="save" disabled>Save</button>`;
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();
});
})();
})(); })();
</script> </script>