Add docker actions, arr profile enforcer, monitor caching, and web file symlink
Web files now served via symlink to the git repo so git pull changes survive reboots without rebuilding the txz. Also includes: docker pull/rebuild/restart with live log streaming, arr_profile_enforcer for Sonarr/Radarr quality profiles, monitor page cache fix (background writer now in cron), and ARR_KIDS/SONARR/RADARR profile name vars in master.conf.
This commit is contained in:
@@ -1,21 +1,88 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
|
||||
define('VV_JOB_DIR', '/tmp/varaverk_dk_jobs');
|
||||
|
||||
$action = trim($_POST['action'] ?? '');
|
||||
$name = trim($_POST['name'] ?? '');
|
||||
$jobId = trim($_POST['job_id'] ?? '');
|
||||
|
||||
if (!$name || !in_array($action, ['start', 'stop'], true)) {
|
||||
echo json_encode(['ok' => false, 'error' => 'Invalid request']);
|
||||
exit;
|
||||
// ── Job status (no name required) ─────────────────────────────────────────────
|
||||
if ($action === 'job_status') {
|
||||
if (!$jobId || !preg_match('/^[0-9a-f]+$/', $jobId)) {
|
||||
echo json_encode(['ok' => false, 'error' => 'invalid job_id']); exit;
|
||||
}
|
||||
$file = VV_JOB_DIR . '/' . $jobId . '.json';
|
||||
if (!file_exists($file)) {
|
||||
echo json_encode(['ok' => true, 'status' => 'pending']); exit;
|
||||
}
|
||||
echo file_get_contents($file); exit;
|
||||
}
|
||||
|
||||
// Confirm container exists
|
||||
$check = trim(shell_exec('docker ps -a --filter ' . escapeshellarg('name=^' . $name . '$') . " --format '{{.Names}}' 2>/dev/null") ?? '');
|
||||
// ── Logs ──────────────────────────────────────────────────────────────────────
|
||||
if ($action === 'logs') {
|
||||
if (!$name || !preg_match('/^[a-zA-Z0-9_.-]+$/', $name)) {
|
||||
echo json_encode(['ok' => false, 'error' => 'invalid name']); exit;
|
||||
}
|
||||
$out = shell_exec('docker logs --tail 200 --timestamps ' . escapeshellarg($name) . ' 2>&1');
|
||||
echo json_encode(['ok' => true, 'logs' => $out ?? '']); exit;
|
||||
}
|
||||
|
||||
// ── Container-scoped actions ──────────────────────────────────────────────────
|
||||
if (!$name || !preg_match('/^[a-zA-Z0-9_.-]+$/', $name)) {
|
||||
echo json_encode(['ok' => false, 'error' => 'invalid name']); exit;
|
||||
}
|
||||
|
||||
$check = trim(shell_exec(
|
||||
'docker ps -a --filter ' . escapeshellarg('name=^' . $name . '$') . " --format '{{.Names}}' 2>/dev/null"
|
||||
) ?? '');
|
||||
if ($check !== $name) {
|
||||
echo json_encode(['ok' => false, 'error' => 'Container not found']);
|
||||
exit;
|
||||
echo json_encode(['ok' => false, 'error' => 'Container not found']); exit;
|
||||
}
|
||||
|
||||
exec(($action === 'start' ? 'docker start' : 'docker stop') . ' ' . escapeshellarg($name) . ' 2>&1', $out, $rc);
|
||||
if ($action === 'start' || $action === 'stop') {
|
||||
exec(($action === 'start' ? 'docker start' : 'docker stop') . ' ' . escapeshellarg($name) . ' 2>&1', $out, $rc);
|
||||
echo json_encode(['ok' => $rc === 0, 'output' => implode("\n", $out)]); exit;
|
||||
}
|
||||
|
||||
echo json_encode(['ok' => $rc === 0, 'output' => implode("\n", $out)]);
|
||||
if ($action === 'restart') {
|
||||
$rebuild = '/usr/local/emhttp/plugins/dynamix.docker.manager/scripts/rebuild_container';
|
||||
if (is_executable($rebuild)) {
|
||||
exec($rebuild . ' ' . escapeshellarg($name) . ' 2>&1', $out, $rc);
|
||||
} else {
|
||||
exec('docker stop ' . escapeshellarg($name) . ' 2>&1', $o1, $rc1);
|
||||
exec('docker start ' . escapeshellarg($name) . ' 2>&1', $o2, $rc2);
|
||||
$out = array_merge($o1, $o2);
|
||||
$rc = ($rc1 === 0 && $rc2 === 0) ? 0 : 1;
|
||||
}
|
||||
echo json_encode(['ok' => $rc === 0, 'output' => implode("\n", $out)]); exit;
|
||||
}
|
||||
|
||||
if ($action === 'pull_rebuild') {
|
||||
@mkdir(VV_JOB_DIR, 0700, true);
|
||||
$jobId = bin2hex(random_bytes(8));
|
||||
$jobFile = VV_JOB_DIR . '/' . $jobId . '.json';
|
||||
$worker = __DIR__ . '/docker_pull_worker.php';
|
||||
$rebuild = '/usr/local/emhttp/plugins/dynamix.docker.manager/scripts/rebuild_container';
|
||||
|
||||
$oldId = trim(shell_exec('docker inspect --format={{.Image}} ' . escapeshellarg($name) . ' 2>/dev/null') ?: '');
|
||||
$image = trim(shell_exec('docker inspect --format={{.Config.Image}} ' . escapeshellarg($name) . ' 2>/dev/null') ?: '');
|
||||
|
||||
if (!$image) {
|
||||
echo json_encode(['ok' => false, 'error' => 'Could not determine image']); exit;
|
||||
}
|
||||
|
||||
file_put_contents($jobFile, json_encode(['ok' => true, 'status' => 'pulling', 'container' => $name]));
|
||||
|
||||
$cmd = 'php ' . escapeshellarg($worker) . ' ' .
|
||||
escapeshellarg($name) . ' ' .
|
||||
escapeshellarg($jobFile) . ' ' .
|
||||
escapeshellarg($oldId) . ' ' .
|
||||
escapeshellarg($image) . ' ' .
|
||||
escapeshellarg($rebuild) . ' >/dev/null 2>&1 &';
|
||||
exec($cmd);
|
||||
|
||||
echo json_encode(['ok' => true, 'status' => 'started', 'job_id' => $jobId]); exit;
|
||||
}
|
||||
|
||||
echo json_encode(['ok' => false, 'error' => 'Unknown action']);
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
// Background worker: docker pull → compare image ID → rebuild if updated.
|
||||
// Called via: php docker_pull_worker.php <name> <jobFile> <oldId> <image> <rebuild>
|
||||
[$name, $jobFile, $oldId, $image, $rebuild] = array_slice($argv, 1, 5);
|
||||
|
||||
if (!$name || !$jobFile || !$image) exit(1);
|
||||
|
||||
function jw(string $f, array $d): void { file_put_contents($f, json_encode($d)); }
|
||||
|
||||
shell_exec('docker pull ' . escapeshellarg($image) . ' 2>&1');
|
||||
|
||||
$rawInfo = shell_exec('docker image inspect ' . escapeshellarg($image) . ' 2>/dev/null') ?: '[]';
|
||||
$info = json_decode($rawInfo, true) ?: [];
|
||||
$newId = $info[0]['Id'] ?? '';
|
||||
|
||||
if ($oldId && $newId && $oldId === $newId) {
|
||||
jw($jobFile, ['ok' => true, 'status' => 'done', 'updated' => false, 'message' => 'Already up to date']);
|
||||
exit;
|
||||
}
|
||||
|
||||
jw($jobFile, ['ok' => true, 'status' => 'rebuilding']);
|
||||
|
||||
if ($rebuild && is_executable($rebuild)) {
|
||||
exec($rebuild . ' ' . escapeshellarg($name) . ' 2>&1', $out, $rc);
|
||||
} else {
|
||||
exec('docker stop ' . escapeshellarg($name) . ' 2>&1', $o1, $rc1);
|
||||
exec('docker start ' . escapeshellarg($name) . ' 2>&1', $o2, $rc2);
|
||||
$rc = ($rc1 === 0 && $rc2 === 0) ? 0 : 1;
|
||||
}
|
||||
|
||||
jw($jobFile, $rc === 0
|
||||
? ['ok' => true, 'status' => 'done', 'updated' => true, 'message' => 'Updated and rebuilt']
|
||||
: ['ok' => false, 'status' => 'done', 'error' => 'Rebuild failed after pull']
|
||||
);
|
||||
@@ -48,7 +48,8 @@ if ($action === 'rsync_log') {
|
||||
$base = vv_rsync_status();
|
||||
$vars = vv_conf_vars();
|
||||
|
||||
$base['windows']['fallback'] = ($vars['FALLBACK_RSYNC_ENABLED'] ?? 'true') !== 'false';
|
||||
$base['windows']['fallback'] = ($vars['FALLBACK_RSYNC_ENABLED'] ?? 'true') !== 'false';
|
||||
$base['windows']['monthly'] = ($vars['MONTHLY_RSYNC_ENABLED'] ?? 'true') !== 'false';
|
||||
|
||||
// Bandwidth history — last 30 days
|
||||
$bwLog = DATA_DIR . '/bandwidth_history.db';
|
||||
@@ -82,6 +83,7 @@ $winArrayDefs = [
|
||||
'intermediate' => ['INTERMEDIATE_MAINTENANCE_SCRIPTS', "{$myId}_INTERMEDIATE_SYNC_SHARES"],
|
||||
'daily' => ['DAILY_MAINTENANCE_SCRIPTS', "{$myId}_DAILY_SYNC_SHARES"],
|
||||
'weekly' => ['WEEKLY_MAINTENANCE_SCRIPTS', "{$myId}_WEEKLY_SYNC_SHARES"],
|
||||
'monthly' => ['MONTHLY_MAINTENANCE_SCRIPTS', "{$myId}_MONTHLY_SYNC_SHARES"],
|
||||
'fallback' => [null, null],
|
||||
];
|
||||
$winArrays = [];
|
||||
|
||||
@@ -19,15 +19,14 @@ function vv_system_info(): array {
|
||||
$os = $api['info']['os'] ?? [];
|
||||
$cpu = $api['info']['cpu'] ?? [];
|
||||
|
||||
// uptime is a String in this schema — try numeric (seconds) first, else display as-is
|
||||
// uptime is a String in this schema — try numeric (seconds) first, else fall back to /proc/uptime
|
||||
$uptimeRaw = $os['uptime'] ?? '';
|
||||
if (is_numeric($uptimeRaw)) {
|
||||
$uptimeSec = (int)$uptimeRaw;
|
||||
$uptime = vv_format_uptime($uptimeSec);
|
||||
} else {
|
||||
$uptimeSec = 0;
|
||||
$uptime = $uptimeRaw ?: '—';
|
||||
$uptimeSec = (int)explode(' ', @file_get_contents('/proc/uptime') ?: '0')[0];
|
||||
}
|
||||
$uptime = vv_format_uptime($uptimeSec);
|
||||
|
||||
$load = sys_getloadavg();
|
||||
return [
|
||||
|
||||
@@ -233,6 +233,11 @@ function vv_watchdog_summary(): array {
|
||||
|
||||
$zombies = (int)trim(shell_exec("ps -eo stat 2>/dev/null | grep -c '^Z'") ?: '0');
|
||||
|
||||
$fileNr = explode("\t", trim(@file_get_contents('/proc/sys/fs/file-nr') ?: '0 0 1'));
|
||||
$fdOpen = max(0, (int)($fileNr[0] ?? 0) - (int)($fileNr[1] ?? 0));
|
||||
$fdMax = max(1, (int)($fileNr[2] ?? 1));
|
||||
$fdPct = round($fdOpen / $fdMax * 100, 1);
|
||||
|
||||
$nic = trim(shell_exec("ip route show default 2>/dev/null | awk 'NR==1{print \$5}'") ?: 'eth0') ?: 'eth0';
|
||||
$nicState = trim(@file_get_contents("/sys/class/net/$nic/operstate") ?: 'unknown');
|
||||
$sshdOk = (int)trim(shell_exec('pgrep -c sshd 2>/dev/null') ?: '0') > 0;
|
||||
@@ -260,6 +265,9 @@ function vv_watchdog_summary(): array {
|
||||
'load_1min' => $load1,
|
||||
'cpu_temp' => $cpuTemp,
|
||||
'zombies' => $zombies,
|
||||
'fd_open' => $fdOpen,
|
||||
'fd_max' => $fdMax,
|
||||
'fd_pct' => $fdPct,
|
||||
'nic' => $nic,
|
||||
'nic_state' => $nicState,
|
||||
'sshd_ok' => $sshdOk,
|
||||
@@ -330,6 +338,7 @@ function vv_rsync_status(): array {
|
||||
'daily' => ($vars['DAILY_RSYNC_ENABLED'] ?? 'false') !== 'false',
|
||||
'intermediate' => ($vars['INTERMEDIATE_RSYNC_ENABLED'] ?? 'true') !== 'false',
|
||||
'weekly' => ($vars['WEEKLY_RSYNC_ENABLED'] ?? 'false') !== 'false',
|
||||
'monthly' => ($vars['MONTHLY_RSYNC_ENABLED'] ?? 'true') !== 'false',
|
||||
];
|
||||
|
||||
// Active rsync profiles — from lock files
|
||||
@@ -353,10 +362,11 @@ function vv_rsync_status(): array {
|
||||
'daily' => 'daily_sync_maintenance',
|
||||
'intermediate' => 'intermediate_sync_maintenance',
|
||||
'weekly' => 'weekly_sync_maintenance',
|
||||
'monthly' => 'monthly_maintenance',
|
||||
];
|
||||
$lastSync = [];
|
||||
foreach ($scriptMap as $key => $scriptName) {
|
||||
$logFile = LOG_DIR . "/$scriptName.json";
|
||||
$logFile = LOG_DIR . "/Orchestrators/$scriptName.json";
|
||||
if (!file_exists($logFile)) continue;
|
||||
$stat = json_decode(@file_get_contents($logFile) ?: '{}', true) ?: [];
|
||||
$lastSync[$key] = [
|
||||
@@ -376,7 +386,7 @@ function vv_rsync_status(): array {
|
||||
$p = explode('|', $line);
|
||||
if (count($p) < 4 || ($p[0] ?? '') < $cutoff7) continue;
|
||||
$name = $p[2] ?? '';
|
||||
if (!$name) continue;
|
||||
if (!$name || str_ends_with($name, '-fallback')) continue;
|
||||
if (!isset($profiles[$name])) $profiles[$name] = ['runs' => 0, 'dur' => 0, 'bytes' => 0];
|
||||
$profiles[$name]['runs']++;
|
||||
$profiles[$name]['dur'] += (int)($p[3] ?? 0);
|
||||
|
||||
@@ -121,6 +121,17 @@ function vv_cron_rebuild(array $schedule): bool {
|
||||
}
|
||||
$lines[] = "";
|
||||
|
||||
// Background writers — always injected, never user-configurable (excluded from scheduler UI).
|
||||
$toolsDir = SCRIPTS_DIR . '/Plugin/unraid/Tools';
|
||||
foreach ([
|
||||
['* * * * *', 'api_cache_writer.sh'],
|
||||
['0 */2 * * *', 'remote_arr_cache_writer.sh'],
|
||||
] as [$cron, $script]) {
|
||||
$path = "$toolsDir/$script";
|
||||
if (file_exists($path)) $lines[] = "$cron bash \"$runner\" \"Plugin/unraid/Tools/$script\" \"$path\"";
|
||||
}
|
||||
$lines[] = "";
|
||||
|
||||
// Write to the plugin cron file; update_cron merges all plugin *.cron files into /etc/cron.d/root.
|
||||
if (file_put_contents(CRON_FILE, implode("\n", $lines)) === false) return false;
|
||||
exec('/usr/local/sbin/update_cron');
|
||||
@@ -483,7 +494,7 @@ function vv_conf_script_map(): array {
|
||||
// Read a boolean flag value (e.g. INTERMEDIATE_RSYNC_ENABLED) from master.conf.
|
||||
function vv_conf_flag_value(string $name): bool {
|
||||
$conf = file_get_contents(CONF_DIR . '/master.conf') ?: '';
|
||||
if (preg_match('/^\s*' . preg_quote($name, '/') . '\s*=\s*(true|false)\s*$/m', $conf, $m)) {
|
||||
if (preg_match('/^\s*' . preg_quote($name, '/') . '\s*=\s*(true|false)\s*(?:#.*)?$/m', $conf, $m)) {
|
||||
return $m[1] === 'true';
|
||||
}
|
||||
return false;
|
||||
|
||||
@@ -71,6 +71,21 @@
|
||||
.vv-dk-pop-item.current { color:#4caf50; }
|
||||
.vv-dk-pop-item.sep { border-top:1px solid #222;margin-top:4px;padding-top:8px; }
|
||||
.vv-dk-pop-item.blue { color:#5c9fd4; }
|
||||
.vv-dk-pop-item.red { color:#ef5350; }
|
||||
|
||||
/* Action button + job badge */
|
||||
.vv-dk-ctr-act-btn { font-size:11px;padding:0 5px;border-radius:3px;border:1px solid #222;background:transparent;color:#333;cursor:pointer;line-height:1.7;margin-left:auto;flex-shrink:0; }
|
||||
.vv-dk-ctr-act-btn:hover { background:#1e1e1e;color:#777; }
|
||||
.vv-dk-job-badge { font-size:10px;padding:1px 6px;border-radius:2px;white-space:nowrap;flex-shrink:0; }
|
||||
.vv-dk-job-badge.pulling { background:#0d1a2a;color:#5c7cfa; }
|
||||
.vv-dk-job-badge.rebuilding { background:#1a1a0a;color:#cddc39; }
|
||||
.vv-dk-job-badge.restarting { background:#1a1a2a;color:#9c89d4; }
|
||||
.vv-dk-job-badge.done-ok { background:#0d1a0d;color:#4caf50; }
|
||||
.vv-dk-job-badge.done-err { background:#1a0d0d;color:#ef5350; }
|
||||
|
||||
/* Log panel */
|
||||
.vv-dk-log-panel { padding:8px 12px;background:#080808;border-top:1px solid #161616; }
|
||||
.vv-dk-log-pre { margin:0;font-size:10px;color:#4a4a4a;font-family:monospace;white-space:pre-wrap;word-break:break-all;max-height:260px;overflow-y:auto; }
|
||||
</style>
|
||||
|
||||
<div class="vv-dk-toolbar">
|
||||
@@ -125,6 +140,9 @@ function _ctrRow(c, folderId) {
|
||||
const statusLbl = c.running ? 'RUNNING' : (c.status || 'STOPPED').toUpperCase();
|
||||
const rowCls = 'vv-dk-ctr' + (c.running ? '' : ' stopped') + (_editMode ? ' edit-mode' : '');
|
||||
const editAttr = _editMode ? `data-ctr="${_esc(c.name)}" data-folder="${folderId||''}" title="Move ${c.name}"` : '';
|
||||
const ctrAttr = `data-ctr-name="${_esc(c.name)}"`;
|
||||
const actBtn = !_editMode ? `<button class="vv-dk-ctr-act-btn" data-act-ctr="${_esc(c.name)}" title="Actions">···</button>` : '';
|
||||
const jobBadge = !_editMode ? `<span class="vv-dk-job-badge" id="vv-dk-job-${_esc(c.name)}" style="display:none"></span>` : '';
|
||||
|
||||
// Icon or placeholder
|
||||
const iconHtml = c.icon
|
||||
@@ -161,17 +179,21 @@ function _ctrRow(c, folderId) {
|
||||
`</div>`;
|
||||
}
|
||||
|
||||
return `<div class="${rowCls}" ${editAttr}>
|
||||
return `<div class="${rowCls}" ${editAttr} ${ctrAttr}>
|
||||
${iconHtml}
|
||||
<div>
|
||||
<div class="vv-dk-ctr-name-row">
|
||||
<span class="vv-dk-ctr-name">${nameHtml}</span>
|
||||
<span class="vv-dk-ctr-status ${statusCls}">${statusLbl}</span>
|
||||
${jobBadge}${actBtn}
|
||||
</div>
|
||||
<div class="vv-dk-ctr-image">${_esc(_shortImage(c.image||''))}</div>
|
||||
${(netBadges || portBadges) ? `<div class="vv-dk-meta-row">${netBadges}${portBadges}${morePorts}</div>` : ''}
|
||||
${pathsHtml}
|
||||
</div>
|
||||
</div>
|
||||
<div class="vv-dk-log-panel" id="vv-dk-log-${_esc(c.name)}" style="display:none">
|
||||
<pre class="vv-dk-log-pre"></pre>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@@ -255,9 +277,143 @@ function _render(data) {
|
||||
_bindEvents();
|
||||
}
|
||||
|
||||
// ── Container actions ─────────────────────────────────────────────────────────
|
||||
|
||||
let _activeJobs = {}; // { ctrName: intervalId }
|
||||
|
||||
function _actApi(params, cb) {
|
||||
const fd = new FormData();
|
||||
for (const [k, v] of Object.entries(params)) fd.append(k, v);
|
||||
fetch('/plugins/varaverk/api/docker_action.php', {method: 'POST', body: fd})
|
||||
.then(r => r.json()).then(cb)
|
||||
.catch(() => cb({ok: false, error: 'Request failed'}));
|
||||
}
|
||||
|
||||
function _jobBadgeEl(name) {
|
||||
return document.getElementById('vv-dk-job-' + name);
|
||||
}
|
||||
|
||||
function _setBadge(name, cls, text, autohide) {
|
||||
const el = _jobBadgeEl(name);
|
||||
if (!el) return;
|
||||
el.className = 'vv-dk-job-badge ' + cls;
|
||||
el.textContent = text;
|
||||
el.style.display = '';
|
||||
if (autohide) setTimeout(() => { if (el.parentNode) el.style.display = 'none'; }, 4000);
|
||||
}
|
||||
|
||||
function _pollJob(name, jobId) {
|
||||
_actApi({action: 'job_status', job_id: jobId}, data => {
|
||||
const st = data.status;
|
||||
if (st === 'pulling') _setBadge(name, 'pulling', 'Pulling…', false);
|
||||
if (st === 'rebuilding') _setBadge(name, 'rebuilding', 'Rebuilding…', false);
|
||||
if (st === 'done') {
|
||||
clearInterval(_activeJobs[name]);
|
||||
delete _activeJobs[name];
|
||||
if (data.ok) {
|
||||
_setBadge(name, 'done-ok', data.message || 'Done', true);
|
||||
} else {
|
||||
_setBadge(name, 'done-err', data.error || 'Failed', true);
|
||||
}
|
||||
setTimeout(_reload, 1500);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function _startJob(name, jobId) {
|
||||
if (_activeJobs[name]) clearInterval(_activeJobs[name]);
|
||||
_setBadge(name, 'pulling', 'Pulling…', false);
|
||||
_activeJobs[name] = setInterval(() => _pollJob(name, jobId), 2000);
|
||||
}
|
||||
|
||||
function _doRestart(name) {
|
||||
_setBadge(name, 'restarting', 'Restarting…', false);
|
||||
_actApi({action: 'restart', name}, data => {
|
||||
if (data.ok) {
|
||||
_setBadge(name, 'done-ok', 'Restarted', true);
|
||||
setTimeout(_reload, 1000);
|
||||
} else {
|
||||
_setBadge(name, 'done-err', 'Failed', true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function _doStartStop(name, start) {
|
||||
_actApi({action: start ? 'start' : 'stop', name}, data => {
|
||||
if (data.ok) setTimeout(_reload, 800);
|
||||
else alert((start ? 'Start' : 'Stop') + ' failed: ' + (data.output || data.error || '?'));
|
||||
});
|
||||
}
|
||||
|
||||
function _doPullRebuild(name) {
|
||||
_actApi({action: 'pull_rebuild', name}, data => {
|
||||
if (data.ok && data.job_id) {
|
||||
_startJob(name, data.job_id);
|
||||
} else {
|
||||
alert('Update failed: ' + (data.error || '?'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function _toggleLog(name) {
|
||||
const panel = document.getElementById('vv-dk-log-' + name);
|
||||
if (!panel) return;
|
||||
if (panel.style.display !== 'none') {
|
||||
panel.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
const pre = panel.querySelector('.vv-dk-log-pre');
|
||||
pre.textContent = 'Loading…';
|
||||
panel.style.display = '';
|
||||
_actApi({action: 'logs', name}, data => {
|
||||
pre.textContent = data.ok ? (data.logs || '(no output)') : ('Error: ' + (data.error || '?'));
|
||||
pre.scrollTop = pre.scrollHeight;
|
||||
});
|
||||
}
|
||||
|
||||
function _showActionsMenu(name, running, x, y) {
|
||||
const pop = document.getElementById('vv-dk-popover');
|
||||
let html = '';
|
||||
if (running) {
|
||||
html += `<div class="vv-dk-pop-item red" data-act="stop" data-act-n="${_esc(name)}">Stop</div>`;
|
||||
html += `<div class="vv-dk-pop-item" data-act="restart" data-act-n="${_esc(name)}">Restart</div>`;
|
||||
} else {
|
||||
html += `<div class="vv-dk-pop-item current" data-act="start" data-act-n="${_esc(name)}">Start</div>`;
|
||||
}
|
||||
html += `<div class="vv-dk-pop-item sep blue" data-act="pull_rebuild" data-act-n="${_esc(name)}">Update (Pull & Rebuild)</div>`;
|
||||
html += `<div class="vv-dk-pop-item sep" data-act="logs" data-act-n="${_esc(name)}">View Logs</div>`;
|
||||
pop.innerHTML = html;
|
||||
pop.style.display = 'block';
|
||||
const vw = window.innerWidth, vh = window.innerHeight;
|
||||
pop.style.left = Math.min(x, vw - 200) + 'px';
|
||||
pop.style.top = Math.min(y + 8, vh - 160) + 'px';
|
||||
|
||||
pop.querySelectorAll('[data-act]').forEach(el => {
|
||||
el.addEventListener('click', () => {
|
||||
const act = el.dataset.act, n = el.dataset.actN;
|
||||
_hidePopover();
|
||||
if (act === 'start' || act === 'stop') _doStartStop(n, act === 'start');
|
||||
else if (act === 'restart') _doRestart(n);
|
||||
else if (act === 'pull_rebuild') _doPullRebuild(n);
|
||||
else if (act === 'logs') _toggleLog(n);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── Events ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function _bindEvents() {
|
||||
// Action menu button (non-edit mode)
|
||||
document.querySelectorAll('[data-act-ctr]').forEach(btn => {
|
||||
btn.addEventListener('click', e => {
|
||||
e.stopPropagation();
|
||||
const name = btn.dataset.actCtr;
|
||||
const allCtrs = [...(_data.folders||[]).flatMap(f=>f.containers), ...(_data.ungrouped||[])];
|
||||
const c = allCtrs.find(x => x.name === name);
|
||||
_showActionsMenu(name, c?.running ?? false, e.clientX, e.clientY);
|
||||
});
|
||||
});
|
||||
|
||||
// Container click in edit mode → folder picker
|
||||
document.querySelectorAll('.vv-dk-ctr.edit-mode').forEach(el => {
|
||||
el.addEventListener('click', e => {
|
||||
|
||||
@@ -545,6 +545,33 @@ function vvIoSum(devices) {
|
||||
devices.forEach(dev => { const io = vvDiskIo[dev]; if (io) { r += io.r ?? 0; w += io.w ?? 0; } });
|
||||
return [r, w];
|
||||
}
|
||||
function vvWdRsyncToggle(el) {
|
||||
const flag = el.dataset.flag;
|
||||
const on = el.dataset.enabled !== '1';
|
||||
el.dataset.enabled = on ? '1' : '0';
|
||||
el.style.color = on ? '#4caf50' : '#333';
|
||||
el.style.background = on ? '#0f1a0f' : '#111';
|
||||
el.style.borderColor = on ? '#1a3a1a' : '#222';
|
||||
const fd = new FormData();
|
||||
fd.append('name', flag);
|
||||
fd.append('enabled', on ? '1' : '0');
|
||||
fetch('/plugins/varaverk/api/flag_toggle.php', { method: 'POST', body: fd })
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
if (!d.ok) {
|
||||
el.dataset.enabled = on ? '0' : '1';
|
||||
el.style.color = on ? '#333' : '#4caf50';
|
||||
el.style.background = on ? '#111' : '#0f1a0f';
|
||||
el.style.borderColor = on ? '#222' : '#1a3a1a';
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
el.dataset.enabled = on ? '0' : '1';
|
||||
el.style.color = on ? '#333' : '#4caf50';
|
||||
el.style.background = on ? '#111' : '#0f1a0f';
|
||||
el.style.borderColor = on ? '#222' : '#1a3a1a';
|
||||
});
|
||||
}
|
||||
function vvIoTotalSum(devices) {
|
||||
let tr = 0, tw = 0;
|
||||
devices.forEach(dev => { const io = vvDiskIo[dev]; if (io) { tr += io.tr ?? 0; tw += io.tw ?? 0; } });
|
||||
@@ -1102,11 +1129,26 @@ function vvPollMonitor() {
|
||||
const ramFree = stab.ram_free_gb ?? 0;
|
||||
const ramColor = ramFree < 6 ? '#f44336' : ramFree < 12 ? '#ff9800' : '#4caf50';
|
||||
const load = stab.load_1min ?? 0;
|
||||
const loadColor = load > 6 ? '#f44336' : load > 3 ? '#ff9800' : '#4caf50';
|
||||
const _wdCores = sys.cpu_cores || 0;
|
||||
const loadColor = _wdCores > 0
|
||||
? (load > _wdCores * 2 ? '#f44336' : load > _wdCores ? '#ff9800' : '#4caf50')
|
||||
: (load > 6 ? '#f44336' : load > 3 ? '#ff9800' : '#4caf50');
|
||||
const nicOk = (stab.nic_state ?? '') === 'up';
|
||||
const sshdOk = stab.sshd_ok ?? true;
|
||||
const zombies = stab.zombies ?? 0;
|
||||
|
||||
const uptimeSec = sys.uptime_sec ?? 0;
|
||||
const uptimeDays = Math.floor(uptimeSec / 86400);
|
||||
const uptimeHrs = Math.floor((uptimeSec % 86400) / 3600);
|
||||
const uptimeStr = uptimeDays > 0 ? `${uptimeDays}d ${uptimeHrs}h` : `${uptimeHrs}h`;
|
||||
const uptimeColor = uptimeDays === 0 ? '#ff9800' : '#4caf50';
|
||||
const stabCount = stabNames.length;
|
||||
|
||||
const fdOpen = stab.fd_open ?? 0;
|
||||
const fdPct = stab.fd_pct ?? 0;
|
||||
const fdStr = fdOpen >= 1e6 ? (fdOpen/1e6).toFixed(1)+'M' : fdOpen >= 1000 ? (fdOpen/1000).toFixed(1)+'k' : String(fdOpen);
|
||||
const fdColor = fdPct >= 50 ? '#f44336' : fdPct >= 20 ? '#ff9800' : '#4caf50';
|
||||
|
||||
const cpuRow = stab.cpu_temp != null
|
||||
? `<span style="color:#444;">CPU</span><span style="color:${wdPct(stab.cpu_temp,75,90)};">${stab.cpu_temp}°C</span>` : '';
|
||||
let statsHtml = `<div style="display:grid;grid-template-columns:auto 1fr auto 1fr;gap:2px 8px;font-size:11px;margin-top:8px;margin-bottom:6px;">
|
||||
@@ -1117,9 +1159,13 @@ function vvPollMonitor() {
|
||||
<span style="color:#444;">Load</span><span style="color:${loadColor};">${load}</span>
|
||||
${cpuRow}
|
||||
<span style="color:#444;">Zombies</span><span style="color:${zombies>0?'#ff9800':'#4caf50'};">${zombies}</span>
|
||||
<span style="color:#444;">FD open</span><span style="color:${fdColor};">${fdStr}</span>
|
||||
<span style="color:#444;">${stab.nic??'nic'}</span><span style="color:${nicOk?'#4caf50':'#f44336'};">● ${stab.nic_state??'?'}</span>
|
||||
<span style="color:#444;">sshd</span><span style="color:${sshdOk?'#4caf50':'#f44336'};">${sshdOk?'● ok':'✗ down'}</span>
|
||||
<span style="color:#444;">NPM</span><span style="color:${npmStrikes>0?'#ff9800':'#4caf50'};">${npmStrikes>0?npmStrikes+'× strikes':'● ok'}</span>
|
||||
<span style="color:#444;">Uptime</span><span style="color:${uptimeColor};">${uptimeStr}</span>
|
||||
<span style="color:#444;">Reboots</span><span style="color:${reboots>0?'#f44336':'#4caf50'};">${reboots}/12h</span>
|
||||
<span style="color:#444;">Strikes</span><span style="color:${stabCount>0?'#ff9800':'#4caf50'};">${stabCount>0?stabCount+' active':'none'}</span>
|
||||
</div>`;
|
||||
html += statsHtml;
|
||||
|
||||
@@ -1317,13 +1363,24 @@ function vvPollMonitor() {
|
||||
let html = `<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;">
|
||||
<span style="font-size:10px;font-weight:600;color:${gCol};">● ${enabled ? 'ENABLED' : 'DISABLED'}</span>
|
||||
<div style="display:flex;gap:3px;">`;
|
||||
[['C','critical'],['D','daily'],['I','intermediate'],['W','weekly']].forEach(([s,k]) => {
|
||||
const on = windows[k] ?? false;
|
||||
const run = active.some(a => a.profile?.includes(k));
|
||||
const bg = run ? '#1a2a0a' : on ? '#0f1a0f' : '#111';
|
||||
const brd = run ? '#3a6a1a' : on ? '#1a3a1a' : '#222';
|
||||
const col = run ? '#8bc34a' : on ? '#4caf50' : '#333';
|
||||
html += `<span title="${k}" style="font-size:9px;padding:2px 5px;border-radius:2px;
|
||||
const _flagMap = {
|
||||
critical: 'CRITICAL_RSYNC_ENABLED',
|
||||
intermediate: 'INTERMEDIATE_RSYNC_ENABLED',
|
||||
daily: 'DAILY_RSYNC_ENABLED',
|
||||
weekly: 'WEEKLY_RSYNC_ENABLED',
|
||||
monthly: 'MONTHLY_RSYNC_ENABLED',
|
||||
};
|
||||
[['C','critical'],['I','intermediate'],['D','daily'],['W','weekly'],['M','monthly']].forEach(([s,k]) => {
|
||||
const on = windows[k] ?? false;
|
||||
const run = active.some(a => a.profile?.includes(k));
|
||||
const flag = _flagMap[k];
|
||||
const bg = run ? '#1a2a0a' : on ? '#0f1a0f' : '#111';
|
||||
const brd = run ? '#3a6a1a' : on ? '#1a3a1a' : '#222';
|
||||
const col = run ? '#8bc34a' : on ? '#4caf50' : '#333';
|
||||
const tog = flag ? `data-flag="${flag}" data-enabled="${on?'1':'0'}" onclick="vvWdRsyncToggle(this)"` : '';
|
||||
const cur = flag ? 'cursor:pointer;' : '';
|
||||
const tip = `Toggle ${k} rsync`;
|
||||
html += `<span title="${tip}" ${tog} style="${cur}font-size:9px;padding:2px 5px;border-radius:2px;
|
||||
background:${bg};border:1px solid ${brd};color:${col};font-weight:600;">${s}</span>`;
|
||||
});
|
||||
html += `</div></div>`;
|
||||
@@ -2180,27 +2237,31 @@ function vvRenderDockerFolders(data) {
|
||||
return out;
|
||||
}
|
||||
|
||||
// Build folder list — ungrouped gets a 📁 emoji icon
|
||||
const allFolders = (data.folders ?? []).map(f => ({ ...f, isEmoji: false }));
|
||||
const ug = data.ungrouped ?? [];
|
||||
if (ug.length) allFolders.push({ id:'__ungrouped__', name:'Ungrouped', icon:'📁', isEmoji:true, containers:ug });
|
||||
// Build item list — folders first, then ungrouped containers as individual rows
|
||||
const _folders = (data.folders ?? []).map(f => ({ ...f, _type: 'folder', isEmoji: false }));
|
||||
const _solo = (data.ungrouped ?? []).map(c => ({ ...c, _type: 'container' }));
|
||||
const _items = [..._folders, ..._solo];
|
||||
|
||||
if (!allFolders.length) {
|
||||
if (!_items.length) {
|
||||
html += '<div class="vv-df-empty">No containers found</div>';
|
||||
el.innerHTML = html;
|
||||
return;
|
||||
}
|
||||
|
||||
function renderItem(item) {
|
||||
return item._type === 'folder' ? renderFolder(item) : renderContainer(item);
|
||||
}
|
||||
|
||||
// Column count: 3 big / 2 intermediate / 1 small
|
||||
const _w = window.innerWidth;
|
||||
const _cols = _w > 1400 ? 3 : _w > 640 ? 2 : 1;
|
||||
|
||||
if (_cols === 1) {
|
||||
html += `<div class="vv-df-col">${allFolders.map(renderFolder).join('')}</div>`;
|
||||
html += `<div class="vv-df-col">${_items.map(renderItem).join('')}</div>`;
|
||||
} else {
|
||||
const perCol = Math.ceil(allFolders.length / _cols);
|
||||
const perCol = Math.ceil(_items.length / _cols);
|
||||
const colDivs = Array.from({length: _cols}, (_, i) =>
|
||||
`<div class="vv-df-col">${allFolders.slice(i * perCol, (i + 1) * perCol).map(renderFolder).join('')}</div>`
|
||||
`<div class="vv-df-col">${_items.slice(i * perCol, (i + 1) * perCol).map(renderItem).join('')}</div>`
|
||||
).join('');
|
||||
html += `<div class="vv-df-cols">${colDivs}</div>`;
|
||||
}
|
||||
|
||||
@@ -531,10 +531,11 @@ function _vvRyViewPanelInner(key, cfg) {
|
||||
(function() {
|
||||
|
||||
const WIN_META = {
|
||||
critical: { label: 'Critical', cadence: '30 min' },
|
||||
intermediate: { label: 'Intermediate', cadence: '4 hr' },
|
||||
daily: { label: 'Daily', cadence: 'nightly' },
|
||||
weekly: { label: 'Weekly', cadence: 'weekly' },
|
||||
critical: { label: 'Critical', cadence: '30 min' },
|
||||
intermediate: { label: 'Intermediate', cadence: '4 hr' },
|
||||
daily: { label: 'Daily', cadence: 'nightly' },
|
||||
weekly: { label: 'Weekly', cadence: 'weekly' },
|
||||
monthly: { label: 'Monthly', cadence: '30-day gate' },
|
||||
fallback: { label: 'Fallback', cadence: 'on handback' },
|
||||
};
|
||||
|
||||
@@ -829,7 +830,8 @@ function _settingsSection(data) {
|
||||
${_toggle('CRITICAL_RSYNC_ENABLED', w.critical, 'Critical')}
|
||||
${_toggle('INTERMEDIATE_RSYNC_ENABLED', w.intermediate, 'Intermediate')}
|
||||
${_toggle('DAILY_RSYNC_ENABLED', w.daily, 'Daily')}
|
||||
${_toggle('WEEKLY_RSYNC_ENABLED', w.weekly, 'Weekly')}
|
||||
${_toggle('WEEKLY_RSYNC_ENABLED', w.weekly, 'Weekly')}
|
||||
${_toggle('MONTHLY_RSYNC_ENABLED', w.monthly, 'Monthly')}
|
||||
${_toggle('FALLBACK_RSYNC_ENABLED', w.fallback, 'Fallback')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user