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:
@@ -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}↓ ${qWrn}⚠ ${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) + ' ' + 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 ? ` <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) + ' ' + 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) + ' ' + 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
Reference in New Issue
Block a user