Varaverk: FallBack + Watchdog tabs; plugin path restructure to Plugin/unraid/

- FallBack tab: per-node tier inventory + active fallback card with duration, tier, handback strikes, running container status
- Watchdog tab: live system health (RAM bar + thresholds, load, uptime, daemon), docker watchdog strikes + skip list + restart history, stability strikes + reboot log, resource pressure alert card, config inventory (mem limits, required, pause/stop lists)
- Swapped partnership/arrs tab order; FallBack between partnership and watchdog
- Plugin source tree moved from Plugin/usr/local/emhttp/plugins/varaverk/ to Plugin/unraid/
- Deployment/ conf templates added
This commit is contained in:
Gmer4Lfe
2026-05-28 22:24:50 -04:00
parent 64d95aa991
commit fb051b60c1
73 changed files with 5896 additions and 462 deletions
+231
View File
@@ -0,0 +1,231 @@
<style>
.vv-arr-card { background:#161616;border:1px solid #2a2a2a;border-radius:6px;padding:10px;min-width:0; }
.vv-arr-head { display:flex;justify-content:space-between;align-items:center;margin-bottom:8px; }
.vv-arr-name { font-size:12px;font-weight:bold;text-transform:uppercase;color:#888;letter-spacing:.05em; }
.vv-arr-status{ display:flex;align-items:center;gap:5px; }
.vv-arr-dot { width:7px;height:7px;border-radius:50%;flex-shrink:0; }
.vv-arr-ver { font-size:10px;color:#555; }
.vv-arr-row { display:flex;justify-content:space-between;align-items:baseline;gap:8px;margin:2px 0; }
.vv-arr-lbl { font-size:11px;color:#555;white-space:nowrap; }
.vv-arr-val { font-size:12px;color:#bbb;text-align:right; }
.vv-arr-sep { border:none;border-top:1px solid #222;margin:6px 0; }
.vv-arr-sub { font-size:10px;color:#444;margin:1px 0 1px 2px; }
.vv-arr-sub.warn { color:#e57; }
.vv-arr-path { font-size:10px;color:#393939;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-top:4px; }
.vv-arr-node { grid-column:1/-1; }
.vv-arr-node-head { display:flex;align-items:baseline;gap:8px;margin-bottom:10px; }
.vv-arr-node-title{ font-size:12px;font-weight:bold;color:#666;letter-spacing:.06em;text-transform:uppercase; }
.vv-arr-node-host { font-size:11px;color:#3a3a3a; }
.vv-arr-cards { display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:10px; }
.vv-arr-stat-row { display:flex;gap:16px;flex-wrap:wrap; }
.vv-arr-stat { display:flex;flex-direction:column;gap:1px; }
.vv-arr-stat-val { font-size:15px;font-weight:bold;color:#ccc; }
.vv-arr-stat-lbl { font-size:10px;color:#555; }
</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;">Arrs</span>
<span style="font-size:11px;color:#3a3a3a;" id="vv-arrs-ts"></span>
</div>
<div id="vv-arrs-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>
<script>
(function() {
function _relTime(ts) {
if (!ts) return '—';
const d = Math.floor(Date.now() / 1000) - ts;
if (d < 60) return 'just now';
if (d < 3600) return Math.floor(d / 60) + 'm ago';
if (d < 86400) return Math.floor(d / 3600) + 'h ago';
if (d < 172800) return 'yesterday';
return Math.floor(d / 86400) + 'd ago';
}
function _n(v) { return v == null ? '—' : Number(v).toLocaleString(); }
function _bytes(b) {
if (!b) return '—';
const u = ['B','KB','MB','GB','TB']; let i = 0;
while (b >= 1024 && i < u.length - 1) { b /= 1024; i++; }
return b.toFixed(1) + ' ' + u[i];
}
function _row(lbl, val) {
return `<div class="vv-arr-row"><span class="vv-arr-lbl">${lbl}</span><span class="vv-arr-val">${val}</span></div>`;
}
function _arrCard(arr) {
const names = {sonarr:'Sonarr', radarr:'Radarr', lidarr:'Lidarr'};
const name = names[arr.type] || arr.type;
const online = arr.online;
const dotCol = online ? '#4caf50' : (arr.remote ? '#444' : '#c62828');
const verStr = online ? (arr.version || 'online') : (arr.remote ? 'not reachable' : 'offline');
let body = '';
if (online) {
// Library
if (arr.type === 'sonarr') {
body += _row('Series', _n(arr.total));
body += _row('Monitored', _n(arr.monitored));
body += _row('Episodes', _n(arr.episodes));
} else if (arr.type === 'radarr') {
body += _row('Movies', _n(arr.total));
body += _row('Monitored', _n(arr.monitored));
body += _row('Files', _n(arr.files));
} else if (arr.type === 'lidarr') {
body += _row('Artists', _n(arr.total));
body += _row('Monitored', _n(arr.monitored));
if (arr.albums) body += _row('Albums', _n(arr.albums));
}
// Disk — show largest entry
if (arr.disk && arr.disk.length) {
const d = arr.disk.reduce((a,b) => (b.totalSpace||0) > (a.totalSpace||0) ? b : a);
if (d.freeSpace && d.totalSpace) {
body += _row('Free', _bytes(d.freeSpace) + ' / ' + _bytes(d.totalSpace));
}
}
// Queue
const q = arr.queue || {};
const qDl = q.dl || 0;
const qWrn = q.warn || 0;
const qErr = q.err || 0;
const qTot = qDl + qWrn + qErr;
const qCol = qErr ? '#f44' : (qWrn ? '#ff9800' : '#bbb');
body += `<hr class="vv-arr-sep">`;
const qStr = qTot === 0
? `<span style="color:#3a3a3a">idle</span>`
: `<span style="color:${qCol}">${qDl}↓&nbsp; ${qWrn}⚠&nbsp; ${qErr}✗</span>`;
body += _row('Queue', qStr);
// Health
if (arr.health && arr.health.length) {
const col = arr.health.some(h => h.type === 'error') ? '#f44' : '#ff9800';
body += `<div class="vv-arr-sub warn" style="color:${col}">` +
arr.health.slice(0,2).map(h => h.message || h.type).join('<br>') + '</div>';
}
// Cleanup
const cl = arr.cleanup || {};
if (cl.last_run) {
body += `<hr class="vv-arr-sep">`;
const clOk = cl.status === 'ok'
? '<span style="color:#4caf50">✓</span>'
: '<span style="color:#f44">✗</span>';
body += _row('Cleanup', _relTime(cl.last_run) + '&nbsp;' + clOk);
if (cl.tracked != null) {
body += `<div class="vv-arr-sub">${_n(cl.tracked)} files · ${_n(cl.total)} items</div>`;
const orph = cl.orphans || 0, junk = cl.junk || 0;
if (orph || junk) {
body += `<div class="vv-arr-sub warn">${orph} orphans · ${junk} junk</div>`;
} else {
body += `<div class="vv-arr-sub">0 orphans · 0 junk</div>`;
}
}
}
// Discovery
const disc = arr.discovery || {};
if (disc.last_run) {
if (!cl.last_run) body += `<hr class="vv-arr-sep">`;
const added = disc.added != null ? `&nbsp;<span style="color:#6fcf97">+${disc.added}</span>` : '';
body += _row('Discovery', _relTime(disc.last_run) + added);
}
}
body += `<div class="vv-arr-path">${arr.root || '—'}</div>`;
return `<div class="vv-arr-card">
<div class="vv-arr-head">
<span class="vv-arr-name">${name}</span>
<span class="vv-arr-status">
<span class="vv-arr-dot" style="background:${dotCol}"></span>
<span class="vv-arr-ver">${verStr}</span>
</span>
</div>
${body}
</div>`;
}
function _nodeSection(node, ownerHost) {
const isOwner = node.host === ownerHost;
const badge = isOwner ? ' <span style="color:#3a4a3a;font-size:10px;">owner</span>' : '';
const cards = node.arrs.map(_arrCard).join('');
return `<div class="vv-card vv-arr-node">
<div class="vv-arr-node-head">
<span class="vv-arr-node-title">${node.host.toUpperCase()}</span>
<span class="vv-arr-node-host">${node.name}${badge}</span>
</div>
<div class="vv-arr-cards">${cards}</div>
</div>`;
}
function _syncCard(sync) {
let body = '';
if (!sync.last_run) {
body = '<div style="color:#3a3a3a;font-size:12px;">Never run</div>';
} else {
const ok = sync.status === 'ok'
? '<span style="color:#4caf50">✓</span>'
: '<span style="color:#f44">✗</span>';
body += _row('Last run', _relTime(sync.last_run) + '&nbsp;' + ok);
if (sync.added != null) body += _row('Added', `<span style="color:#6fcf97">+${_n(sync.added)}</span>`);
if (sync.nodes != null) body += _row('Nodes', _n(sync.nodes));
}
if (sync.blocklist_count != null) {
body += `<hr class="vv-arr-sep">`;
body += _row('Blocklist', _n(sync.blocklist_count) + ' entries');
}
return `<div class="vv-card" style="grid-column:span 4;">
<h3>Arrs Sync</h3>${body}</div>`;
}
function _recoveryCard(rec) {
let body = '';
if (!rec.last_run) {
body = '<div style="color:#3a3a3a;font-size:12px;">Never run</div>';
} else {
const ok = rec.status === 'ok'
? '<span style="color:#4caf50">✓</span>'
: '<span style="color:#f44">✗</span>';
body += _row('Last run', _relTime(rec.last_run) + '&nbsp;' + ok);
body += _row('Fixed', _n(rec.fixed));
body += _row('Re-searched',_n(rec.searched));
}
return `<div class="vv-card" style="grid-column:span 4;">
<h3>Failed / Stalled Recovery</h3>${body}</div>`;
}
function _render(data) {
let html = '';
for (const node of (data.nodes || [])) {
html += _nodeSection(node, data.host);
}
html += _syncCard(data.sync || {});
html += _recoveryCard(data.recovery || {});
document.getElementById('vv-arrs-grid').innerHTML = html || '<div style="grid-column:1/-1;color:#444;font-size:12px;padding:16px 0;text-align:center;">No arrs configured.</div>';
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-arrs-ts').textContent = ts ? 'Updated: ' + ts : '';
}
function vvArrsLoad() {
fetch('/plugins/varaverk/api/arrs.php')
.then(r => r.json())
.then(_render)
.catch(() => {});
}
vvArrsLoad();
setInterval(vvArrsLoad, 60000);
})();
</script>
+83
View File
@@ -0,0 +1,83 @@
<?php
require_once dirname(__DIR__) . '/include/config.php';
$files = vv_get_conf_files();
$active = $_GET['conf'] ?? ($files[0] ?? '');
if (!in_array($active, $files)) $active = $files[0] ?? '';
$currentScriptsDir = SCRIPTS_DIR;
?>
<div id="vv-config">
<!-- Plugin Settings -->
<div class="vv-card vv-wide" style="margin-bottom:16px;">
<h3>Plugin Settings</h3>
<div class="vv-job-row" style="max-width:700px;gap:8px;">
<label style="color:#aaa;font-size:13px;white-space:nowrap;">Scripts directory</label>
<input type="text" id="vv-scripts-dir" value="<?= htmlspecialchars($currentScriptsDir) ?>"
style="flex:1;background:#111;border:1px solid #444;color:#ddd;padding:4px 8px;
border-radius:4px;font-family:monospace;font-size:13px;">
<button type="button" onclick="vvSaveSettings()" style="padding:4px 14px;background:#4caf50;
border:none;color:#fff;border-radius:4px;cursor:pointer;font-size:13px;">Save</button>
<span id="vv-settings-status" style="font-size:13px;color:#aaa;"></span>
</div>
</div>
<!-- File selector -->
<div id="vv-conf-tabs">
<?php foreach ($files as $f): ?>
<a href="?tab=config&conf=<?= urlencode($f) ?>"
class="vv-conf-tab<?= $f === $active ? ' active' : '' ?>">
<?= htmlspecialchars($f) ?>
</a>
<?php endforeach; ?>
</div>
<?php if ($active): ?>
<form id="vv-conf-form">
<input type="hidden" name="file" value="<?= htmlspecialchars($active) ?>">
<textarea id="vv-conf-editor" name="content" spellcheck="false"><?=
htmlspecialchars(vv_read_conf_raw($active))
?></textarea>
<div id="vv-conf-actions">
<button type="button" onclick="vvSaveConf()">Save</button>
<span id="vv-conf-status"></span>
</div>
</form>
<?php else: ?>
<p>No configuration files found.</p>
<?php endif; ?>
</div>
<script>
function vvPost(url, data) {
const params = new URLSearchParams({csrf_token, ...data});
return fetch(url, {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: params
}).then(r => r.json());
}
function vvSaveSettings() {
const dir = document.getElementById('vv-scripts-dir').value.trim();
const status = document.getElementById('vv-settings-status');
if (!dir) { status.textContent = '✗ Path required'; return; }
status.textContent = 'Saving...';
vvPost('/plugins/varaverk/api/settings.php', {scripts_dir: dir})
.then(d => { status.textContent = d.ok ? '✓ Saved — reload to apply' : '✗ ' + (d.error ?? 'Error'); })
.catch(() => { status.textContent = '✗ Request failed'; });
}
function vvSaveConf() {
const form = document.getElementById('vv-conf-form');
const file = form.querySelector('[name=file]').value;
const content = form.querySelector('[name=content]').value;
const status = document.getElementById('vv-conf-status');
status.textContent = 'Saving...';
vvPost('/plugins/varaverk/api/config.php', {file, content})
.then(d => { status.textContent = d.ok ? '✓ Saved' : '✗ ' + (d.error ?? 'Error'); })
.catch(() => { status.textContent = '✗ Request failed'; });
}
</script>
+44
View File
@@ -0,0 +1,44 @@
<?php
require_once dirname(__DIR__) . '/include/docs.php';
require_once dirname(__DIR__) . '/include/config.php';
$tree = vv_docs_tree();
$vars = vv_conf_vars();
$active = $_GET['doc'] ?? '';
// Validate: must be a .md file within SCRIPTS_DIR
$active = preg_match('/^[a-zA-Z0-9_\-\/]+\.md$/', $active) ? $active : '';
if ($active && !file_exists(SCRIPTS_DIR . '/' . $active)) $active = '';
?>
<div id="vv-docs">
<div id="vv-docs-sidebar">
<h3>Documents</h3>
<ul>
<?php foreach ($tree as $rel): ?>
<li>
<a href="?tab=docs&doc=<?= urlencode($rel) ?>"
class="<?= $rel === $active ? 'active' : '' ?>">
<?= htmlspecialchars($rel) ?>
</a>
</li>
<?php endforeach; ?>
</ul>
</div>
<div id="vv-docs-content">
<?php if ($active): ?>
<div class="vv-doc-body">
<?= vv_docs_render($active, $vars) ?>
</div>
<p class="vv-doc-hint">
Values shown in <code class="vv-live-var">green</code> are live from your conf files.
<code class="vv-unknown-var">Orange</code> means the variable was not found.
</p>
<?php else: ?>
<p>Select a document from the sidebar.</p>
<?php endif; ?>
</div>
</div>
+243
View File
@@ -0,0 +1,243 @@
<style>
.vv-fb-active { background:#1a1200;border:1px solid #5a3800;border-radius:6px;padding:12px 14px; }
.vv-fb-active-h { display:flex;align-items:baseline;gap:10px;margin-bottom:8px; }
.vv-fb-badge { font-size:11px;font-weight:bold;letter-spacing:.06em;padding:2px 7px;border-radius:3px;flex-shrink:0; }
.vv-fb-badge.fb { background:#5a3800;color:#ffb74d; }
.vv-fb-badge.norm { background:#1a2a1a;color:#4caf50; }
.vv-fb-badge.dark { background:#2a1a2a;color:#9c27b0; }
.vv-fb-badge.nonet{ background:#1a1a2a;color:#5c7cfa; }
.vv-fb-meta { display:flex;gap:18px;flex-wrap:wrap;margin-bottom:10px; }
.vv-fb-meta-item{ display:flex;flex-direction:column;gap:1px; }
.vv-fb-meta-val { font-size:17px;font-weight:bold;color:#ffb74d; }
.vv-fb-meta-lbl { font-size:10px;color:#5a4020; }
.vv-fb-ctrs { display:flex;gap:6px;flex-wrap:wrap;margin-top:6px; }
.vv-fb-ctr { font-size:11px;padding:2px 8px;border-radius:3px;background:#2a1e00;color:#ffb74d;border:1px solid #4a2e00; }
.vv-fb-ctr.running { background:#1a2a1a;color:#6fcf97;border-color:#2d4a2d; }
.vv-fb-ctr.stopped { background:#2a1a1a;color:#e57;border-color:#4a2020;opacity:.7; }
.vv-fb-node { grid-column:span 4; }
.vv-fb-node-h { display:flex;align-items:baseline;gap:8px;margin-bottom:10px; }
.vv-fb-node-id { font-size:12px;font-weight:bold;color:#666;letter-spacing:.06em;text-transform:uppercase; }
.vv-fb-node-nm { font-size:11px;color:#3a3a3a; }
.vv-fb-arrow { font-size:11px;color:#333; }
.vv-fb-covers { font-size:11px;color:#3a3a3a; }
.vv-fb-tier { margin-bottom:8px; }
.vv-fb-tier-h { display:flex;align-items:baseline;gap:6px;margin-bottom:4px; }
.vv-fb-tier-lbl { font-size:11px;font-weight:bold;color:#555;text-transform:uppercase;letter-spacing:.04em; }
.vv-fb-tier-delay { font-size:10px;color:#3a3a3a; }
.vv-fb-tier-pills { display:flex;gap:5px;flex-wrap:wrap; }
.vv-fb-pill { font-size:11px;padding:2px 7px;border-radius:3px;background:#1e1e1e;color:#777;border:1px solid #2a2a2a; }
.vv-fb-pill.active-t { background:#1a2010;color:#8bc34a;border-color:#2d3a1d; }
.vv-fb-pill.empty { color:#333;font-style:italic; }
.vv-fb-state-dot { width:6px;height:6px;border-radius:50%;flex-shrink:0;margin-top:3px; }
.vv-fb-sep { border:none;border-top:1px solid #222;margin:8px 0; }
.vv-fb-disabled { grid-column:1/-1;color:#3a3a3a;font-size:12px;padding:20px 0;text-align:center; }
</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;">FallBack</span>
<span style="font-size:11px;color:#3a3a3a;" id="vv-fb-ts"></span>
</div>
<div id="vv-fb-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>
<script>
(function() {
function _dur(startTs) {
if (!startTs || startTs === 0) return '—';
const s = Math.floor(Date.now() / 1000) - startTs;
if (s < 60) return s + 's';
if (s < 3600) return Math.floor(s / 60) + 'm';
const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60);
return m ? h + 'h ' + m + 'm' : h + 'h';
}
function _fmtDelay(min) {
if (!min) return 'immediate';
if (min < 60) return min + 'min';
const h = Math.floor(min / 60), m = min % 60;
return m ? h + 'h ' + m + 'm' : h + 'h';
}
function _activeTier(state) {
if (!state) return 0;
if (state.tier4_started) return 4;
if (state.tier3_started) return 3;
if (state.tier2_started) return 2;
return 1;
}
function _stateBadge(st) {
const map = {
NORMAL: ['norm', 'NORMAL'],
FALLBACK: ['fb', 'FALLBACK'],
NO_INTERNET: ['nonet', 'NO INTERNET'],
DARK: ['dark', 'DARK'],
OFFLINE: ['dark', 'OFFLINE'],
UNREACHABLE: ['dark', 'UNREACHABLE'],
UNKNOWN: ['dark', 'UNKNOWN'],
};
const [cls, label] = map[st] || ['dark', st];
return `<span class="vv-fb-badge ${cls}">${label}</span>`;
}
function _stateDot(st) {
const col = {
NORMAL:'#4caf50', FALLBACK:'#ffb74d',
NO_INTERNET:'#5c7cfa', DARK:'#9c27b0',
OFFLINE:'#555', UNREACHABLE:'#555', UNKNOWN:'#333',
}[st] || '#333';
return `<span class="vv-fb-state-dot" style="background:${col}"></span>`;
}
function _activeCard(nodes, handbackReq) {
const active = nodes.filter(n => n.state && n.state.state === 'FALLBACK');
if (!active.length) return '';
return active.map(covering => {
const st = covering.state;
const tier = _activeTier(st);
const cov = covering.covers;
const covered = cov ? cov.hostname : '?';
// All containers that should be running at current tier
let expected = [...(cov?.tier1 || [])];
if (tier >= 2) expected = expected.concat(cov?.tier2 || []);
if (tier >= 3) expected = expected.concat(cov?.tier3 || []);
if (tier >= 4) expected = expected.concat(cov?.tier4 || []);
const runningSet = new Set(covering.running || []);
const ctrPills = expected.length
? expected.map(c => {
const cls = runningSet.has(c) ? 'running' : 'stopped';
const sym = runningSet.has(c) ? '▲' : '▼';
return `<span class="vv-fb-ctr ${cls}">${sym} ${c}</span>`;
}).join('')
: '<span style="color:#5a4020;font-size:11px;">No containers configured for this tier</span>';
return `<div class="vv-card vv-fb-active" style="grid-column:1/-1;">
<div class="vv-fb-active-h">
${_stateBadge('FALLBACK')}
<span style="font-size:12px;color:#aa7020;">${covering.id} covering ${cov?.id || '?'} (${covered})</span>
</div>
<div class="vv-fb-meta">
<div class="vv-fb-meta-item">
<span class="vv-fb-meta-val">${_dur(st.fallback_start)}</span>
<span class="vv-fb-meta-lbl">DURATION</span>
</div>
<div class="vv-fb-meta-item">
<span class="vv-fb-meta-val">Tier ${tier}</span>
<span class="vv-fb-meta-lbl">ACTIVE TIER</span>
</div>
<div class="vv-fb-meta-item">
<span class="vv-fb-meta-val">${st.handback_strikes} / ${handbackReq}</span>
<span class="vv-fb-meta-lbl">HANDBACK STRIKES</span>
</div>
<div class="vv-fb-meta-item">
<span class="vv-fb-meta-val">${expected.length}</span>
<span class="vv-fb-meta-lbl">CONTAINERS</span>
</div>
</div>
<div class="vv-fb-ctrs">${ctrPills}</div>
</div>`;
}).join('');
}
function _tierSection(tiers, activeTier, delays) {
const defs = [
{ n: 1, key: 'tier1', label: 'Tier 1', delay: 0 },
{ n: 2, key: 'tier2', label: 'Tier 2', delay: delays?.tier2 },
{ n: 3, key: 'tier3', label: 'Tier 3', delay: delays?.tier3 },
{ n: 4, key: 'tier4', label: 'Tier 4', delay: delays?.tier4 },
];
return defs.map(({ n, key, label, delay }) => {
const containers = tiers[key] || [];
const isActive = activeTier >= n;
const pills = containers.length
? containers.map(c => `<span class="vv-fb-pill${isActive ? ' active-t' : ''}">${c}</span>`).join('')
: `<span class="vv-fb-pill empty">none</span>`;
const delayStr = n === 1 ? 'immediate' : _fmtDelay(delay);
return `<div class="vv-fb-tier">
<div class="vv-fb-tier-h">
<span class="vv-fb-tier-lbl">${label}</span>
<span class="vv-fb-tier-delay">${delayStr}</span>
</div>
<div class="vv-fb-tier-pills">${pills}</div>
</div>`;
}).join('');
}
function _nodeCard(node) {
const st = node.state || {};
const state = st.state || 'UNKNOWN';
const cov = node.covers;
const active = _activeTier(state === 'FALLBACK' ? st : null);
const covTarget = cov
? `<span class="vv-fb-arrow">→</span><span class="vv-fb-covers">covers ${cov.id} (${cov.hostname})</span>`
: '';
const tierSection = cov
? _tierSection(cov, active, cov.delays)
: '<div style="color:#3a3a3a;font-size:11px;">No coverage configured</div>';
return `<div class="vv-card vv-fb-node">
<div class="vv-fb-node-h">
${_stateDot(state)}
<span class="vv-fb-node-id">${node.id}</span>
<span class="vv-fb-node-nm">${node.hostname}</span>
${covTarget}
<span style="flex:1"></span>
${_stateBadge(state)}
</div>
<hr class="vv-fb-sep">
${tierSection}
</div>`;
}
function _render(data) {
if (!data.fb_enabled) {
document.getElementById('vv-fb-grid').innerHTML =
'<div class="vv-fb-disabled">FALLBACK_ENABLED=false — fallback monitoring is disabled</div>';
return;
}
const nodes = data.nodes || [];
let html = '';
// Top: active fallback card (if any)
html += _activeCard(nodes, data.handback_req || 3);
// Per-node cards
for (const node of nodes) {
html += _nodeCard(node);
}
if (!html) {
html = '<div class="vv-fb-disabled">No nodes configured.</div>';
}
document.getElementById('vv-fb-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-fb-ts').textContent = ts ? 'Updated: ' + ts : '';
}
function vvFbLoad() {
fetch('/plugins/varaverk/api/fallback.php')
.then(r => r.json())
.then(_render)
.catch(() => {});
}
vvFbLoad();
setInterval(vvFbLoad, 30000);
})();
</script>
File diff suppressed because it is too large Load Diff
+290
View File
@@ -0,0 +1,290 @@
<style>
.vv-pt-grid { display:grid; gap:12px; }
.vv-pt-node { background:#161616; border:1px solid #2a2a2a; border-radius:6px; padding:12px; min-width:0; }
.vv-pt-node.me { border-color:#2a3a2a; }
.vv-pt-node-head { display:flex; justify-content:space-between; align-items:flex-start; margin-bottom:10px; }
.vv-pt-hostname { font-size:13px; font-weight:bold; color:#ccc; }
.vv-pt-slot { font-size:10px; color:#444; margin-top:1px; }
.vv-pt-tags { display:flex; gap:4px; flex-wrap:wrap; justify-content:flex-end; }
.vv-pt-tag { font-size:8px; padding:1px 5px; border-radius:3px; white-space:nowrap; }
.vv-pt-tag.me { background:#1a3a1a; color:#4caf50; }
.vv-pt-tag.owner { background:#1a2a3a; color:#4a9eff; }
.vv-pt-tag.mirror { background:#2a2a1a; color:#ff9800; }
.vv-pt-row { display:flex; justify-content:space-between; align-items:baseline; margin:3px 0; }
.vv-pt-lbl { font-size:11px; color:#555; }
.vv-pt-val { font-size:11px; color:#bbb; text-align:right; }
.vv-pt-sep { border:none; border-top:1px solid #222; margin:8px 0; }
.vv-pt-state { font-size:12px; font-weight:500; }
.vv-pt-dot { width:8px; height:8px; border-radius:50%; display:inline-block; flex-shrink:0; margin-right:5px; }
.vv-pt-actions { display:flex; gap:10px; flex-wrap:wrap; margin-top:4px; }
.vv-pt-action-btn { padding:5px 16px; border:none; border-radius:4px; cursor:pointer;
font-size:12px; font-weight:500; }
.vv-pt-action-btn.run { background:#1a3a1a; color:#6fcf97; border:1px solid #2e6b2e; }
.vv-pt-action-btn.warn { background:#3a1a1a; color:#f88; border:1px solid #6b2e2e; }
.vv-pt-action-btn.info { background:#1a2a3a; color:#7ab; border:1px solid #2e4a6b; }
.vv-pt-action-btn:disabled { opacity:0.4; cursor:default; }
.vv-pt-transfer-note { font-size:10px; color:#555; margin-top:6px; font-family:monospace; }
</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;">Partnership</span>
<span style="font-size:11px;color:#3a3a3a;" id="vv-pt-ts"></span>
</div>
<!-- Config bar -->
<div class="vv-card" style="margin-bottom:12px;" id="vv-pt-config-card">
<div id="vv-pt-config-body" style="color:#444;font-size:12px;">Loading…</div>
</div>
<!-- Node grid -->
<div id="vv-pt-nodes" class="vv-pt-grid" style="margin-bottom:12px;">
<div style="color:#444;font-size:12px;padding:16px 0;text-align:center;">Loading…</div>
</div>
<!-- Actions -->
<div class="vv-card" id="vv-pt-actions-card">
<h3>Actions</h3>
<div id="vv-pt-actions-body" style="color:#444;font-size:12px;">Loading…</div>
</div>
<script>
(function() {
// ── Helpers ───────────────────────────────────────────────────────────────────
function _relTime(ts) {
if (!ts) return '—';
const d = Math.floor(Date.now() / 1000) - ts;
if (d < 60) return 'just now';
if (d < 3600) return Math.floor(d / 60) + 'm ago';
if (d < 86400) return Math.floor(d / 3600) + 'h ago';
if (d < 172800) return 'yesterday';
return Math.floor(d / 86400) + 'd ago';
}
function _uptime(sec) {
if (!sec) return '—';
const d = Math.floor(sec / 86400);
const h = Math.floor((sec % 86400) / 3600);
const m = Math.floor((sec % 3600) / 60);
return d > 0 ? `${d}d ${h}h ${m}m` : h > 0 ? `${h}h ${m}m` : `${m}m`;
}
function _row(lbl, val) {
return `<div class="vv-pt-row"><span class="vv-pt-lbl">${lbl}</span><span class="vv-pt-val">${val}</span></div>`;
}
// ── Config bar ────────────────────────────────────────────────────────────────
function _renderConfig(cfg) {
const dot = cfg.enabled ? '#4caf50' : '#555';
const label = cfg.enabled
? `<span style="color:#4caf50;">enabled</span>`
: `<span style="color:#555;">disabled</span>`;
const owner = cfg.owner_host || '—';
const items = [
label,
`Owner: <span style="color:#ccc;">${owner}</span>`,
`Sync: ${cfg.sync_min}min`,
`Grace: ${cfg.grace_hours}h`,
`Offline threshold: ${cfg.offline_threshold}d`,
cfg.remove_tailscale ? 'Tailscale removal: on' : 'Tailscale removal: off',
cfg.tailscale_configured ? '' : '<span style="color:#ff9800;">⚠ Tailscale API key not set</span>',
].filter(Boolean);
return `<div style="display:flex;align-items:center;gap:6px;flex-wrap:wrap;">
<span class="vv-pt-dot" style="background:${dot}"></span>
${items.map(i => `<span style="font-size:11px;color:#666;">${i}</span>`)
.join('<span style="color:#333;">·</span>')}
</div>`;
}
// ── Node card ─────────────────────────────────────────────────────────────────
const fbColors = {
NORMAL: '#4caf50',
FAILOVER: '#f44336',
NO_INTERNET: '#ff9800',
DARK: '#9e9e9e',
UNKNOWN: '#444',
};
const fbLabels = {
NORMAL: '✓ Nominal',
FAILOVER: '⚠ Failover',
NO_INTERNET: '⚡ No internet',
DARK: '◌ Dark mode',
UNKNOWN: '— Unknown',
};
const ptStates = {
ACTIVE: ['#4caf50', 'Active'],
INACTIVE: ['#555', 'Inactive'],
PENDING: ['#ff9800', 'Pending'],
};
function _nodeCard(node) {
const tsOnline = node.ts_online;
const dotCol = tsOnline === null ? '#555' : tsOnline ? '#4caf50' : '#f44336';
const dotTip = tsOnline === null ? 'unknown' : tsOnline ? (node.ts_active ? 'active' : 'idle') : 'offline';
const tags = [
node.is_me ? '<span class="vv-pt-tag me">US</span>' : '',
node.is_owner ? '<span class="vv-pt-tag owner">OWNER</span>' : '<span class="vv-pt-tag mirror">MIRROR</span>',
].join('');
// System info
const sys = node.system || {};
const ver = sys.unraid_version || '—';
const uptime = sys.uptime_sec ? _uptime(sys.uptime_sec) : '—';
// Fallback
const fb = (node.fallback || 'UNKNOWN').toUpperCase();
const fbCol = fbColors[fb] || '#444';
const fbLbl = fbLabels[fb] || fb;
// Partnership DB
const pt = node.partnership || {};
const ptState = (pt.state || '').toUpperCase();
const [ptCol, ptLbl] = ptStates[ptState] || ['#444', ptState || '—'];
const ptUpdated = pt.updated ? _relTime(parseInt(pt.updated)) : null;
let body = '';
// Network
body += `<div style="display:flex;align-items:center;gap:6px;margin-bottom:8px;">
<span class="vv-pt-dot" style="background:${dotCol}"></span>
<span style="font-size:11px;color:${dotCol};">${dotTip}</span>`;
if (node.ts_ip) body += `<span style="font-size:10px;color:#444;margin-left:4px;">${node.ts_ip}</span>`;
body += `</div>`;
// System
body += _row('unRAID', ver);
body += _row('Uptime', uptime);
body += `<hr class="vv-pt-sep">`;
// States
body += `<div class="vv-pt-row">
<span class="vv-pt-lbl">Fallback</span>
<span class="vv-pt-state" style="color:${fbCol};">${fbLbl}</span>
</div>`;
body += `<div class="vv-pt-row">
<span class="vv-pt-lbl">Partnership</span>
<span class="vv-pt-state" style="color:${ptCol};">${ptLbl}${ptUpdated ? `<span style="font-size:9px;color:#444;margin-left:4px;">${ptUpdated}</span>` : ''}</span>
</div>`;
if (pt.reason) {
body += `<div style="font-size:10px;color:#555;margin-top:3px;text-align:right;">${pt.reason}</div>`;
}
return `<div class="vv-pt-node${node.is_me ? ' me' : ''}">
<div class="vv-pt-node-head">
<div>
<div class="vv-pt-hostname">${node.hostname}</div>
<div class="vv-pt-slot">${node.id}</div>
</div>
<div class="vv-pt-tags">${tags}</div>
</div>
${body}
</div>`;
}
// ── Actions ───────────────────────────────────────────────────────────────────
function _renderActions(nodes, cfg) {
// Onboard: only show when partnership is disabled (not yet set up)
// Offboard: only when enabled
// Transfer: only when enabled + we are owner
const isOwner = nodes.some(n => n.is_me && n.is_owner);
const remotes = nodes.filter(n => !n.is_me);
const remoteOpts = remotes.map(n =>
`<option value="${n.slot}">${n.id} — ${n.hostname}</option>`).join('');
let html = '<div class="vv-pt-actions">';
// Onboard
html += `<button class="vv-pt-action-btn run" onclick="vvPtOnboard(this)"
title="Run partnership_onboard.sh — auto-detects role (run on mirror first, then owner)">
▶ Onboard
</button>`;
// Offboard
html += `<button class="vv-pt-action-btn warn" onclick="vvPtOffboard(this)"
${!cfg.enabled ? 'title="No active partnership to offboard" style="opacity:.35;cursor:default;"' : ''}>
▶ Offboard
</button>`;
html += '</div>';
// Transfer — show manual command, too destructive to one-click
if (cfg.enabled && isOwner) {
const confirmStr = 'i-understand-this-transfers-ownership';
html += `<div style="margin-top:14px;padding-top:10px;border-top:1px solid #1e1e1e;">
<span style="font-size:11px;color:#555;">Transfer ownership — run manually from the current owner:</span>
<div class="vv-pt-transfer-note" style="margin-top:4px;padding:6px 10px;background:#111;border-radius:4px;color:#666;">
bash Partnership/partnership_transfer.sh --confirm=${confirmStr}
</div>
</div>`;
}
return html;
}
// ── Main render ───────────────────────────────────────────────────────────────
function _render(data) {
const cfg = data.config || {};
const nodes = data.nodes || [];
// Config bar
document.getElementById('vv-pt-config-body').innerHTML = _renderConfig(cfg);
// Node grid — columns based on count
const cols = nodes.length <= 2 ? nodes.length : nodes.length <= 4 ? 2 : 3;
const grid = document.getElementById('vv-pt-nodes');
grid.style.gridTemplateColumns = `repeat(${cols}, 1fr)`;
grid.innerHTML = nodes.length
? nodes.map(_nodeCard).join('')
: '<div style="color:#444;font-size:12px;padding:12px 0;">No hosts found in master.conf.</div>';
// Actions
document.getElementById('vv-pt-actions-body').innerHTML = _renderActions(nodes, cfg);
// Timestamp
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-pt-ts').textContent = ts ? 'Updated: ' + ts : '';
}
// ── Actions ───────────────────────────────────────────────────────────────────
function vvPtOnboard(btn) {
if (!confirm('Run partnership_onboard.sh?\n\nRun on the MIRROR first, then on the OWNER.')) return;
btn.disabled = true;
btn.textContent = '⟳ Starting…';
vvRunById('Partnership/partnership_onboard.sh');
setTimeout(() => { btn.disabled = false; btn.textContent = '▶ Onboard'; }, 4000);
}
function vvPtOffboard(btn) {
if (btn.style.opacity === '0.35' || btn.style.cursor === 'default') return;
if (!confirm('Run partnership_offboard.sh?\n\nThis will end the partnership, reconfigure WebUIs, and revoke SSH access.\n\nContinue?')) return;
btn.disabled = true;
btn.textContent = '⟳ Starting…';
vvRunById('Partnership/partnership_offboard.sh');
setTimeout(() => { btn.disabled = false; btn.textContent = '▶ Offboard'; }, 4000);
}
// ── Poll ──────────────────────────────────────────────────────────────────────
function vvPtLoad() {
fetch('/plugins/varaverk/api/partnership.php')
.then(r => r.json())
.then(_render)
.catch(() => {});
}
vvPtLoad();
setInterval(vvPtLoad, 10000);
})();
</script>
File diff suppressed because it is too large Load Diff
+370
View File
@@ -0,0 +1,370 @@
<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; }
</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>
<script>
(function() {
const GB = 1073741824;
function _relTime(ts) {
if (!ts) return '—';
const d = Math.floor(Date.now() / 1000) - ts;
if (d < 60) return 'just now';
if (d < 3600) return Math.floor(d / 60) + 'm ago';
if (d < 86400)return Math.floor(d / 3600) + 'h ' + Math.floor((d % 3600) / 60) + 'm ago';
return Math.floor(d / 86400) + 'd ago';
}
function _fmtBytes(b) {
if (b >= GB) return (b / GB).toFixed(1) + ' GB';
if (b >= 1048576) return (b / 1048576).toFixed(0) + ' MB';
return (b / 1024).toFixed(0) + ' KB';
}
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>`;
}
function _pill(label, cls) {
return `<span class="vv-wd-pill ${cls}">${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;">${node.id} (${node.hostname})</span>
${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" style="grid-column:span 2;">
<div class="vv-wd-node-h">
<span class="vv-wd-dot" style="background:#444"></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" 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 daemonDot = sys.daemon_ok ? '#4caf50' : '#ef5350';
const st = node.states || {};
const level = st.rw_level || 0;
const dotCol = level >= 3 ? '#ef5350' : level >= 2 ? '#ffb74d' : level >= 1 ? '#cddc39' : '#4caf50';
return `<div class="vv-wd-card" style="grid-column:span 2;">
<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>
</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>
${_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>`;
}
// ── 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>`;
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">${name}</span><span class="vv-wd-strike-cnt">${cnt} / ${cfg.cpu_fail_lim}</span></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">${n}</span><span class="vv-wd-val" style="color:${c >= cfg.restart_limit ? '#ef5350' : '#ffb74d'}">${c}×</span></div>`
).join('');
}
return `<div class="vv-wd-card" style="grid-column:span 3;">
<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" 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>`;
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;">${k.replace(/_/g,' ')}</span><span class="vv-wd-strike-cnt">${v}</span></div>`
).join('');
}
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('');
return `<div class="vv-wd-card" style="grid-column:span 3;">
<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">${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">${node.id}</span>
<span style="font-size:11px;color:#3a3a3a;">${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>`;
}
// ── 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
for (const node of nodes) {
html += _systemCard(node, cfg);
html += _dockerCard(node, cfg);
html += _stabilityCard(node, cfg);
}
// Config inventory per node
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(() => {});
}
vvWdLoad();
setInterval(vvWdLoad, 30000);
})();
</script>