Files
Varaverk/Plugin/unraid/pages/watchdog.php
T
Gmer4Lfe 8808cbfc04 Give the Watchdog tab an assistant scoped to it, and Why? on every strike
The page states what struck but never why, and the log that would say is the
orchestrator's, not one per watchdog. Why? opens troubleshoot against it —
read-only, because the page's contract is that it changes nothing.
2026-08-14 10:09:04 -04:00

692 lines
33 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// Watchdog tab. Shows what every watchdog has recorded across all nodes — strike counts,
// 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.
//
// 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.
//
// 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.
//
// 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
// and no way to act. A page whose stated contract is "changes nothing" must not carry the one
// profile that can — see include/ai_profiles.php for why that grant is deliberately narrow.
//
// A node that fails to report renders as unavailable rather than healthy. Absence of data
// is never drawn as an all-clear.
//
// RENDERS
// Per-node cards: resource / docker / system / storage / network watchdog state,
// strike pills, restart history, reboot and OOM counts
// 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
// include/ai_chat.php → the shared chat component
require_once dirname(__DIR__) . '/include/confui.php';
require_once dirname(__DIR__) . '/include/ai_chat.php';
?>
<style>
.vv-wd-card { background:#161616;border:1px solid #2a2a2a;border-radius:6px;padding:10px;min-width:0; }
.vv-wd-sec { font-size:10px;font-weight:bold;color:#444;letter-spacing:.07em;text-transform:uppercase;margin-bottom:6px; }
.vv-wd-row { display:flex;justify-content:space-between;align-items:baseline;gap:6px;margin:2px 0; }
.vv-wd-lbl { font-size:11px;color:#444;white-space:nowrap; }
.vv-wd-val { font-size:12px;color:#bbb;text-align:right; }
.vv-wd-sep { border:none;border-top:1px solid #1e1e1e;margin:6px 0; }
.vv-wd-pill { font-size:10px;padding:1px 6px;border-radius:2px;background:#1e1e1e;color:#666;border:1px solid #272727; }
.vv-wd-pill.ok { background:#0d1f0d;color:#4caf50;border-color:#1a3a1a; }
.vv-wd-pill.warn { background:#1f1500;color:#ffb74d;border-color:#3a2800; }
.vv-wd-pill.err { background:#200d0d;color:#ef5350;border-color:#3a1a1a; }
.vv-wd-pill-row { display:flex;flex-wrap:wrap;gap:4px;margin-top:4px; }
.vv-wd-bar { height:5px;border-radius:2px;background:#1e1e1e;margin-top:3px;overflow:hidden; }
.vv-wd-bar-fill{ height:100%;border-radius:2px;transition:width .3s; }
.vv-wd-badge { font-size:11px;font-weight:bold;padding:2px 8px;border-radius:3px; }
.vv-wd-badge.ok { background:#0d1f0d;color:#4caf50; }
.vv-wd-badge.soft { background:#1f1f00;color:#cddc39; }
.vv-wd-badge.med { background:#1f1000;color:#ffb74d; }
.vv-wd-badge.hard { background:#200d0d;color:#ef5350; }
.vv-wd-node-h { display:flex;align-items:center;gap:8px;margin-bottom:10px; }
.vv-wd-node-id { font-size:12px;font-weight:bold;color:#666;letter-spacing:.06em;text-transform:uppercase; }
.vv-wd-dot { width:6px;height:6px;border-radius:50%;flex-shrink:0; }
.vv-wd-strike-row { display:flex;align-items:baseline;gap:6px;margin:2px 0; }
.vv-wd-strike-name{ font-size:11px;color:#777;flex:1; }
.vv-wd-strike-cnt { font-size:11px;font-weight:bold;color:#ffb74d; }
.vv-wd-reboot-ts { font-size:11px;color:#555;margin:1px 0; }
.vv-wd-ctr-row { display:flex;justify-content:space-between;align-items:baseline;margin:2px 0; }
.vv-wd-ctr-name{ font-size:11px;color:#888; }
.vv-wd-ctr-lim { font-size:11px;color:#555; }
.vv-wd-pressure{ grid-column:1/-1;border-color:#3a2000;background:#1a1000; }
/* One host per row — inner grid sizes all cards equally */
.vv-wd-host-row {
grid-column: 1 / -1;
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 12px;
}
@media (max-width: 900px) {
.vv-wd-host-row { grid-template-columns: 1fr; }
}
</style>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;padding:0 2px;">
<span style="font-size:13px;font-weight:bold;color:#888;text-transform:uppercase;letter-spacing:.06em;">Watchdog</span>
<span style="font-size:11px;color:#3a3a3a;" id="vv-wd-ts"></span>
</div>
<div id="vv-wd-grid" style="display:grid;grid-template-columns:repeat(8,1fr);gap:12px;">
<div style="grid-column:1/-1;color:#444;font-size:12px;padding:16px 0;text-align:center;">Loading…</div>
</div>
<?php if (vv_ai_ui_on()): ?>
<div class="vv-card" id="vv-wd-ai-card" style="margin-top:12px;">
<?php
// All three, in this order, before the markup. vv_ai_chat_markup() emits only the boxes — the
// factory that brings them to life, the profile registry the picker draws from, and the store
// that keeps a thread across a reload are separate and none of them is implied by the others.
// Omitting them renders a chat that looks complete and dies on the first click with VvAiChat
// undefined, which is how the Scheduler's panel behaved before it was given the same three.
// Each is guarded internally, so calling them here costs nothing on a page that already has.
vv_ai_profiles_script();
vv_ai_chat_store_script();
vv_ai_chat_assets();
// Reads the page rather than sitting beside it. The scope is sent with every question, and the
// worker turns it into "they have Watchdog open — read bare references against it", so "why did
// it restart that" resolves here instead of asking which of forty containers is meant.
//
// Starts on the Varaverk assistant, not General Chat as the Monitor card does. The two pages ask
// different things: the dashboard is where an idle question gets typed, this is a page you only
// open because something struck. Answers about strikes and thresholds should come from this
// installation's own docs and state, with sources, rather than from the model's general
// impression of what a watchdog usually does.
//
// No 'height' triple — this card is in normal page flow, not a fixed panel, so the component's
// own defaults apply and the expand control still works against them. The Scheduler is the only
// placement whose size is a share of something and therefore has to be told.
vv_ai_chat_markup('vv-wd-ai', [
'profile' => 'varaverk',
'compact' => true,
'title' => 'Assistant',
'scopeLabel' => 'Watchdog',
'empty' => 'Ask about a strike, a restart, or a threshold on this page. '
. 'Why? on any strike asks against the orchestrator\'s log.',
'placeholder' => 'Ask about what is on this page…',
]); ?>
</div>
<?php endif; ?>
<?php
// The conf sections this page is about, drawn by the shared renderer. They were reachable
// only from the Settings tab's catch-all, which is a long way to go for a setting named
// after the page you are already looking at.
vv_conf_ui_card('vv-cf-watchdog', 'watchdog', 'Watchdog settings');
?>
<script>
(function() {
const GB = 1073741824;
// The assistant instance, once the card is on the page; null whenever AI is off. Every Why?
// button checks it, and none of them render without it — a control that opens a panel that does
// not exist is worse than no control.
let vvWdChat = null;
// What the chat says the operator is looking at. Starts as the tab itself and moves to the
// orchestrator's log when a Why? is pressed, because that is the only place any of this is
// written down: the watchdogs log through watchdog_orchestrator.sh and have no log files of
// their own — checked against /var/log/varaverk, where Watchdogs/ does not exist.
let vvWdScope = 'Watchdog';
const WD_LOG = 'Orchestrators/watchdog_orchestrator';
// Rendered as data attributes and read back by one delegated listener rather than written into an
// onclick. Container names and log paths reach these buttons from conf and from the filesystem, and
// a name containing a quote closes the attribute early and takes the rest of the handler with it.
// vvEscAttr is the one that escapes quotes; vvEscHtml deliberately does not.
function _why(label, q) {
if (!vvWdChat) return '';
return `<button class="vv-btn-sm vv-why-btn" title="Ask the assistant about this"
data-label="${vvEscAttr(label)}" data-q="${vvEscAttr(q)}">Why?</button>`;
}
function vvWdWhy(label, q) {
if (!vvWdChat || vvWdChat.busy()) return;
// Troubleshoot, never repair. This page changes nothing, and repair is the one profile that can
// — see the safeguard note in the header.
vvWdScope = WD_LOG;
vvWdChat.retarget('troubleshoot', label, 'now looking at ' + label);
const input = document.getElementById('vv-wd-ai-input');
if (input) input.value = q;
vvWdChat.send();
const card = document.getElementById('vv-wd-ai-card');
if (card) card.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
function _fmtBytes(b) {
if (!b) return '0';
if (b >= GB) return (b / GB).toFixed(1) + 'G';
if (b >= 1048576) return (b / 1048576).toFixed(0) + 'M';
return (b / 1024).toFixed(0) + 'K';
}
function _relTime(ts) {
if (!ts) return '—';
const s = Math.floor(Date.now() / 1000) - ts;
if (s < 60) return 'just now';
if (s < 3600) return Math.floor(s / 60) + 'm ago';
if (s < 86400) return Math.floor(s / 3600) + 'h ago';
return Math.floor(s / 86400) + 'd ago';
}
function _dur(s) {
const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60);
if (d) return d + 'd ' + h + 'h';
if (h) return h + 'h ' + m + 'm';
return m + 'm';
}
function _row(lbl, val) {
return `<div class="vv-wd-row"><span class="vv-wd-lbl">${lbl}</span><span class="vv-wd-val">${val}</span></div>`;
}
function _bar(pct, col) {
return `<div class="vv-wd-bar"><div class="vv-wd-bar-fill" style="width:${Math.min(pct,100)}%;background:${col}"></div></div>`;
}
// Escapes its own label, so every caller passes plain text and none of them has to remember. The
// labels here are container names, log paths and DDNS domains — all of them values this page reads
// rather than writes, which is exactly the category that must not be trusted into markup.
function _pill(label, cls) {
return `<span class="vv-wd-pill ${cls}">${vvEscHtml(label)}</span>`;
}
function _levelLabel(level) {
return ['OK', 'SOFT', 'MEDIUM', 'HARD'][level] || '?';
}
function _levelCls(level) {
return ['ok', 'soft', 'med', 'hard'][level] || 'ok';
}
// ── Pressure alert card ───────────────────────────────────────────────────────
function _pressureCard(node) {
const st = node.states;
if (!st || st.rw_level === 0) return '';
const level = st.rw_level;
const cls = _levelCls(level);
const label = _levelLabel(level);
const paused = (st.rw_paused || []).filter(Boolean);
const stopped = (st.rw_stopped || []).filter(Boolean);
const pausedHtml = paused.length ? paused.map(c => _pill(c, 'warn')).join('') : '';
const stoppedHtml = stopped.length ? stopped.map(c => _pill(c, 'err')).join('') : '';
return `<div class="vv-wd-card vv-wd-pressure" style="grid-column:1/-1">
<div style="display:flex;align-items:center;gap:10px;margin-bottom:8px;">
<span class="vv-wd-badge ${cls}">PRESSURE ${label}</span>
<span style="font-size:11px;color:#7a5020;">${vvEscHtml(node.id)} (${vvEscHtml(node.hostname)})</span>
${_why(node.id + ' memory pressure',
'The resource watchdog is at ' + label + ' pressure on ' + node.id
+ '. What drove it there, and what did it pause or stop?')}
${st.mem_shutdown ? `<span class="vv-wd-badge hard" style="margin-left:auto;">MEM SHUTDOWN ACTIVE</span>` : ''}
</div>
${paused.length ? `<div style="margin-bottom:4px;"><span class="vv-wd-lbl">Paused:</span> <span class="vv-wd-pill-row" style="display:inline-flex;">${pausedHtml}</span></div>` : ''}
${stopped.length ? `<div><span class="vv-wd-lbl">Stopped:</span> <span class="vv-wd-pill-row" style="display:inline-flex;">${stoppedHtml}</span></div>` : ''}
</div>`;
}
// ── System health card ────────────────────────────────────────────────────────
function _systemCard(node, cfg) {
const sys = node.system;
if (!sys) {
return `<div class="vv-wd-card">
<div class="vv-wd-node-h">
<span class="vv-wd-dot" style="background:#444"></span>
<span class="vv-wd-node-id">${vvEscHtml(node.id)}</span>
<span style="font-size:11px;color:#3a3a3a;">${vvEscHtml(node.hostname)}</span>
<span class="vv-wd-badge" style="margin-left:auto;background:#1a1a1a;color:#444">UNREACHABLE</span>
</div>
</div>`;
}
const memGb = sys.mem_avail / GB;
const memTotGb = sys.mem_total / GB;
const usedPct = memTotGb > 0 ? ((memTotGb - memGb) / memTotGb) * 100 : 0;
const memCol = memGb < cfg.sys_mem_gb ? '#ef5350'
: memGb < cfg.rw_hard_gb ? '#ef5350'
: memGb < cfg.rw_medium_gb ? '#ffb74d'
: memGb < cfg.rw_soft_gb ? '#cddc39' : '#4caf50';
const loadPct = sys.cores > 0 ? (sys.load1 / (sys.cores * cfg.rw_load_med)) * 100 : 0;
const loadCol = sys.load1 > sys.cores * cfg.rw_load_med ? '#ef5350'
: sys.load1 > sys.cores * cfg.rw_load_soft ? '#ffb74d'
: '#4caf50';
const apiOnly = sys.api_only === true;
const daemonDot = sys.daemon_ok === null ? '#555' : sys.daemon_ok ? '#4caf50' : '#ef5350';
const daemonTxt = sys.daemon_ok === null ? '—' : sys.daemon_ok ? 'daemon ok' : 'daemon err';
const st = node.states || {};
const level = st.rw_level || 0;
const dotCol = level >= 3 ? '#ef5350' : level >= 2 ? '#ffb74d' : level >= 1 ? '#cddc39'
: apiOnly ? '#4a7a9b' // blue-grey: API-only, no watchdog state
: '#4caf50';
return `<div class="vv-wd-card">
<div class="vv-wd-node-h">
<span class="vv-wd-dot" style="background:${dotCol}"></span>
<span class="vv-wd-node-id">${vvEscHtml(node.id)}</span>
<span style="font-size:11px;color:#3a3a3a;">${vvEscHtml(node.hostname)}</span>
<span class="vv-wd-badge ${apiOnly ? '' : _levelCls(level)}"
style="margin-left:auto;${apiOnly ? 'background:#0d1f2a;color:#4a9eff;' : ''}"
>${apiOnly ? 'API ONLY' : _levelLabel(level)}</span>
</div>
<div class="vv-wd-sec">System</div>
${_row('RAM free', `<span style="color:${memCol}">${_fmtBytes(sys.mem_avail)}</span> / ${_fmtBytes(sys.mem_total)}`)}
${_bar(usedPct, memCol)}
<div style="display:flex;justify-content:space-between;margin-top:1px;font-size:10px;color:#333">
<span>free</span>
<span>${cfg.rw_soft_gb}G soft · ${cfg.rw_hard_gb}G hard · ${cfg.sys_mem_gb}G reboot</span>
</div>
<div style="height:5px"></div>
${!apiOnly ? `${_row('Load avg', `<span style="color:${loadCol}">${sys.load1.toFixed(2)}</span> / ${sys.cores} cores`)}
${_bar(loadPct, loadCol)}
<div style="height:5px"></div>` : ''}
${_row('Uptime', sys.uptime ? _dur(sys.uptime) : '—')}
${_row('Docker', `<span style="color:${daemonDot}">${daemonTxt}</span>`)}
${!apiOnly ? (sys.oom_count > 0 ? _row('OOM kills', `<span style="color:#ef5350">${sys.oom_count}</span>`) : _row('OOM kills', '<span style="color:#333">0</span>')) : ''}
${apiOnly ? `<div style="font-size:10px;color:#333;margin-top:6px;">Watchdog state unavailable — SSH not configured</div>` : ''}
</div>`;
}
// ── Docker watchdog state card ────────────────────────────────────────────────
function _dockerCard(node, cfg) {
const st = node.states;
if (!st) return `<div class="vv-wd-card"><div class="vv-wd-sec">Docker Watchdog</div><div style="color:#3a3a3a;font-size:11px;padding:8px 0;">No data</div></div>`;
const strikes = Object.entries(st.ctr_strikes || {});
const skiplist = st.skiplist || [];
const restarts = (st.restarts || []).slice(0, 10);
const daemonCls = st.daemon_strikes > 0 ? 'err' : 'ok';
const allOk = strikes.length === 0 && skiplist.length === 0 && st.daemon_strikes === 0;
// Group restarts by container for last-24h summary
const rCounts = {};
for (const r of restarts) {
rCounts[r.name] = (rCounts[r.name] || 0) + 1;
}
let strikesHtml = '';
if (strikes.length === 0 && st.daemon_strikes === 0) {
strikesHtml = '<div style="color:#333;font-size:11px;">0 active strikes</div>';
} else {
if (st.daemon_strikes > 0) {
strikesHtml += `<div class="vv-wd-strike-row"><span class="vv-wd-strike-name">daemon</span><span class="vv-wd-strike-cnt">${st.daemon_strikes}</span></div>`;
}
for (const [name, cnt] of strikes) {
strikesHtml += `<div class="vv-wd-strike-row">
<span class="vv-wd-strike-name">${vvEscHtml(name)}</span>
<span class="vv-wd-strike-cnt">${cnt} / ${cfg.cpu_fail_lim}</span>
${_why(name + ' strikes', 'The docker watchdog has ' + cnt + ' strike(s) against '
+ name + '. What is failing its check?')}
</div>`;
}
}
let skipHtml = '';
if (skiplist.length === 0) {
skipHtml = '<div style="color:#333;font-size:11px;">empty</div>';
} else {
skipHtml = `<div class="vv-wd-pill-row">${skiplist.map(c => _pill(c, 'err')).join('')}</div>`;
}
let restartHtml = '';
const rcEntries = Object.entries(rCounts);
if (rcEntries.length === 0) {
restartHtml = '<div style="color:#333;font-size:11px;">none (24h)</div>';
} else {
restartHtml = rcEntries.map(([n, c]) =>
`<div class="vv-wd-row">
<span class="vv-wd-lbl">${vvEscHtml(n)}</span>
<span class="vv-wd-val" style="color:${c >= cfg.restart_limit ? '#ef5350' : '#ffb74d'}">${c}×</span>
${_why(n + ' restarts', n + ' has been restarted ' + c
+ ' time(s) in the last 24 hours. Why does it keep needing a restart?')}
</div>`
).join('');
}
return `<div class="vv-wd-card">
<div class="vv-wd-sec">Docker Watchdog</div>
<div style="display:flex;gap:6px;margin-bottom:8px;">
${_pill(st.daemon_restart ? 'daemon restarted' : 'daemon ok', daemonCls)}
${allOk ? _pill('all clear', 'ok') : ''}
</div>
<div class="vv-wd-sec">Strikes</div>
${strikesHtml}
<hr class="vv-wd-sep">
<div class="vv-wd-sec">Skip list</div>
${skipHtml}
<hr class="vv-wd-sep">
<div class="vv-wd-sec">Restarts (24h)</div>
${restartHtml}
</div>`;
}
// ── Stability / reboot card ───────────────────────────────────────────────────
function _stabilityCard(node, cfg) {
const st = node.states;
if (!st) return `<div class="vv-wd-card"><div class="vv-wd-sec">Stability</div><div style="color:#3a3a3a;font-size:11px;padding:8px 0;">No data</div></div>`;
const reboots = st.reboots || [];
const sysStr = Object.entries(st.sys_strikes || {});
const rebootCls = reboots.length >= cfg.reboot_limit ? 'err' : reboots.length > 0 ? 'warn' : 'ok';
let sysHtml = '';
if (sysStr.length === 0) {
sysHtml = '<div style="color:#333;font-size:11px;">0 active strikes</div>';
} else {
sysHtml = sysStr.map(([k, v]) =>
`<div class="vv-wd-strike-row">
<span class="vv-wd-strike-name" style="font-size:10px;">${vvEscHtml(k.replace(/_/g,' '))}</span>
<span class="vv-wd-strike-cnt">${v}</span>
${_why(k.replace(/_/g,' ') + ' strikes', 'The stability watchdog has ' + v
+ ' strike(s) for ' + k.replace(/_/g,' ') + '. What is it detecting?')}
</div>`
).join('');
}
// The one entry on this page that is always worth explaining, so the button is on the heading
// rather than each row — an unattended reboot is a single question, not one per occurrence.
const rebootHtml = reboots.length === 0
? '<div style="color:#333;font-size:11px;">none (12h)</div>'
: reboots.map(ts => `<div class="vv-wd-reboot-ts">${_relTime(ts)}</div>`).join('')
+ `<div style="margin-top:5px;">${_why(node.id + ' reboots',
'The stability watchdog rebooted ' + node.id + ' ' + reboots.length
+ ' time(s) in the last ' + cfg.reboot_window + ' hours. What forced it?')}</div>`;
return `<div class="vv-wd-card">
<div class="vv-wd-sec">Stability</div>
<div style="display:flex;gap:6px;margin-bottom:8px;">
${_pill(`${reboots.length} / ${cfg.reboot_limit} reboots`, rebootCls)}
${_pill(`${cfg.reboot_window}h window`, '')}
</div>
<div class="vv-wd-sec">Strikes</div>
${sysHtml}
<hr class="vv-wd-sep">
<div class="vv-wd-sec">Reboot history</div>
${rebootHtml}
</div>`;
}
// ── Config inventory card ─────────────────────────────────────────────────────
function _configCard(node) {
const cfg = node.config || {};
const mon = Object.entries(cfg.monitored || {});
const req = cfg.required || [];
const ign = cfg.ignore || [];
const paus = cfg.pause_list|| [];
const stop = cfg.stop_list || [];
const crit = cfg.critical || [];
const monHtml = mon.length === 0
? '<div style="color:#333;font-size:11px;">none</div>'
: mon.map(([name, mb]) => {
const gb = (mb / 1024).toFixed(0);
return `<div class="vv-wd-ctr-row"><span class="vv-wd-ctr-name">${vvEscHtml(name)}</span><span class="vv-wd-ctr-lim">${gb} GB</span></div>`;
}).join('');
const reqHtml = req.length === 0
? '<div style="color:#333;font-size:11px;">none</div>'
: `<div class="vv-wd-pill-row">${req.map(c => _pill(c, crit.includes(c) ? '' : '')).join('')}</div>`;
const ignHtml = ign.length === 0
? '<div style="color:#3a3a3a;font-size:11px;">none</div>'
: `<div class="vv-wd-pill-row">${ign.map(c => _pill(c, '')).join('')}</div>`;
const pausHtml = paus.length === 0
? '<div style="color:#3a3a3a;font-size:11px;">none</div>'
: `<div class="vv-wd-pill-row">${paus.map(c => _pill(c, 'warn')).join('')}</div>`;
const stopHtml = stop.length === 0
? '<div style="color:#3a3a3a;font-size:11px;">none</div>'
: `<div class="vv-wd-pill-row">${stop.map(c => _pill(c, 'err')).join('')}</div>`;
return `<div class="vv-wd-card" style="grid-column:span 4;">
<div style="display:flex;gap:6px;align-items:center;margin-bottom:8px;">
<span class="vv-wd-node-id">${vvEscHtml(node.id)}</span>
<span style="font-size:11px;color:#3a3a3a;">${vvEscHtml(node.hostname)}</span>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;">
<div>
<div class="vv-wd-sec">Mem limits (Tier 1)</div>
${monHtml}
<div style="height:8px"></div>
<div class="vv-wd-sec">Required</div>
${reqHtml}
</div>
<div>
<div class="vv-wd-sec">Pause at medium pressure</div>
${pausHtml}
<div style="height:8px"></div>
<div class="vv-wd-sec">Stop at hard pressure</div>
${stopHtml}
<div style="height:8px"></div>
<div class="vv-wd-sec">Scan ignore</div>
${ignHtml}
</div>
</div>
</div>`;
}
// ── Storage watchdog card ─────────────────────────────────────────────────────
function _storageCard(node, cfg) {
const st = node.states;
const nodeCfg = node.config || {};
if (!st) return `<div class="vv-wd-card"><div class="vv-wd-sec">Storage Watchdog</div><div style="color:#3a3a3a;font-size:11px;padding:8px 0;">No data</div></div>`;
const storWd = st.storage_wd || {};
const growthStr = Object.entries(storWd.growth_strikes || {});
const logStr = Object.entries(storWd.log_strikes || {});
const totalIssues = growthStr.length + logStr.length;
const allClear = totalIssues === 0;
// Baseline info
const bCount = storWd.baseline_count ?? 0;
const bAge = storWd.baseline_age_sec;
let baselineNote = bCount > 0
? `${bCount} containers tracked`
: 'no baseline yet (builds after first cycle)';
if (bAge != null && bCount > 0) {
const bAgeStr = bAge < 120 ? bAge + 's ago' : bAge < 3600 ? Math.floor(bAge/60) + 'm ago' : Math.floor(bAge/3600) + 'h ago';
baselineNote += ` · updated ${bAgeStr}`;
}
// Suppress ceilings configured for this host
const sizes = Object.entries(nodeCfg.appdata_sizes || {});
const sizesHtml = sizes.length
? sizes.map(([c, mb]) => _pill(c + ' <' + Math.round(mb/1024) + 'GB', '')).join('')
: '';
let growthHtml = '';
if (growthStr.length === 0) {
growthHtml = '<div style="color:#333;font-size:11px;">no active strikes</div>';
} else {
growthHtml = growthStr.map(([name, cnt]) =>
`<div class="vv-wd-strike-row">
<span class="vv-wd-strike-name">${vvEscHtml(name)}</span>
<span class="vv-wd-strike-cnt">${cnt} / ${cfg.stor_strike_lim}</span>
${_why(name + ' appdata growth', name + ' has ' + cnt
+ ' growth strike(s) — its appdata is growing more than '
+ cfg.growth_gb + 'GB per cycle. What is filling up?')}
</div>`
).join('');
}
let logHtml = '';
if (logStr.length === 0) {
logHtml = '<div style="color:#333;font-size:11px;">no active strikes</div>';
} else {
logHtml = logStr.map(([key, cnt]) => {
const display = key.length > 36 ? '…' + key.slice(-36) : key;
return `<div class="vv-wd-strike-row">
<span class="vv-wd-strike-name" style="font-size:10px;" title="${vvEscAttr(key)}">${vvEscHtml(display)}</span>
<span class="vv-wd-strike-cnt">${cnt} / ${cfg.stor_strike_lim}</span>
${_why('oversized log ' + key, key + ' has ' + cnt
+ ' log-size strike(s) against a ' + cfg.log_max_gb
+ 'GB ceiling. What is writing to it?')}
</div>`;
}).join('');
}
return `<div class="vv-wd-card">
<div class="vv-wd-sec">Storage Watchdog</div>
<div style="display:flex;gap:5px;flex-wrap:wrap;margin-bottom:6px;">
${allClear ? _pill('all clear', 'ok') : _pill(totalIssues + ' active strike' + (totalIssues !== 1 ? 's' : ''), 'warn')}
${_pill('growth >' + cfg.growth_gb + 'GB/cycle', '')}
${_pill('log max ' + cfg.log_max_gb + 'GB', '')}
${cfg.truncate_logs ? _pill('auto-truncate on', 'ok') : _pill('auto-truncate off', '')}
</div>
<div style="font-size:10px;color:#3a3a3a;margin-bottom:8px;">${baselineNote}</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
<div>
<div class="vv-wd-sec">Growth strikes</div>
${growthHtml}
</div>
<div>
<div class="vv-wd-sec">Log size strikes</div>
${logHtml}
</div>
</div>
${sizes.length ? `<hr class="vv-wd-sep"><div class="vv-wd-sec">Suppress ceilings (this host)</div><div class="vv-wd-pill-row">${sizesHtml}</div>` : ''}
</div>`;
}
// ── Network watchdog card ─────────────────────────────────────────────────────
function _networkCard(node, cfg) {
const st = node.states;
const nodeCfg = node.config || {};
if (!st) return `<div class="vv-wd-card"><div class="vv-wd-sec">Network Watchdog</div><div style="color:#3a3a3a;font-size:11px;padding:8px 0;">No data</div></div>`;
const netWd = st.network_wd || {};
const npmStr = netWd.npm_strikes ?? 0;
const npmCls = npmStr >= cfg.npm_strike_lim ? 'err' : npmStr > 0 ? 'warn' : 'ok';
const ddnsDomain = nodeCfg.ddns_domain || '';
const ddnsCtr = nodeCfg.ddns_container || '';
const npmUrl = nodeCfg.npm_url || '';
const ddnsHtml = ddnsDomain
? `${_row('DDNS domain', `<span style="color:#888;">${vvEscHtml(ddnsDomain)}</span>`)}
${ddnsCtr ? _row('DDNS container', `<span style="color:#888;">${vvEscHtml(ddnsCtr)}</span>`) : ''}`
: _row('DDNS', '<span style="color:#444;">not configured for this host</span>');
const npmHtml = npmUrl
? `${_row('NPM URL', `<span style="color:#888;font-size:10px;">${vvEscHtml(npmUrl)}</span>`)}
${_row('NPM strikes', `<span class="vv-wd-pill ${npmCls}" style="font-size:10px;">${npmStr} / ${cfg.npm_strike_lim}</span>`
+ (npmStr > 0 ? ' ' + _why('NPM strikes on ' + node.id,
'The network watchdog has ' + npmStr + ' strike(s) against NPM on '
+ node.id + '. What check is failing?') : ''))}`
: _row('NPM check', '<span style="color:#444;">not configured for this host</span>');
return `<div class="vv-wd-card">
<div class="vv-wd-sec">Network Watchdog</div>
<div style="display:flex;gap:5px;flex-wrap:wrap;margin-bottom:8px;">
${cfg.net_wd_enabled ? _pill('enabled', 'ok') : _pill('disabled', '')}
${_pill('Tailscale check ' + (cfg.ts_check ? 'on' : 'off'), cfg.ts_check ? '' : '')}
${npmUrl ? _pill('NPM ' + npmStr + '/' + cfg.npm_strike_lim + ' strikes', npmCls) : ''}
</div>
${ddnsHtml}
<hr class="vv-wd-sep">
${npmHtml}
</div>`;
}
// ── Main render ───────────────────────────────────────────────────────────────
function _render(data) {
const nodes = data.nodes || [];
const cfg = data.cfg || {};
let html = '';
// Per-host: pressure alert (if active) then all 5 watchdog cards in one equal-spaced row
for (const node of nodes) {
html += _pressureCard(node);
html += `<div class="vv-wd-host-row">
${_systemCard(node, cfg)}
${_dockerCard(node, cfg)}
${_stabilityCard(node, cfg)}
${_storageCard(node, cfg)}
${_networkCard(node, cfg)}
</div>`;
}
// Config inventory — separate row, each node span 4 (2 nodes = full row)
for (const node of nodes) html += _configCard(node);
if (!html) html = '<div style="grid-column:1/-1;color:#444;font-size:12px;padding:16px 0;text-align:center;">No nodes configured.</div>';
document.getElementById('vv-wd-grid').innerHTML = html;
const ts = data.ts
? new Date(data.ts * 1000).toLocaleString([], {
month:'numeric', day:'numeric', year:'numeric',
hour:'2-digit', minute:'2-digit', second:'2-digit'})
: '';
document.getElementById('vv-wd-ts').textContent = ts ? 'Updated: ' + ts : '';
}
function vvWdLoad() {
fetch('/plugins/varaverk/api/watchdog.php')
.then(r => r.json())
.then(_render)
.catch(() => {
document.getElementById('vv-wd-grid').innerHTML =
'<div style="grid-column:1/-1;color:#555;font-size:12px;padding:24px 0;text-align:center;">Error loading watchdog data — check API</div>';
});
}
// One listener on the grid rather than a handler per button. The grid is replaced wholesale every
// 30 seconds, so anything bound to a button inside it would be thrown away on the next poll — and
// re-binding after each render is the version of this that quietly stops working the day a card
// gains a button and nobody updates the binder.
document.getElementById('vv-wd-grid').addEventListener('click', ev => {
const b = ev.target.closest('.vv-why-btn');
if (!b) return;
vvWdWhy(b.dataset.label || 'this watchdog', b.dataset.q || 'What does this mean?');
});
// Before the first load, not after. _why() renders nothing while vvWdChat is null, so a render
// that beat the instance into existence would draw the whole page without a single Why? button
// and not correct itself until the next poll thirty seconds later.
if (document.getElementById('vv-wd-ai-chat')) {
vvWdChat = VvAiChat({
prefix: 'vv-wd-ai',
profile: 'varaverk',
scopeLabel: 'Watchdog',
// Read at send time, not captured: a Why? moves the scope to the orchestrator's log, and the
// question is sent from that same click.
scope: () => vvWdScope,
// Same reason the Monitor card pins its own: without it the card resumes whatever thread was
// last touched anywhere, so opening this tab could land it mid-conversation from the Scheduler
// under a profile this page never offers.
resumeProfile: 'varaverk',
// Reasoning is worth watching for a diagnosis and noise for a definition — same rule the
// Scheduler applies.
think: p => p === 'troubleshoot',
empty: 'Ask about a strike, a restart, or a threshold on this page. '
+ 'Why? on any strike asks against the orchestrator\'s log.',
});
}
vvWdLoad();
setInterval(vvWdLoad, 30000);
})();
</script>