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:
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user