Plugin: common.php library, watchdog expansion, monitor + scheduler improvements

PHP architecture:
- Extract common.php from monitor.php — shared system functions (vv_system_info,
  vv_memory_breakdown, vv_remote_hosts_stats, disk/GPU/UPS/network/docker/parity, etc.)
  now live in one place; monitor.php and watchdog.php both require common.php
- Add unraid_api.php as explicit include (was implicit via config.php chain)
- confform.php: add missing require_once config.php (implicit dep made explicit)
- Delete orphaned pages/docs.php and pages/config.php (absorbed into scheduler)

Watchdog page:
- Add Storage watchdog card (growth + log strikes, baseline age, suppress ceilings)
- Add Network watchdog card (NPM strikes, DDNS domain/container, NPM URL)
- One host per row layout — all 5 watchdog cards equally spaced via inner grid
- Watchdog now uses Unraid API for local system stats; remote nodes with API key
  but no SSH get system info from vv_remote_hosts_stats() with api_only flag
- SSH bundle: /proc/meminfo passed as raw section instead of awk-parsed header
  fields — fixes RAM showing 0 on remote hosts where awk quoting was unreliable
- vv_wd_local_system() rewritten as thin wrapper over vv_system_info() + vv_memory_breakdown()

Monitor page:
- Watchdog card: add stability strikes, storage watchdog strikes, network NPM status,
  live system stats (rootfs/log/tmp %, RAM free, load, CPU temp, zombies, NIC, sshd)
- Row height: switch from max-height on cards to minmax(0, calc(...)) on grid track —
  all cards in a row now fill to the tallest card's height correctly (fixes Pools card
  being shorter than neighbours)

Scheduler page:
- Add Tools section above Custom Scripts — lists Tools/*.sh with run/dry-run/cron/log
- vv_tools_scripts() function in scheduler.php include

Tools:
- Add docker_prune_images.sh — removes dangling Docker images; --dry-run and --status modes
This commit is contained in:
Gmer4Lfe
2026-05-29 23:33:52 -04:00
parent 8f05f0d27c
commit 86048b32d3
13 changed files with 2072 additions and 898 deletions
+155 -22
View File
@@ -28,6 +28,17 @@
.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;">
@@ -113,7 +124,7 @@ function _pressureCard(node) {
function _systemCard(node, cfg) {
const sys = node.system;
if (!sys) {
return `<div class="vv-wd-card" style="grid-column:span 2;">
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">${node.id}</span>
@@ -136,18 +147,24 @@ function _systemCard(node, cfg) {
: sys.load1 > sys.cores * cfg.rw_load_soft ? '#ffb74d'
: '#4caf50';
const daemonDot = sys.daemon_ok ? '#4caf50' : '#ef5350';
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' : '#4caf50';
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" style="grid-column:span 2;">
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">${node.id}</span>
<span style="font-size:11px;color:#3a3a3a;">${node.hostname}</span>
<span class="vv-wd-badge ${_levelCls(level)}" style="margin-left:auto;">${_levelLabel(level)}</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)}`)}
@@ -157,19 +174,20 @@ function _systemCard(node, cfg) {
<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>
${_row('Load avg', `<span style="color:${loadCol}">${sys.load1.toFixed(2)}</span> / ${sys.cores} cores`)}
${!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', _dur(sys.uptime))}
${_row('Docker', `<span style="color:${daemonDot}">${sys.daemon_ok ? 'daemon ok' : 'daemon err'}</span>`)}
${sys.oom_count > 0 ? _row('OOM kills', `<span style="color:#ef5350">${sys.oom_count}</span>`) : _row('OOM kills', '<span style="color:#333">0</span>')}
<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" style="grid-column:span 2;"><div class="vv-wd-sec">Docker Watchdog</div><div style="color:#3a3a3a;font-size:11px;padding:8px 0;">No data</div></div>`;
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 || [];
@@ -212,7 +230,7 @@ function _dockerCard(node, cfg) {
).join('');
}
return `<div class="vv-wd-card" style="grid-column:span 3;">
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)}
@@ -232,7 +250,7 @@ function _dockerCard(node, cfg) {
// ── Stability / reboot card ───────────────────────────────────────────────────
function _stabilityCard(node, cfg) {
const st = node.states;
if (!st) return `<div class="vv-wd-card" style="grid-column:span 1;"><div class="vv-wd-sec">Stability</div><div style="color:#3a3a3a;font-size:11px;padding:8px 0;">No data</div></div>`;
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 || {});
@@ -251,7 +269,7 @@ function _stabilityCard(node, cfg) {
? '<div style="color:#333;font-size:11px;">none (12h)</div>'
: reboots.map(ts => `<div class="vv-wd-reboot-ts">${_relTime(ts)}</div>`).join('');
return `<div class="vv-wd-card" style="grid-column:span 3;">
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)}
@@ -325,23 +343,138 @@ function _configCard(node) {
</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">${name}</span>
<span class="vv-wd-strike-cnt">${cnt} / ${cfg.stor_strike_lim}</span>
</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="${key}">${display}</span>
<span class="vv-wd-strike-cnt">${cnt} / ${cfg.stor_strike_lim}</span>
</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;">${ddnsDomain}</span>`)}
${ddnsCtr ? _row('DDNS container', `<span style="color:#888;">${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;">${npmUrl}</span>`)}
${_row('NPM strikes', `<span class="vv-wd-pill ${npmCls}" style="font-size:10px;">${npmStr} / ${cfg.npm_strike_lim}</span>`)}`
: _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 = '';
// Pressure alerts (full width, per node)
for (const node of nodes) html += _pressureCard(node);
// System + watchdog state rows per node
// Per-host: pressure alert (if active) then all 5 watchdog cards in one equal-spaced row
for (const node of nodes) {
html += _systemCard(node, cfg);
html += _dockerCard(node, cfg);
html += _stabilityCard(node, cfg);
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 per node
// 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>';