varaverk: monitor overhaul — CPU/memory/network/streams cards; scheduler polish
Monitor tab: - CPU card: per-core bars (freq-colored), overall usage bar, rolling history chart - Memory card: breakdown by system/VM/ZFS/Docker/free with progress bars - Network card: LAN + ext + Tailscale IP display, live RX/TX chart with auto-scale - Streams card: Emby/Jellyfin/Plex sessions with playback bar and server badges - GPU card: adds power draw, encode%, decode% meters - Transcode card: ramdisk vs SSD location pill, session count, flip history - Canvas chart helpers: vvDrawChart, vvDrawNetChart, vvMeter, vvFmtBps, vvFmtGib api/monitor.php: adds cpu, mem, net to response payload
This commit is contained in:
@@ -19,7 +19,7 @@ $logFile = vv_job_log_path($id);
|
||||
$logDir = dirname($logFile);
|
||||
if (!is_dir($logDir)) mkdir($logDir, 0755, true);
|
||||
|
||||
file_put_contents($logFile, date('[Y-m-d H:i:s]') . " [DRY RUN] started\n", FILE_APPEND);
|
||||
file_put_contents($logFile, "\n── " . date('Y-m-d H:i:s') . " [DRY RUN] ──────────────────────\n", FILE_APPEND);
|
||||
|
||||
$flags = vv_job_flags($id);
|
||||
exec('nohup env DRY_RUN=1 bash ' . escapeshellarg($script) . ($flags ? " $flags" : '') . ' >> ' . escapeshellarg($logFile) . ' 2>&1 </dev/null &');
|
||||
|
||||
@@ -5,6 +5,9 @@ require_once dirname(__DIR__) . '/include/monitor.php';
|
||||
echo json_encode([
|
||||
'fallback' => vv_fallback_state(),
|
||||
'resources' => vv_system_resources(),
|
||||
'cpu' => vv_cpu_per_core(),
|
||||
'mem' => vv_memory_breakdown(),
|
||||
'net' => vv_network_stats(),
|
||||
'gpu' => vv_gpu_stats(),
|
||||
'gpu_procs' => vv_gpu_processes(),
|
||||
'containers' => vv_docker_containers(),
|
||||
|
||||
@@ -19,8 +19,7 @@ $logFile = vv_job_log_path($id);
|
||||
$logDir = dirname($logFile);
|
||||
if (!is_dir($logDir)) mkdir($logDir, 0755, true);
|
||||
|
||||
// Stamp the log so the panel shows when the run started
|
||||
file_put_contents($logFile, date('[Y-m-d H:i:s]') . " Manual run started\n", FILE_APPEND);
|
||||
file_put_contents($logFile, "\n── " . date('Y-m-d H:i:s') . " ──────────────────────\n", FILE_APPEND);
|
||||
|
||||
$flags = vv_job_flags($id);
|
||||
exec('nohup bash ' . escapeshellarg($script) . ($flags ? " $flags" : '') . ' >> ' . escapeshellarg($logFile) . ' 2>&1 </dev/null &');
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
color: #888; letter-spacing: 0.05em; }
|
||||
|
||||
/* Fallback state badge */
|
||||
.vv-state-badge { font-size: 20px; font-weight: bold; padding: 4px 0; }
|
||||
.vv-state-badge { font-size: 20px; font-weight: bold; padding: 4px 0; margin-bottom: 2px; }
|
||||
.vv-state-normal { color: #4caf50; }
|
||||
.vv-state-failover { color: #f44336; }
|
||||
.vv-state-no_internet { color: #ff9800; }
|
||||
@@ -108,6 +108,50 @@
|
||||
.vv-dry-btn { border-color: #ff9800 !important; color: #ff9800 !important; }
|
||||
.vv-dry-btn:hover { background: #e65100 !important; color: #fff !important;
|
||||
border-color: #ff9800 !important; }
|
||||
.vv-log-btn { border-color: #555 !important; color: #666 !important; }
|
||||
.vv-log-btn:hover { background: #333 !important; color: #aaa !important; border-color: #777 !important; }
|
||||
.vv-log-btn.vv-has-log { border-color: #4caf50 !important; color: #4caf50 !important; }
|
||||
.vv-log-btn.vv-has-log:hover { background: #1b5e20 !important; color: #fff !important;
|
||||
border-color: #4caf50 !important; }
|
||||
.vv-edit-btn { border-color: #7b1fa2 !important; color: #ce93d8 !important; }
|
||||
.vv-edit-btn:hover { background: #4a148c !important; color: #fff !important; border-color: #7b1fa2 !important; }
|
||||
.vv-add-script-btn { background: #1565c0 !important; border: none !important; color: #fff !important; margin-left: 8px; }
|
||||
.vv-add-script-btn:hover { background: #0d47a1 !important; }
|
||||
.vv-save-script-btn-style { background: #1565c0 !important; border: none !important; color: #fff !important; }
|
||||
.vv-save-script-btn-style:hover { background: #0d47a1 !important; }
|
||||
.vv-delete-btn { background: #b71c1c !important; border: none !important; color: #fff !important; margin-left: 4px; }
|
||||
.vv-delete-btn:hover { background: #7f0000 !important; }
|
||||
.vv-conf-btn { border-color: #00838f !important; color: #4dd0e1 !important; }
|
||||
.vv-conf-btn:hover { background: #006064 !important; color: #fff !important; border-color: #00838f !important; }
|
||||
|
||||
/* Config form */
|
||||
#vv-confform { padding: 2px 4px; }
|
||||
.vv-cf-group { border-bottom: 1px solid #252525; padding-bottom: 14px; margin-bottom: 14px; }
|
||||
.vv-cf-group:last-child { border-bottom: none; margin-bottom: 0; }
|
||||
.vv-cf-group-header { display: flex; align-items: center; gap: 8px; font-size: 11px; font-weight: bold;
|
||||
color: #7a9eb5; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 10px; }
|
||||
.vv-cf-file { font-size: 10px; color: #555; background: #1a1a1a; border: 1px solid #2e2e2e;
|
||||
padding: 1px 6px; border-radius: 3px; font-weight: normal; text-transform: none;
|
||||
letter-spacing: 0; }
|
||||
.vv-cf-field { margin-bottom: 10px; }
|
||||
.vv-cf-key { font-family: monospace; font-size: 12px; color: #ccc; margin-bottom: 3px; }
|
||||
.vv-cf-desc { font-size: 11px; color: #666; margin-bottom: 4px; font-style: italic; line-height: 1.4; }
|
||||
.vv-cf-scalar { display: block; width: 100%; box-sizing: border-box; background: #111; border: 1px solid #333;
|
||||
color: #ddd; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; }
|
||||
.vv-cf-scalar:focus { border-color: #555; outline: none; }
|
||||
.vv-cf-array { display: block; width: 100%; box-sizing: border-box; background: #0d0d0d; border: 1px solid #333;
|
||||
color: #ccc; padding: 8px; border-radius: 4px; font-family: monospace; font-size: 11px;
|
||||
line-height: 1.6; resize: vertical; min-height: 60px; }
|
||||
.vv-cf-array:focus { border-color: #555; outline: none; }
|
||||
.vv-cf-empty { color: #555; font-size: 13px; font-style: italic; padding: 20px 4px; text-align: center; margin: 0; }
|
||||
.vv-custom-empty { color: #666; font-size: 13px; padding: 8px 4px; margin: 0; font-style: italic; }
|
||||
.vv-custom-count { font-size: 12px; color: #666; margin-left: 8px; flex-shrink: 0; }
|
||||
.vv-section-sep { font-size: 11px; font-weight: bold; color: #666; text-transform: uppercase;
|
||||
letter-spacing: 0.08em; padding: 10px 4px 4px; border-top: 1px solid #222; margin-top: 8px; }
|
||||
#vv-editor { flex-direction: column; gap: 0; }
|
||||
.vv-editor-body { font-family: monospace; font-size: 12px; background: #0d0d0d; color: #ccc;
|
||||
border: 1px solid #333; border-radius: 4px; padding: 10px 12px; resize: none;
|
||||
line-height: 1.5; scroll-behavior: auto; tab-size: 2; width: 100%; box-sizing: border-box; }
|
||||
|
||||
/* Suggestions panel — accordion */
|
||||
#vv-suggestions { overflow-y: auto; }
|
||||
@@ -144,7 +188,8 @@
|
||||
.vv-log-pre { background: #0d0d0d; border: 1px solid #333; border-radius: 4px;
|
||||
padding: 10px 12px; margin: 0; font-family: monospace; font-size: 12px;
|
||||
color: #ccc; white-space: pre-wrap; word-break: break-all;
|
||||
max-height: 340px; overflow-y: auto; line-height: 1.5; }
|
||||
max-height: 340px; overflow-y: auto; line-height: 1.5;
|
||||
scroll-behavior: auto; }
|
||||
|
||||
/* Toggle switch */
|
||||
.vv-toggle { position: relative; display: inline-block; width: 36px; height: 20px; flex-shrink: 0; }
|
||||
@@ -188,6 +233,28 @@
|
||||
.vv-doc-body pre { background: #111; padding: 12px; border-radius: 4px; overflow-x: auto; }
|
||||
.vv-doc-hint { font-size: 12px; color: #666; margin-top: 8px; }
|
||||
|
||||
/* Media stream sessions */
|
||||
.vv-stream-servers { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 10px; }
|
||||
.vv-server-badge { background: #333; border: 1px solid #555; color: #aaa;
|
||||
font-size: 10px; padding: 1px 7px; border-radius: 10px; }
|
||||
.vv-server-badge-sm { font-size: 9px; padding: 1px 6px; flex-shrink: 0; }
|
||||
.vv-stream-empty { color: #555; font-style: italic; font-size: 12px; margin: 4px 0; }
|
||||
.vv-stream-empty span { font-size: 11px; color: #444; }
|
||||
.vv-stream-row { margin-bottom: 10px; padding-bottom: 10px; border-bottom: 1px solid #282828; }
|
||||
.vv-stream-row:last-child { margin-bottom: 0; padding-bottom: 0; border-bottom: none; }
|
||||
.vv-stream-top { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; }
|
||||
.vv-stream-icon { font-size: 10px; color: #888; flex-shrink: 0; }
|
||||
.vv-stream-title { flex: 1; font-size: 12px; color: #ddd; font-weight: 500;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.vv-stream-meta { display: flex; gap: 10px; flex-wrap: wrap; font-size: 10px;
|
||||
color: #666; margin-bottom: 5px; }
|
||||
.vv-stream-user { color: #888; }
|
||||
.vv-stream-client { color: #555; }
|
||||
.vv-stream-method { font-weight: 500; }
|
||||
.vv-stream-time { color: #555; margin-left: auto; }
|
||||
.vv-stream-bar { height: 3px; background: #1a1a1a; border-radius: 2px; overflow: hidden; }
|
||||
.vv-stream-bar div { height: 100%; border-radius: 2px; transition: width 0.4s; }
|
||||
|
||||
/* Live var substitution colours */
|
||||
code.vv-live-var { color: #4caf50; background: #0d1f0d; }
|
||||
code.vv-unknown-var { color: #ff9800; background: #1f130d; }
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// schedule.json is per-host, never synced.
|
||||
|
||||
require_once __DIR__ . '/config.php';
|
||||
require_once __DIR__ . '/confform.php';
|
||||
|
||||
define('SCHEDULE_FILE', '/boot/config/plugins/varaverk/schedule.json');
|
||||
define('CRON_FILE', '/etc/cron.d/varaverk');
|
||||
@@ -59,7 +60,7 @@ function vv_cron_rebuild(array $schedule): bool {
|
||||
$logDir = dirname($logFile);
|
||||
if (!is_dir($logDir)) mkdir($logDir, 0755, true);
|
||||
$flags = !empty($entry['log_enabled']) ? ' --log' : '';
|
||||
$lines[] = "{$entry['cron']} bash \"$script\"$flags >> \"$logFile\" 2>&1";
|
||||
$lines[] = "{$entry['cron']} bash -c 'echo; echo \"── \$(date \"+%Y-%m-%d %H:%M:%S\") ──────────────────────\"; bash \"$script\"$flags' >> \"$logFile\" 2>&1";
|
||||
}
|
||||
$lines[] = "";
|
||||
|
||||
@@ -171,6 +172,25 @@ function vv_script_description(string $path): string {
|
||||
return $first;
|
||||
}
|
||||
|
||||
function vv_custom_scripts(): array {
|
||||
$dir = SCRIPTS_DIR . '/Custom';
|
||||
$schedule = vv_schedule_load();
|
||||
$scripts = [];
|
||||
foreach (glob("$dir/*.sh") ?: [] as $path) {
|
||||
$rel = 'Custom/' . basename($path);
|
||||
$entry = $schedule[$rel] ?? [];
|
||||
$scripts[] = [
|
||||
'id' => $rel,
|
||||
'label' => basename($path, '.sh'),
|
||||
'desc' => vv_script_description($path),
|
||||
'enabled' => (bool)($entry['enabled'] ?? false),
|
||||
'cron' => $entry['cron'] ?? '',
|
||||
'log_enabled' => (bool)($entry['log_enabled'] ?? false),
|
||||
];
|
||||
}
|
||||
return $scripts;
|
||||
}
|
||||
|
||||
// Walk the scripts repo and return the job tree:
|
||||
// orchestrators as top-level, individual scripts as children
|
||||
function vv_job_tree(): array {
|
||||
|
||||
@@ -6,27 +6,41 @@
|
||||
<div class="vv-card" id="vv-fallback">
|
||||
<h3>Fallback State</h3>
|
||||
<div class="vv-state-badge" id="vv-fallback-state">—</div>
|
||||
<div id="vv-fallback-desc"></div>
|
||||
</div>
|
||||
|
||||
<div class="vv-card" id="vv-resources">
|
||||
<h3>Resources</h3>
|
||||
<div id="vv-resource-body">Loading...</div>
|
||||
<div class="vv-card" id="vv-cpu" style="flex:2;min-width:240px;">
|
||||
<h3>CPU</h3>
|
||||
<div id="vv-cpu-body">Loading...</div>
|
||||
</div>
|
||||
|
||||
<div class="vv-card" id="vv-gpu-card">
|
||||
<div class="vv-card" id="vv-memory" style="flex:2;min-width:240px;">
|
||||
<h3>Memory</h3>
|
||||
<div id="vv-memory-body">Loading...</div>
|
||||
</div>
|
||||
|
||||
<div class="vv-card" id="vv-network" style="flex:2;min-width:240px;">
|
||||
<h3>Network</h3>
|
||||
<div id="vv-network-body">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="vv-row">
|
||||
<div class="vv-card" id="vv-gpu-card" style="flex:2;min-width:240px;">
|
||||
<h3>GPU</h3>
|
||||
<div id="vv-gpu-body">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="vv-row">
|
||||
<div class="vv-card vv-wide" id="vv-transcode">
|
||||
<div class="vv-card" id="vv-transcode">
|
||||
<h3>Transcode</h3>
|
||||
<div id="vv-transcode-body">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="vv-row">
|
||||
<div class="vv-card" id="vv-streams" style="flex:2;min-width:280px;">
|
||||
<h3>Streams</h3>
|
||||
<div id="vv-streams-body">Loading...</div>
|
||||
</div>
|
||||
|
||||
<div class="vv-card vv-wide" id="vv-docker">
|
||||
<h3>Containers</h3>
|
||||
<table id="vv-docker-table">
|
||||
@@ -39,36 +53,397 @@
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Poll monitor API every 5 seconds
|
||||
// ── Shared helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
function vvMeter(label, pct, text) {
|
||||
const color = pct >= 85 ? '#f44336' : pct >= 65 ? '#ff9800' : '#4caf50';
|
||||
return `<div style="margin-bottom:7px;">
|
||||
<div style="display:flex;justify-content:space-between;font-size:11px;color:#666;margin-bottom:3px;">
|
||||
<span>${label}</span><span style="color:#999;">${text}</span>
|
||||
</div>
|
||||
<div style="background:#1a1a1a;border-radius:3px;height:6px;overflow:hidden;">
|
||||
<div style="width:${pct}%;height:100%;background:${color};border-radius:3px;transition:width 0.4s;"></div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── Rolling history ───────────────────────────────────────────────────────────
|
||||
|
||||
const VV_HIST_MAX = 24; // 24 × 5 s = 120 s
|
||||
let vvCpuHistory = [];
|
||||
let vvNetRxHistory = [];
|
||||
let vvNetTxHistory = [];
|
||||
|
||||
// ── Canvas chart ──────────────────────────────────────────────────────────────
|
||||
|
||||
function vvDrawChart(canvas, data, lineColor, fillColor, grid = false) {
|
||||
if (!canvas) return;
|
||||
const W = canvas.offsetWidth || 200;
|
||||
const H = canvas.offsetHeight || 36;
|
||||
if (canvas.width !== W) canvas.width = W;
|
||||
if (canvas.height !== H) canvas.height = H;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.clearRect(0, 0, W, H);
|
||||
|
||||
// grid lines
|
||||
if (grid) {
|
||||
ctx.save();
|
||||
ctx.setLineDash([2, 3]);
|
||||
ctx.lineWidth = 0.5;
|
||||
[25, 50, 75].forEach(pct => {
|
||||
const y = H - (pct / 100) * (H - 2) - 1;
|
||||
ctx.strokeStyle = '#2e2e2e';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, y);
|
||||
ctx.lineTo(W, y);
|
||||
ctx.stroke();
|
||||
ctx.fillStyle = '#3a3a3a';
|
||||
ctx.font = '7px monospace';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText(pct + '%', 2, y - 2);
|
||||
});
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
if (data.length < 2) return;
|
||||
|
||||
const step = W / (VV_HIST_MAX - 1);
|
||||
const pts = data.map((v, i) => [i * step, H - (v / 100) * (H - 2) - 1]);
|
||||
|
||||
// fill
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, H);
|
||||
pts.forEach(([x, y]) => ctx.lineTo(x, y));
|
||||
ctx.lineTo((data.length - 1) * step, H);
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = fillColor;
|
||||
ctx.fill();
|
||||
|
||||
// line
|
||||
ctx.beginPath();
|
||||
pts.forEach(([x, y], i) => i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y));
|
||||
ctx.strokeStyle = lineColor;
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// ── Network helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
function vvFmtBps(bps) {
|
||||
if (bps >= 1e9) return (bps / 1e9).toFixed(2) + ' Gb/s';
|
||||
if (bps >= 1e6) return (bps / 1e6).toFixed(1) + ' Mb/s';
|
||||
if (bps >= 1e3) return (bps / 1e3).toFixed(0) + ' Kb/s';
|
||||
return bps + ' B/s';
|
||||
}
|
||||
|
||||
function vvDrawNetChart(canvas, rxData, txData, maxBps) {
|
||||
if (!canvas) return;
|
||||
const W = canvas.offsetWidth || 200;
|
||||
const H = canvas.offsetHeight || 52;
|
||||
if (canvas.width !== W) canvas.width = W;
|
||||
if (canvas.height !== H) canvas.height = H;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.clearRect(0, 0, W, H);
|
||||
|
||||
const scale = maxBps > 0 ? maxBps : 1;
|
||||
const toY = v => H - (v / scale) * (H - 2) - 1;
|
||||
const step = W / (VV_HIST_MAX - 1);
|
||||
|
||||
// grid — labels show actual bandwidth at each line
|
||||
ctx.save();
|
||||
ctx.setLineDash([2, 3]);
|
||||
ctx.lineWidth = 0.5;
|
||||
[0.25, 0.5, 0.75].forEach(frac => {
|
||||
const y = H - frac * (H - 2) - 1;
|
||||
ctx.strokeStyle = '#2e2e2e';
|
||||
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke();
|
||||
ctx.fillStyle = '#3a3a3a'; ctx.font = '7px monospace'; ctx.textAlign = 'left';
|
||||
ctx.fillText(vvFmtBps(maxBps * frac), 2, y - 2);
|
||||
});
|
||||
ctx.restore();
|
||||
|
||||
const drawLine = (data, lineColor, fillColor) => {
|
||||
if (data.length < 2) return;
|
||||
const pts = data.map((v, i) => [i * step, toY(v)]);
|
||||
ctx.beginPath(); ctx.moveTo(0, H);
|
||||
pts.forEach(([x, y]) => ctx.lineTo(x, y));
|
||||
ctx.lineTo((data.length - 1) * step, H);
|
||||
ctx.closePath(); ctx.fillStyle = fillColor; ctx.fill();
|
||||
ctx.beginPath();
|
||||
pts.forEach(([x, y], i) => i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y));
|
||||
ctx.strokeStyle = lineColor; ctx.lineWidth = 1.5; ctx.stroke();
|
||||
};
|
||||
|
||||
drawLine(txData, '#ff9800', 'rgba(255,152,0,0.10)');
|
||||
drawLine(rxData, '#4caf50', 'rgba(76,175,80,0.12)');
|
||||
}
|
||||
|
||||
// ── CPU helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
function vvCoreColor(freqMhz, maxMhz, minMhz) {
|
||||
if (!freqMhz || !maxMhz || maxMhz === minMhz) return '#4caf50';
|
||||
const t = Math.max(0, Math.min(1, (freqMhz - minMhz) / (maxMhz - minMhz)));
|
||||
// blue(220°) → green(120°) → red(0°) as t goes 0→1
|
||||
const hue = Math.round((1 - t) * 220);
|
||||
return `hsl(${hue},70%,45%)`;
|
||||
}
|
||||
|
||||
function vvRenderCpu(cpu) {
|
||||
const overall = cpu.overall ?? 0;
|
||||
const cores = cpu.cores ?? [];
|
||||
|
||||
// Overall usage bar
|
||||
let html = vvMeter('Overall', overall, overall + '%');
|
||||
|
||||
// Per-core vertical bars
|
||||
if (cores.length) {
|
||||
html += `<div style="display:flex;align-items:flex-end;gap:3px;height:54px;margin:10px 0 4px;">`;
|
||||
cores.forEach(c => {
|
||||
const color = vvCoreColor(c.freq_mhz, c.max_mhz, c.min_mhz);
|
||||
const usePct = c.usage_pct ?? 0;
|
||||
const barH = Math.max(2, Math.round(usePct * 0.46)); // max ~46px at 100%
|
||||
const label = c.freq_mhz ? (c.freq_mhz >= 1000 ? (c.freq_mhz/1000).toFixed(1)+'G' : c.freq_mhz+'M') : '';
|
||||
html += `<div style="flex:1;display:flex;flex-direction:column;align-items:center;gap:1px;min-width:10px;">
|
||||
<div style="font-size:8px;color:#555;line-height:1;">${label}</div>
|
||||
<div style="width:100%;height:46px;background:#1a1a1a;border-radius:2px;display:flex;align-items:flex-end;overflow:hidden;">
|
||||
<div style="width:100%;height:${barH}px;background:${color};border-radius:2px 2px 0 0;transition:height 0.4s;"></div>
|
||||
</div>
|
||||
<div style="font-size:8px;color:#555;line-height:1;">${c.core}</div>
|
||||
</div>`;
|
||||
});
|
||||
html += `</div>`;
|
||||
|
||||
// Freq legend
|
||||
html += `<div style="display:flex;justify-content:space-between;font-size:9px;color:#444;margin-bottom:8px;">
|
||||
<span style="color:hsl(220,70%,45%)">Low freq</span>
|
||||
<span style="color:hsl(120,70%,45%)">Mid</span>
|
||||
<span style="color:hsl(0,70%,45%)">High freq</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Canvas chart
|
||||
html += `<canvas id="vv-cpu-canvas" style="width:100%;height:36px;display:block;"></canvas>`;
|
||||
return html;
|
||||
}
|
||||
|
||||
// ── Memory helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
const VV_MEM_COLORS = {
|
||||
system: '#5c6bc0',
|
||||
vm: '#f57c00',
|
||||
zfs: '#0097a7',
|
||||
docker: '#388e3c',
|
||||
free: '#37474f',
|
||||
};
|
||||
|
||||
function vvFmtGib(kb) {
|
||||
return (kb / 1048576).toFixed(1) + ' GiB';
|
||||
}
|
||||
|
||||
function vvMemRow(label, kb, totalKb, color) {
|
||||
const pct = totalKb > 0 ? Math.round(kb / totalKb * 100) : 0;
|
||||
return `<div style="margin-bottom:5px;">
|
||||
<div style="display:flex;justify-content:space-between;font-size:11px;margin-bottom:2px;">
|
||||
<span style="color:${color};font-weight:500;">${label}</span>
|
||||
<span style="color:#777;">${vvFmtGib(kb)}</span>
|
||||
</div>
|
||||
<div style="background:#1a1a1a;border-radius:3px;height:5px;overflow:hidden;">
|
||||
<div style="width:${pct}%;height:100%;background:${color};border-radius:3px;transition:width 0.4s;"></div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function vvRenderMemory(mem) {
|
||||
const total = mem.total_kb ?? 1;
|
||||
const usedKb = total - (mem.free_kb ?? 0);
|
||||
const usedPct = Math.round(usedKb / total * 100);
|
||||
const totalColor = usedPct >= 85 ? '#f44336' : usedPct >= 65 ? '#ff9800' : '#ccc';
|
||||
let html = `<div style="font-size:14px;font-weight:600;color:${totalColor};margin-bottom:10px;">
|
||||
${vvFmtGib(usedKb)} <span style="font-size:11px;color:#555;font-weight:400;">/ ${vvFmtGib(total)}</span>
|
||||
</div>`;
|
||||
html += vvMemRow('System', mem.system_kb ?? 0, total, VV_MEM_COLORS.system)
|
||||
+ vvMemRow('VM', mem.vm_kb ?? 0, total, VV_MEM_COLORS.vm)
|
||||
+ vvMemRow('ZFS', mem.zfs_kb ?? 0, total, VV_MEM_COLORS.zfs)
|
||||
+ vvMemRow('Docker', mem.docker_kb ?? 0, total, VV_MEM_COLORS.docker)
|
||||
+ vvMemRow('Free', mem.free_kb ?? 0, total, VV_MEM_COLORS.free);
|
||||
return html;
|
||||
}
|
||||
|
||||
// ── Poll ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
function vvPollMonitor() {
|
||||
fetch('/plugins/varaverk/api/monitor.php')
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
// Fallback state
|
||||
const fb = d.fallback ?? {};
|
||||
|
||||
// ── Fallback state ──────────────────────────────────────────────────────
|
||||
const fb = d.fallback ?? {};
|
||||
const state = (fb.state ?? 'UNKNOWN').toUpperCase();
|
||||
const badge = document.getElementById('vv-fallback-state');
|
||||
badge.textContent = fb.state ?? '—';
|
||||
badge.className = 'vv-state-badge vv-state-' + (fb.state ?? 'unknown').toLowerCase();
|
||||
badge.textContent = state;
|
||||
badge.className = 'vv-state-badge vv-state-' + state.toLowerCase();
|
||||
|
||||
// Resources
|
||||
const res = d.resources ?? {};
|
||||
document.getElementById('vv-resource-body').innerHTML =
|
||||
`<p>RAM: ${res.ram_free_mb ?? '—'} MB free / ${res.ram_total_mb ?? '—'} MB</p>
|
||||
<p>CPU: ${res.cpu_percent ?? '—'}%</p>
|
||||
<p>Ramdisk: ${res.ramdisk?.used_mb ?? '—'} MB / ${res.ramdisk?.size_mb ?? '—'} MB</p>`;
|
||||
const descMap = {
|
||||
NORMAL: ['#4caf50', '✓', 'Nominal'],
|
||||
FAILOVER: ['#f44336', '⚠', 'Running peer containers'],
|
||||
NO_INTERNET: ['#ff9800', '⚡', 'Internet lost'],
|
||||
DARK: ['#9e9e9e', '◌', 'Isolation mode'],
|
||||
UNKNOWN: ['#555', '?', 'State unavailable'],
|
||||
};
|
||||
const [dColor, dIcon, dText] = descMap[state] ?? ['#555', '?', ''];
|
||||
let descHtml = `<div style="font-size:12px;color:${dColor};margin-top:6px;">${dIcon} ${dText}</div>`;
|
||||
|
||||
// GPU
|
||||
const gpu = d.gpu ?? {};
|
||||
if (gpu.available) {
|
||||
document.getElementById('vv-gpu-body').innerHTML =
|
||||
`<p>${gpu.name}</p>
|
||||
<p>VRAM: ${gpu.memory_used} / ${gpu.memory_total} MB</p>
|
||||
<p>Util: ${gpu.utilization}% Temp: ${gpu.temperature}°C</p>`;
|
||||
if (state === 'FAILOVER') {
|
||||
const start = parseInt(fb.failover_start ?? 0);
|
||||
if (start > 0) {
|
||||
const sec = Math.floor(Date.now() / 1000) - start;
|
||||
const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60);
|
||||
descHtml += `<div style="font-size:11px;color:#888;margin-top:3px;">Duration: ${h}h ${m}m</div>`;
|
||||
}
|
||||
const tiers = ['tier2_started','tier3_started','tier4_started']
|
||||
.filter(k => fb[k] === 'true')
|
||||
.map(k => k.replace('_started','').replace('tier','T'));
|
||||
if (tiers.length)
|
||||
descHtml += `<div style="font-size:11px;color:#f44336;margin-top:3px;">Active: ${tiers.join(' ')}</div>`;
|
||||
}
|
||||
document.getElementById('vv-fallback-desc').innerHTML = descHtml;
|
||||
|
||||
// ── CPU ─────────────────────────────────────────────────────────────────
|
||||
const cpu = d.cpu ?? {};
|
||||
document.getElementById('vv-cpu-body').innerHTML = vvRenderCpu(cpu);
|
||||
|
||||
vvCpuHistory.push(cpu.overall ?? 0);
|
||||
if (vvCpuHistory.length > VV_HIST_MAX) vvCpuHistory.shift();
|
||||
vvDrawChart(document.getElementById('vv-cpu-canvas'), vvCpuHistory, '#4caf50', 'rgba(76,175,80,0.18)', true);
|
||||
|
||||
// ── Memory ──────────────────────────────────────────────────────────────
|
||||
const mem = d.mem ?? {};
|
||||
document.getElementById('vv-memory-body').innerHTML = vvRenderMemory(mem);
|
||||
|
||||
// ── Network ─────────────────────────────────────────────────────────────
|
||||
const net = d.net ?? {};
|
||||
if (net.available) {
|
||||
const rx = net.rx_bps ?? 0;
|
||||
const tx = net.tx_bps ?? 0;
|
||||
const linkMbps = net.speed_mbps ?? 0;
|
||||
const linkLabel = linkMbps >= 1000 ? (linkMbps / 1000) + ' Gb/s' : linkMbps ? linkMbps + ' Mb/s' : '—';
|
||||
const linkBps = linkMbps * 1e6;
|
||||
|
||||
vvNetRxHistory.push(rx);
|
||||
vvNetTxHistory.push(tx);
|
||||
if (vvNetRxHistory.length > VV_HIST_MAX) vvNetRxHistory.shift();
|
||||
if (vvNetTxHistory.length > VV_HIST_MAX) vvNetTxHistory.shift();
|
||||
|
||||
const maxSeen = Math.max(...vvNetRxHistory, ...vvNetTxHistory, 1);
|
||||
const maxBps = maxSeen * 1.25; // auto-scale with 25% headroom
|
||||
|
||||
const ipRows = [
|
||||
net.local_ip ? `<div><span style="color:#555;font-size:9px;">LAN </span>${net.local_ip}</div>` : '',
|
||||
net.ext_ip ? `<div><span style="color:#555;font-size:9px;">EXT </span>${net.ext_ip}</div>` : '',
|
||||
net.ts_ip ? `<div><span style="color:#555;font-size:9px;">TS </span>${net.ts_ip}</div>` : '',
|
||||
].filter(Boolean).join('');
|
||||
|
||||
document.getElementById('vv-network-body').innerHTML =
|
||||
`<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:8px;">
|
||||
<div>
|
||||
<div style="font-size:12px;color:#888;margin-bottom:4px;">${net.iface} · ${linkLabel}</div>
|
||||
<div style="display:flex;gap:16px;font-size:13px;font-weight:600;">
|
||||
<span style="color:#4caf50;">↓ ${vvFmtBps(rx)}</span>
|
||||
<span style="color:#ff9800;">↑ ${vvFmtBps(tx)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="text-align:right;font-size:11px;color:#aaa;line-height:1.6;">${ipRows}</div>
|
||||
</div>
|
||||
<canvas id="vv-net-canvas" style="width:100%;height:52px;display:block;margin-bottom:4px;"></canvas>
|
||||
<div style="display:flex;gap:12px;font-size:9px;color:#555;">
|
||||
<span><span style="color:#4caf50;">━</span> IN (RX)</span>
|
||||
<span><span style="color:#ff9800;">━</span> OUT (TX)</span>
|
||||
</div>`;
|
||||
|
||||
vvDrawNetChart(document.getElementById('vv-net-canvas'), vvNetRxHistory, vvNetTxHistory, maxBps);
|
||||
} else {
|
||||
document.getElementById('vv-gpu-body').innerHTML = '<p>No GPU detected</p>';
|
||||
document.getElementById('vv-network-body').innerHTML = '<p style="color:#555;font-style:italic">No network interface detected</p>';
|
||||
}
|
||||
|
||||
// Docker
|
||||
// ── GPU ─────────────────────────────────────────────────────────────────
|
||||
const gpu = d.gpu ?? {};
|
||||
const gpuProcs = d.gpu_procs ?? [];
|
||||
if (gpu.available) {
|
||||
const vramPct = gpu.memory_total > 0 ? Math.round(gpu.memory_used / gpu.memory_total * 100) : 0;
|
||||
const utilPct = gpu.utilization ?? 0;
|
||||
const temp = gpu.temperature ?? 0;
|
||||
const tempColor = temp >= 85 ? '#f44336' : temp >= 70 ? '#ff9800' : '#4caf50';
|
||||
const powerStr = gpu.power_w != null ? gpu.power_w + ' W' : '—';
|
||||
const procCount = gpuProcs.length;
|
||||
const procColor = procCount > 0 ? '#4caf50' : '#555';
|
||||
|
||||
document.getElementById('vv-gpu-body').innerHTML =
|
||||
// header row: name + process count pill
|
||||
`<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;">
|
||||
<div style="font-size:11px;color:#888;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;">${gpu.name}</div>
|
||||
<div style="margin-left:8px;background:${procColor}22;border:1px solid ${procColor};color:${procColor};
|
||||
padding:1px 8px;border-radius:10px;font-size:11px;white-space:nowrap;">${procCount} proc${procCount !== 1 ? 's' : ''}</div>
|
||||
</div>` +
|
||||
vvMeter('VRAM', vramPct, `${gpu.memory_used} / ${gpu.memory_total} MB`) +
|
||||
vvMeter('GPU', utilPct, `${utilPct}%`) +
|
||||
vvMeter('Encode', gpu.enc_pct ?? 0, `${gpu.enc_pct ?? 0}%`) +
|
||||
vvMeter('Decode', gpu.dec_pct ?? 0, `${gpu.dec_pct ?? 0}%`) +
|
||||
`<div style="display:flex;justify-content:space-between;font-size:11px;margin-top:6px;">
|
||||
<span style="color:#666;">Temp</span>
|
||||
<span style="color:${tempColor};font-weight:bold;">${temp}°C</span>
|
||||
<span style="color:#666;margin-left:12px;">Power</span>
|
||||
<span style="color:#aaa;font-weight:bold;">${powerStr}</span>
|
||||
</div>`;
|
||||
} else {
|
||||
document.getElementById('vv-gpu-body').innerHTML = '<p style="color:#555;font-style:italic">No GPU detected</p>';
|
||||
}
|
||||
|
||||
// ── Transcode ───────────────────────────────────────────────────────────
|
||||
const tc = d.transcode ?? {};
|
||||
if (!tc.available) {
|
||||
document.getElementById('vv-transcode-body').innerHTML =
|
||||
'<p style="color:#555;font-style:italic">No transcode state — ramdisk_setup.sh not yet run.</p>';
|
||||
} else {
|
||||
const loc = tc.is_ramdisk ? 'Ramdisk' : 'SSD';
|
||||
const locColor = tc.is_ramdisk ? '#4caf50' : '#ff9800';
|
||||
const rd = tc.ramdisk ?? {};
|
||||
const usedMb = rd.used_mb ?? 0;
|
||||
const sizeMb = rd.size_mb ?? 0;
|
||||
const pct = sizeMb > 0 ? Math.round(usedMb / sizeMb * 100) : 0;
|
||||
const barColor = pct > 85 ? '#f44336' : pct > 65 ? '#ff9800' : '#4caf50';
|
||||
|
||||
const ago = tc.last_flip_ago;
|
||||
let flipStr = 'never';
|
||||
if (ago !== null && ago !== undefined) {
|
||||
if (ago < 60) flipStr = ago + 's ago';
|
||||
else if (ago < 3600) flipStr = Math.floor(ago / 60) + 'm ago';
|
||||
else flipStr = Math.floor(ago / 3600) + 'h ' + Math.floor((ago % 3600) / 60) + 'm ago';
|
||||
}
|
||||
|
||||
const totalSess = (tc.ram_sessions ?? 0) + (tc.ssd_sessions ?? 0);
|
||||
let sessStr = totalSess === 0 ? 'none' : totalSess + ' active';
|
||||
if (tc.ram_sessions > 0 && tc.ssd_sessions > 0)
|
||||
sessStr += ` (split: ${tc.ram_sessions} RAM / ${tc.ssd_sessions} SSD)`;
|
||||
|
||||
document.getElementById('vv-transcode-body').innerHTML = `
|
||||
<div style="display:flex;align-items:center;gap:10px;margin-bottom:8px;">
|
||||
<span style="background:${locColor}22;border:1px solid ${locColor};color:${locColor};
|
||||
padding:2px 10px;border-radius:12px;font-size:12px;font-weight:bold;">● ${loc}</span>
|
||||
<span style="color:#888;font-size:12px;">Sessions: ${sessStr}</span>
|
||||
<span style="color:#666;font-size:12px;margin-left:auto;">Flips this hour: ${tc.flip_count_hour ?? 0}</span>
|
||||
</div>
|
||||
<div style="margin-bottom:4px;">
|
||||
<div style="display:flex;justify-content:space-between;font-size:11px;color:#666;margin-bottom:3px;">
|
||||
<span>Ramdisk</span><span>${usedMb} MB / ${sizeMb} MB (${pct}%)</span>
|
||||
</div>
|
||||
<div style="background:#1a1a1a;border-radius:3px;height:6px;overflow:hidden;">
|
||||
<div style="width:${pct}%;height:100%;background:${barColor};border-radius:3px;transition:width 0.4s;"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="font-size:11px;color:#555;margin-top:6px;">Last flip: ${flipStr}</div>`;
|
||||
}
|
||||
|
||||
// ── Docker ──────────────────────────────────────────────────────────────
|
||||
const containers = d.containers ?? [];
|
||||
const tbody = document.getElementById('vv-docker-body');
|
||||
tbody.innerHTML = containers.length
|
||||
@@ -77,9 +452,78 @@ function vvPollMonitor() {
|
||||
).join('')
|
||||
: '<tr><td colspan="3">No running containers</td></tr>';
|
||||
})
|
||||
.catch(() => {}); // silent on poll failure
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
vvPollMonitor();
|
||||
setInterval(vvPollMonitor, 5000);
|
||||
|
||||
// ── Media streams (slower poll — media server API calls) ──────────────────────
|
||||
|
||||
function vvFmtSec(sec) {
|
||||
const h = Math.floor(sec / 3600);
|
||||
const m = Math.floor((sec % 3600) / 60);
|
||||
const s = sec % 60;
|
||||
return h > 0
|
||||
? `${h}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`
|
||||
: `${m}:${String(s).padStart(2,'0')}`;
|
||||
}
|
||||
|
||||
function vvPollStreams() {
|
||||
fetch('/plugins/varaverk/api/media.php')
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
const sessions = d.sessions ?? [];
|
||||
const names = d.server_names ?? [];
|
||||
const el = document.getElementById('vv-streams-body');
|
||||
|
||||
if (d.server_count === 0) {
|
||||
el.innerHTML = '<p class="vv-stream-empty">No media servers detected.<br>'
|
||||
+ '<span>Add EMBY_API_KEY / JELLYFIN_API_KEY / PLEX_TOKEN to master.conf to configure.</span></p>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Server badges in header
|
||||
const badges = names.map(n =>
|
||||
`<span class="vv-server-badge">${n}</span>`
|
||||
).join('');
|
||||
|
||||
if (sessions.length === 0) {
|
||||
el.innerHTML = `<div class="vv-stream-servers">${badges}</div>`
|
||||
+ '<p class="vv-stream-empty">Nothing playing</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = sessions.map(s => {
|
||||
const icon = s.paused ? '⏸' : '▶';
|
||||
const barColor = s.is_tc ? '#ff9800' : '#4caf50';
|
||||
const methColor = s.is_tc ? '#ff9800' : '#4caf50';
|
||||
const timeStr = s.dur_sec > 0
|
||||
? `${vvFmtSec(s.pos_sec)} / ${vvFmtSec(s.dur_sec)}`
|
||||
: '';
|
||||
return `<div class="vv-stream-row">
|
||||
<div class="vv-stream-top">
|
||||
<span class="vv-stream-icon">${icon}</span>
|
||||
<span class="vv-stream-title" title="${s.title}">${s.title}</span>
|
||||
<span class="vv-server-badge vv-server-badge-sm">${s.server}</span>
|
||||
</div>
|
||||
<div class="vv-stream-meta">
|
||||
<span class="vv-stream-user">${s.user}</span>
|
||||
<span class="vv-stream-client">${s.client}</span>
|
||||
<span class="vv-stream-method" style="color:${methColor};">${s.method}</span>
|
||||
${timeStr ? `<span class="vv-stream-time">${timeStr}</span>` : ''}
|
||||
</div>
|
||||
<div class="vv-stream-bar">
|
||||
<div style="width:${s.pct}%;background:${barColor};"></div>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
el.innerHTML = `<div class="vv-stream-servers">${badges}</div>${rows}`;
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
vvPollStreams();
|
||||
setInterval(vvPollStreams, 12000);
|
||||
</script>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<?php
|
||||
require_once dirname(__DIR__) . '/include/scheduler.php';
|
||||
$tree = vv_job_tree();
|
||||
$blocks = vv_parse_user_script_template();
|
||||
$tree = vv_job_tree();
|
||||
$customs = vv_custom_scripts();
|
||||
$blocks = vv_parse_user_script_template();
|
||||
|
||||
// Build a flat lookup: rel_path → [enabled, cron, in_tree]
|
||||
$treeMap = [];
|
||||
@@ -45,7 +46,11 @@ foreach ($tree as $orch) {
|
||||
<div class="vv-job-actions">
|
||||
<button class="vv-btn-sm vv-run-btn" onclick="vvRunJob(this)">▶ Run</button>
|
||||
<button class="vv-btn-sm vv-dry-btn" onclick="vvDryRun(this)">▶ Dry Run</button>
|
||||
<button class="vv-btn-sm" onclick="vvSelectLog(this)">Log</button>
|
||||
<?php if (vv_conf_has_sections($orch['id'])): ?>
|
||||
<button class="vv-btn-sm vv-conf-btn" onclick="vvEditConf('<?= $oid ?>')">Config</button>
|
||||
<?php endif; ?>
|
||||
<?php $orchLog = vv_job_log_path($orch['id']); ?>
|
||||
<button class="vv-btn-sm vv-log-btn<?= (file_exists($orchLog) && filesize($orchLog) > 0) ? ' vv-has-log' : '' ?>" onclick="vvSelectLog(this)">Log</button>
|
||||
<label class="vv-log-label" title="Enable verbose --log output">
|
||||
<input type="checkbox" class="vv-log-enabled"
|
||||
<?= $orch['log_enabled'] ? 'checked' : '' ?>
|
||||
@@ -80,7 +85,11 @@ foreach ($tree as $orch) {
|
||||
<div class="vv-job-actions">
|
||||
<button class="vv-btn-sm vv-run-btn" onclick="vvRunJob(this)">▶ Run</button>
|
||||
<button class="vv-btn-sm vv-dry-btn" onclick="vvDryRun(this)">▶ Dry Run</button>
|
||||
<button class="vv-btn-sm" onclick="vvSelectLog(this)">Log</button>
|
||||
<?php if (vv_conf_has_sections($child['id'])): ?>
|
||||
<button class="vv-btn-sm vv-conf-btn" onclick="vvEditConf('<?= $cid ?>')">Config</button>
|
||||
<?php endif; ?>
|
||||
<?php $childLog = vv_job_log_path($child['id']); ?>
|
||||
<button class="vv-btn-sm vv-log-btn<?= (file_exists($childLog) && filesize($childLog) > 0) ? ' vv-has-log' : '' ?>" onclick="vvSelectLog(this)">Log</button>
|
||||
<label class="vv-log-label" title="Enable verbose --log output">
|
||||
<input type="checkbox" class="vv-log-enabled"
|
||||
<?= $child['log_enabled'] ? 'checked' : '' ?>
|
||||
@@ -97,11 +106,61 @@ foreach ($tree as $orch) {
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<!-- Custom Scripts container -->
|
||||
<div class="vv-card vv-wide vv-sched-card vv-custom-card">
|
||||
<div class="vv-job-row">
|
||||
<span class="vv-job-label" style="font-weight:bold;">Custom Scripts</span>
|
||||
<span class="vv-custom-count"><?= count($customs) ?> script<?= count($customs) !== 1 ? 's' : '' ?></span>
|
||||
<button class="vv-advanced-toggle" style="margin-left:auto;width:100px" onclick="vvToggleCustom(this)">Scripts ▸</button>
|
||||
</div>
|
||||
<div class="vv-children" style="display:none;">
|
||||
<?php if (empty($customs)): ?>
|
||||
<p class="vv-custom-empty">No custom scripts yet — click <strong>+ Add Script</strong> to create one.</p>
|
||||
<?php else: ?>
|
||||
<?php foreach ($customs as $cs): $csid = htmlspecialchars($cs['id']); ?>
|
||||
<div class="vv-script" data-id="<?= $csid ?>">
|
||||
<div class="vv-job-row">
|
||||
<label class="vv-toggle" title="Enable/disable">
|
||||
<input type="checkbox" class="vv-enabled"
|
||||
<?= $cs['enabled'] ? 'checked' : '' ?>
|
||||
onchange="vvSaveJob(this)">
|
||||
<span class="vv-slider"></span>
|
||||
</label>
|
||||
<span class="vv-job-label"><?= htmlspecialchars($cs['label']) ?></span>
|
||||
<input type="text" class="vv-cron" value="<?= htmlspecialchars($cs['cron']) ?>"
|
||||
placeholder="cron expression">
|
||||
<span class="vv-save-check"></span>
|
||||
</div>
|
||||
<?php if (!empty($cs['desc'])): ?>
|
||||
<div class="vv-job-desc" title="<?= htmlspecialchars($cs['desc']) ?>"><?= htmlspecialchars($cs['desc']) ?></div>
|
||||
<?php endif; ?>
|
||||
<div class="vv-job-actions">
|
||||
<button class="vv-btn-sm vv-run-btn" onclick="vvRunJob(this)">▶ Run</button>
|
||||
<button class="vv-btn-sm vv-dry-btn" onclick="vvDryRun(this)">▶ Dry Run</button>
|
||||
<button class="vv-btn-sm vv-edit-btn" onclick="vvEditScript('<?= $csid ?>')">Edit</button>
|
||||
<?php $csLog = vv_job_log_path($cs['id']); ?>
|
||||
<button class="vv-btn-sm vv-log-btn<?= (file_exists($csLog) && filesize($csLog) > 0) ? ' vv-has-log' : '' ?>" onclick="vvSelectLog(this)">Log</button>
|
||||
<label class="vv-log-label" title="Enable verbose --log output">
|
||||
<input type="checkbox" class="vv-log-enabled"
|
||||
<?= $cs['log_enabled'] ? 'checked' : '' ?>
|
||||
onchange="vvSaveJob(this)">
|
||||
<span>Verbose</span>
|
||||
</label>
|
||||
<span class="vv-job-dot"></span>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /#vv-sched-cards -->
|
||||
|
||||
<!-- Single global save -->
|
||||
<div class="vv-sched-footer">
|
||||
<button class="vv-save-btn" onclick="vvSaveAll(this)">Save Schedule</button>
|
||||
<button id="vv-save-schedule-btn" class="vv-save-btn" onclick="vvSaveAll(this)">Save Schedule</button>
|
||||
<button class="vv-save-btn vv-add-script-btn" onclick="vvAddScript()">+ Add Script</button>
|
||||
<button id="vv-delete-script-btn" class="vv-save-btn vv-delete-btn" onclick="vvDeleteScript()" style="display:none">🗑 Delete</button>
|
||||
<span id="vv-save-all-status" class="vv-save-status"></span>
|
||||
</div>
|
||||
|
||||
@@ -112,17 +171,23 @@ foreach ($tree as $orch) {
|
||||
<div class="vv-card vv-log-card">
|
||||
<div class="vv-log-toolbar" style="margin-bottom:8px;">
|
||||
<div style="display:flex;align-items:center;gap:8px;">
|
||||
<button id="vv-back-btn" class="vv-btn-sm" onclick="vvBackToSuggestions()" style="display:none">← Back</button>
|
||||
<button id="vv-back-btn" class="vv-btn-sm" onclick="vvBackToSuggestions()" style="display:none">☰ Scheduler Info</button>
|
||||
<button id="vv-restore-btn" class="vv-btn-sm" onclick="vvRestoreLastLog()" style="display:none">← <span id="vv-restore-label"></span></button>
|
||||
<span id="vv-log-title" style="font-size:13px;font-weight:bold;color:#ccc;">Suggested Schedules</span>
|
||||
<span id="vv-log-dot" style="display:none;width:8px;height:8px;border-radius:50%;background:#4caf50;flex-shrink:0;"></span>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:12px;">
|
||||
<label id="vv-scroll-lock-label" class="vv-log-label" title="Lock auto-scroll" style="display:none">
|
||||
<input type="checkbox" id="vv-scroll-lock"> <span>Scroll Lock</span>
|
||||
<label id="vv-auto-scroll-label" class="vv-log-label" title="Auto-scroll to newest line" style="display:none">
|
||||
<input type="checkbox" id="vv-auto-scroll" checked> <span>Auto Scroll</span>
|
||||
</label>
|
||||
<label id="vv-scroll-lock-label" class="vv-log-label" title="Lock viewport — cannot scroll while active" style="display:none">
|
||||
<input type="checkbox" id="vv-scroll-lock" onchange="vvToggleScrollLock(this)"> <span>Scroll Lock</span>
|
||||
</label>
|
||||
<label id="vv-invert-log-label" class="vv-log-label" title="Show newest lines at top" style="display:none">
|
||||
<input type="checkbox" id="vv-invert-log" onchange="vvFetchRight()"> <span>Invert</span>
|
||||
</label>
|
||||
<button id="vv-save-script-btn" class="vv-btn-sm vv-save-script-btn-style" onclick="vvSaveScript()" style="display:none">Save Script</button>
|
||||
<button id="vv-save-conf-btn" class="vv-btn-sm vv-save-script-btn-style" onclick="vvSaveConf()" style="display:none">Save Config</button>
|
||||
<span id="vv-log-ts" class="vv-log-ts"></span>
|
||||
<button id="vv-clear-btn" class="vv-btn-sm" onclick="vvClearRightLog()" style="display:none">Clear</button>
|
||||
</div>
|
||||
@@ -195,6 +260,18 @@ foreach ($tree as $orch) {
|
||||
|
||||
<!-- Log view (shown when a script is selected) -->
|
||||
<pre id="vv-log-pre" class="vv-log-pre vv-log-right-pre" style="display:none"></pre>
|
||||
|
||||
<!-- Config form view (script conf sections) -->
|
||||
<div id="vv-confform" style="display:none; overflow-y:auto;"></div>
|
||||
|
||||
<!-- Editor view (custom scripts) -->
|
||||
<div id="vv-editor" style="display:none">
|
||||
<div style="display:flex;gap:8px;align-items:center;margin-bottom:6px;">
|
||||
<span style="color:#888;font-size:12px;flex-shrink:0;">Name:</span>
|
||||
<input type="text" id="vv-editor-name" class="vv-cron" style="flex:1" placeholder="script_name (no .sh)">
|
||||
</div>
|
||||
<textarea id="vv-editor-body" class="vv-editor-body" spellcheck="false"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<?php
|
||||
$totalJobs = 0; $scheduledJobs = 0;
|
||||
@@ -230,6 +307,8 @@ let vvRunningId = null;
|
||||
let vvLastTs = 0;
|
||||
let vvStaleCount = 0;
|
||||
let vvPollTimer = null;
|
||||
let vvEditorId = null;
|
||||
let vvConfId = null;
|
||||
|
||||
function vvPost(url, data) {
|
||||
const params = new URLSearchParams({csrf_token, ...data});
|
||||
@@ -258,26 +337,51 @@ function vvFitRight() {
|
||||
right.style.height = Math.round(leftRect.bottom - rightRect.top) + 'px';
|
||||
|
||||
const contentH = Math.max(80, lf.getBoundingClientRect().top - 32 - toolbar.getBoundingClientRect().bottom);
|
||||
// Apply to whichever content pane is visible
|
||||
if (pre.style.display !== 'none') {
|
||||
pre.style.maxHeight = 'none';
|
||||
pre.style.height = contentH + 'px';
|
||||
}
|
||||
if (sug.style.display !== 'none') {
|
||||
sug.style.height = contentH + 'px';
|
||||
sug.style.height = contentH + 'px';
|
||||
}
|
||||
const ed = document.getElementById('vv-editor');
|
||||
if (ed.style.display !== 'none') {
|
||||
document.getElementById('vv-editor-body').style.height = Math.max(60, contentH - 38) + 'px';
|
||||
}
|
||||
const cf = document.getElementById('vv-confform');
|
||||
if (cf.style.display !== 'none') {
|
||||
cf.style.height = contentH + 'px';
|
||||
}
|
||||
}
|
||||
window.addEventListener('resize', vvFitRight);
|
||||
|
||||
function vvShowLogMode(id) {
|
||||
document.getElementById('vv-suggestions').style.display = 'none';
|
||||
document.getElementById('vv-log-pre').style.display = '';
|
||||
document.getElementById('vv-back-btn').style.display = '';
|
||||
document.getElementById('vv-clear-btn').style.display = '';
|
||||
document.getElementById('vv-suggestions').style.display = 'none';
|
||||
document.getElementById('vv-editor').style.display = 'none';
|
||||
document.getElementById('vv-log-pre').style.display = '';
|
||||
document.getElementById('vv-back-btn').style.display = '';
|
||||
document.getElementById('vv-clear-btn').style.display = '';
|
||||
document.getElementById('vv-save-script-btn').style.display = 'none';
|
||||
document.getElementById('vv-save-conf-btn').style.display = 'none';
|
||||
document.getElementById('vv-delete-script-btn').style.display = 'none';
|
||||
document.getElementById('vv-confform').style.display = 'none';
|
||||
document.getElementById('vv-auto-scroll-label').style.display = '';
|
||||
document.getElementById('vv-scroll-lock-label').style.display = '';
|
||||
document.getElementById('vv-invert-log-label').style.display = '';
|
||||
document.getElementById('vv-save-schedule-btn').disabled = false;
|
||||
document.getElementById('vv-save-schedule-btn').style.opacity = '';
|
||||
const name = id.replace(/\.sh$/, '').split('/').pop();
|
||||
document.getElementById('vv-log-title').textContent = name;
|
||||
document.getElementById('vv-restore-btn').style.display = 'none';
|
||||
}
|
||||
|
||||
function vvRestoreLastLog() {
|
||||
const last = localStorage.getItem('vv-last-job');
|
||||
if (last && document.querySelector('[data-id="' + CSS.escape(last) + '"]')) vvOpenRight(last);
|
||||
}
|
||||
|
||||
function vvToggleScrollLock(cb) {
|
||||
document.getElementById('vv-log-pre').style.overflowY = cb.checked ? 'hidden' : '';
|
||||
}
|
||||
|
||||
function vvBackToSuggestions() {
|
||||
@@ -289,12 +393,30 @@ function vvBackToSuggestions() {
|
||||
if (vvPollTimer) { clearInterval(vvPollTimer); vvPollTimer = null; }
|
||||
vvSetRunning(null);
|
||||
|
||||
document.getElementById('vv-log-pre').style.display = 'none';
|
||||
document.getElementById('vv-suggestions').style.display = '';
|
||||
document.getElementById('vv-back-btn').style.display = 'none';
|
||||
document.getElementById('vv-clear-btn').style.display = 'none';
|
||||
document.getElementById('vv-log-pre').style.display = 'none';
|
||||
document.getElementById('vv-editor').style.display = 'none';
|
||||
document.getElementById('vv-suggestions').style.display = '';
|
||||
document.getElementById('vv-back-btn').style.display = 'none';
|
||||
document.getElementById('vv-clear-btn').style.display = 'none';
|
||||
document.getElementById('vv-save-script-btn').style.display = 'none';
|
||||
document.getElementById('vv-save-conf-btn').style.display = 'none';
|
||||
document.getElementById('vv-delete-script-btn').style.display = 'none';
|
||||
document.getElementById('vv-confform').style.display = 'none';
|
||||
vvConfId = null;
|
||||
document.getElementById('vv-save-schedule-btn').disabled = false;
|
||||
document.getElementById('vv-save-schedule-btn').style.opacity = '';
|
||||
const lastJob = localStorage.getItem('vv-last-job');
|
||||
if (lastJob) {
|
||||
const lname = lastJob.replace(/\.sh$/, '').split('/').pop();
|
||||
document.getElementById('vv-restore-label').textContent = lname;
|
||||
document.getElementById('vv-restore-btn').style.display = '';
|
||||
}
|
||||
document.getElementById('vv-auto-scroll-label').style.display = 'none';
|
||||
document.getElementById('vv-scroll-lock-label').style.display = 'none';
|
||||
document.getElementById('vv-invert-log-label').style.display = 'none';
|
||||
document.getElementById('vv-auto-scroll').checked = true;
|
||||
document.getElementById('vv-scroll-lock').checked = false;
|
||||
document.getElementById('vv-log-pre').style.overflowY = '';
|
||||
document.getElementById('vv-log-title').textContent = 'Suggested Schedules';
|
||||
document.getElementById('vv-log-ts').textContent = '';
|
||||
document.getElementById('vv-log-dot').style.display = 'none';
|
||||
@@ -307,6 +429,7 @@ function vvOpenRight(id) {
|
||||
if (old) old.querySelector('.vv-job-row').classList.remove('vv-row-selected');
|
||||
}
|
||||
vvActiveId = id;
|
||||
localStorage.setItem('vv-last-job', id);
|
||||
const job = document.querySelector('[data-id="' + CSS.escape(id) + '"]');
|
||||
if (job) job.querySelector('.vv-job-row').classList.add('vv-row-selected');
|
||||
|
||||
@@ -324,12 +447,21 @@ function vvFetchRight() {
|
||||
.then(d => {
|
||||
const pre = document.getElementById('vv-log-pre');
|
||||
const ts = document.getElementById('vv-log-ts');
|
||||
const scrollLock = document.getElementById('vv-scroll-lock')?.checked;
|
||||
const invert = document.getElementById('vv-invert-log')?.checked;
|
||||
const autoScroll = document.getElementById('vv-auto-scroll').checked;
|
||||
const scrollLock = document.getElementById('vv-scroll-lock').checked;
|
||||
const invert = document.getElementById('vv-invert-log').checked;
|
||||
const savedScroll = pre.scrollTop;
|
||||
if (!d.ok) { pre.textContent = '✗ ' + (d.error ?? 'Error'); return; }
|
||||
const content = d.content || '(no log yet)';
|
||||
pre.textContent = invert ? content.split('\n').reverse().join('\n') : content;
|
||||
if (!scrollLock) pre.scrollTop = invert ? 0 : pre.scrollHeight;
|
||||
const content = d.content || '';
|
||||
vvSetLogBtnState(vvActiveId, content.trim().length > 0);
|
||||
pre.textContent = content.trim() ? (invert ? content.split('\n').reverse().join('\n') : content) : '(no log yet)';
|
||||
if (scrollLock) {
|
||||
requestAnimationFrame(() => { pre.scrollTop = savedScroll; });
|
||||
} else if (autoScroll) {
|
||||
pre.scrollTop = invert ? 0 : pre.scrollHeight;
|
||||
} else {
|
||||
requestAnimationFrame(() => { pre.scrollTop = savedScroll; });
|
||||
}
|
||||
ts.textContent = d.ts ? 'Last run: ' + new Date(d.ts * 1000).toLocaleString() : '';
|
||||
|
||||
// Running indicator: clear when log stops changing
|
||||
@@ -450,13 +582,211 @@ function vvToggleAdvanced(btn) {
|
||||
btn.textContent = visible ? 'Advanced ▸' : 'Advanced ▾';
|
||||
}
|
||||
|
||||
function vvToggleCustom(btn) {
|
||||
const card = btn.closest('.vv-custom-card');
|
||||
const children = card.querySelector('.vv-children');
|
||||
const visible = children.style.display !== 'none';
|
||||
children.style.display = visible ? 'none' : 'block';
|
||||
btn.textContent = visible ? 'Scripts ▸' : 'Scripts ▾';
|
||||
}
|
||||
|
||||
function vvSetLogBtnState(id, hasLog) {
|
||||
const job = document.querySelector('[data-id="' + CSS.escape(id) + '"]');
|
||||
if (!job) return;
|
||||
job.querySelector('.vv-log-btn').classList.toggle('vv-has-log', hasLog);
|
||||
}
|
||||
|
||||
function vvClearRightLog() {
|
||||
if (!vvActiveId) return;
|
||||
vvPost('/plugins/varaverk/api/log.php', {id: vvActiveId, clear: '1'})
|
||||
.then(() => vvFetchRight())
|
||||
.then(() => { vvSetLogBtnState(vvActiveId, false); vvFetchRight(); })
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
function vvAddScript() {
|
||||
vvEditorId = null;
|
||||
const nameEl = document.getElementById('vv-editor-name');
|
||||
nameEl.value = '';
|
||||
nameEl.readOnly = false;
|
||||
document.getElementById('vv-editor-body').value = '#!/bin/bash\n\n';
|
||||
vvShowEditorMode('New Script');
|
||||
nameEl.focus();
|
||||
}
|
||||
|
||||
function vvEditScript(id) {
|
||||
if (vvActiveId) {
|
||||
const old = document.querySelector('[data-id="' + CSS.escape(vvActiveId) + '"]');
|
||||
if (old) old.querySelector('.vv-job-row').classList.remove('vv-row-selected');
|
||||
}
|
||||
vvActiveId = null;
|
||||
if (vvPollTimer) { clearInterval(vvPollTimer); vvPollTimer = null; }
|
||||
|
||||
vvEditorId = id;
|
||||
const name = id.replace(/^Custom\//, '').replace(/\.sh$/, '');
|
||||
const nameEl = document.getElementById('vv-editor-name');
|
||||
nameEl.value = name;
|
||||
nameEl.readOnly = true;
|
||||
document.getElementById('vv-editor-body').value = 'Loading…';
|
||||
|
||||
fetch('/plugins/varaverk/api/script.php?id=' + encodeURIComponent(id))
|
||||
.then(r => r.json())
|
||||
.then(d => { document.getElementById('vv-editor-body').value = d.ok ? d.content : '# Error loading script'; })
|
||||
.catch(() => { document.getElementById('vv-editor-body').value = '# Error loading script'; });
|
||||
|
||||
vvShowEditorMode(name);
|
||||
}
|
||||
|
||||
function vvShowEditorMode(title) {
|
||||
document.getElementById('vv-suggestions').style.display = 'none';
|
||||
document.getElementById('vv-log-pre').style.display = 'none';
|
||||
document.getElementById('vv-editor').style.display = 'flex';
|
||||
document.getElementById('vv-back-btn').style.display = '';
|
||||
document.getElementById('vv-restore-btn').style.display = 'none';
|
||||
document.getElementById('vv-save-script-btn').style.display = '';
|
||||
document.getElementById('vv-clear-btn').style.display = 'none';
|
||||
document.getElementById('vv-auto-scroll-label').style.display = 'none';
|
||||
document.getElementById('vv-scroll-lock-label').style.display = 'none';
|
||||
document.getElementById('vv-invert-log-label').style.display = 'none';
|
||||
document.getElementById('vv-log-title').textContent = title;
|
||||
document.getElementById('vv-log-ts').textContent = '';
|
||||
document.getElementById('vv-confform').style.display = 'none';
|
||||
document.getElementById('vv-save-conf-btn').style.display = 'none';
|
||||
document.getElementById('vv-save-schedule-btn').disabled = true;
|
||||
document.getElementById('vv-save-schedule-btn').style.opacity = '0.4';
|
||||
document.getElementById('vv-delete-script-btn').style.display = vvEditorId ? '' : 'none';
|
||||
if (vvPollTimer) { clearInterval(vvPollTimer); vvPollTimer = null; }
|
||||
requestAnimationFrame(vvFitRight);
|
||||
}
|
||||
|
||||
function vvSaveScript() {
|
||||
const name = document.getElementById('vv-editor-name').value.trim();
|
||||
const content = document.getElementById('vv-editor-body').value;
|
||||
if (!name || !/^[a-zA-Z0-9_\-]+$/.test(name)) {
|
||||
alert('Name must be letters, numbers, _ or - only (no spaces, no .sh)');
|
||||
return;
|
||||
}
|
||||
const btn = document.getElementById('vv-save-script-btn');
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Saving…';
|
||||
vvPost('/plugins/varaverk/api/script.php', {name, content})
|
||||
.then(d => {
|
||||
if (!d.ok) { alert('Save failed: ' + (d.error ?? 'Unknown error')); btn.disabled = false; btn.textContent = 'Save Script'; return; }
|
||||
localStorage.setItem('vv-last-job', d.id);
|
||||
window.location.reload();
|
||||
})
|
||||
.catch(() => { btn.disabled = false; btn.textContent = 'Save Script'; });
|
||||
}
|
||||
|
||||
function vvDeleteScript() {
|
||||
if (!vvEditorId) return;
|
||||
const name = vvEditorId.replace(/^Custom\//, '').replace(/\.sh$/, '');
|
||||
if (!confirm('Delete "' + name + '.sh"? This cannot be undone.')) return;
|
||||
const btn = document.getElementById('vv-delete-script-btn');
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Deleting…';
|
||||
vvPost('/plugins/varaverk/api/script.php', {action: 'delete', name})
|
||||
.then(d => {
|
||||
if (!d.ok) { alert('Delete failed: ' + (d.error ?? 'Unknown error')); btn.disabled = false; btn.textContent = '\u{1F5D1} Delete'; return; }
|
||||
localStorage.removeItem('vv-last-job');
|
||||
window.location.reload();
|
||||
})
|
||||
.catch(() => { btn.disabled = false; btn.textContent = '\u{1F5D1} Delete'; });
|
||||
}
|
||||
|
||||
function vvShowConfMode(title) {
|
||||
document.getElementById('vv-suggestions').style.display = 'none';
|
||||
document.getElementById('vv-log-pre').style.display = 'none';
|
||||
document.getElementById('vv-editor').style.display = 'none';
|
||||
document.getElementById('vv-confform').style.display = '';
|
||||
document.getElementById('vv-back-btn').style.display = '';
|
||||
document.getElementById('vv-restore-btn').style.display = 'none';
|
||||
document.getElementById('vv-save-script-btn').style.display = 'none';
|
||||
document.getElementById('vv-save-conf-btn').style.display = '';
|
||||
document.getElementById('vv-delete-script-btn').style.display = 'none';
|
||||
document.getElementById('vv-clear-btn').style.display = 'none';
|
||||
document.getElementById('vv-auto-scroll-label').style.display = 'none';
|
||||
document.getElementById('vv-scroll-lock-label').style.display = 'none';
|
||||
document.getElementById('vv-invert-log-label').style.display = 'none';
|
||||
document.getElementById('vv-log-title').textContent = title;
|
||||
document.getElementById('vv-log-ts').textContent = '';
|
||||
document.getElementById('vv-save-schedule-btn').disabled = true;
|
||||
document.getElementById('vv-save-schedule-btn').style.opacity = '0.4';
|
||||
if (vvPollTimer) { clearInterval(vvPollTimer); vvPollTimer = null; }
|
||||
requestAnimationFrame(vvFitRight);
|
||||
}
|
||||
|
||||
function vvEditConf(id) {
|
||||
if (vvActiveId) {
|
||||
const old = document.querySelector('[data-id="' + CSS.escape(vvActiveId) + '"]');
|
||||
if (old) old.querySelector('.vv-job-row').classList.remove('vv-row-selected');
|
||||
}
|
||||
vvActiveId = null;
|
||||
if (vvPollTimer) { clearInterval(vvPollTimer); vvPollTimer = null; }
|
||||
vvConfId = id;
|
||||
const name = id.replace(/\.sh$/, '').split('/').pop();
|
||||
const cf = document.getElementById('vv-confform');
|
||||
cf.innerHTML = '<p class="vv-cf-empty">Loading…</p>';
|
||||
vvShowConfMode(name + ' — Config');
|
||||
fetch('/plugins/varaverk/api/confform.php?id=' + encodeURIComponent(id))
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
cf.innerHTML = (!d.ok || !d.groups || d.groups.length === 0)
|
||||
? '<p class="vv-cf-empty">No configurable settings found for this host.</p>'
|
||||
: vvRenderConfForm(d.groups);
|
||||
requestAnimationFrame(vvFitRight);
|
||||
})
|
||||
.catch(() => { cf.innerHTML = '<p class="vv-cf-empty">Failed to load configuration.</p>'; });
|
||||
}
|
||||
|
||||
function vvRenderConfForm(groups) {
|
||||
const esc = s => String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
let html = '';
|
||||
for (const g of groups) {
|
||||
html += '<div class="vv-cf-group">';
|
||||
html += '<div class="vv-cf-group-header">' + esc(g.subsection)
|
||||
+ ' <span class="vv-cf-file">' + esc(g.file) + '</span></div>';
|
||||
for (const f of g.fields) {
|
||||
html += '<div class="vv-cf-field">';
|
||||
html += '<div class="vv-cf-key">' + esc(f.key) + '</div>';
|
||||
if (f.desc) html += '<div class="vv-cf-desc">' + esc(f.desc) + '</div>';
|
||||
if (f.type === 'scalar') {
|
||||
html += '<input class="vv-cf-input vv-cf-scalar" type="text"'
|
||||
+ ' data-key="' + esc(f.key) + '" data-file="' + esc(f.file) + '" data-type="scalar"'
|
||||
+ ' value="' + esc(f.value) + '">';
|
||||
} else {
|
||||
const rows = Math.min(20, (f.value.match(/\n/g) || []).length + 3);
|
||||
html += '<textarea class="vv-cf-input vv-cf-array"'
|
||||
+ ' data-key="' + esc(f.key) + '" data-file="' + esc(f.file) + '" data-type="' + esc(f.type) + '"'
|
||||
+ ' rows="' + rows + '">' + esc(f.value) + '</textarea>';
|
||||
}
|
||||
html += '</div>';
|
||||
}
|
||||
html += '</div>';
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
function vvSaveConf() {
|
||||
if (!vvConfId) return;
|
||||
const inputs = document.querySelectorAll('#vv-confform .vv-cf-input');
|
||||
const changes = [];
|
||||
inputs.forEach(el => changes.push({key: el.dataset.key, file: el.dataset.file, type: el.dataset.type, value: el.value}));
|
||||
const btn = document.getElementById('vv-save-conf-btn');
|
||||
btn.disabled = true; btn.textContent = 'Saving…';
|
||||
vvPost('/plugins/varaverk/api/confform.php', {id: vvConfId, changes: JSON.stringify(changes)})
|
||||
.then(d => {
|
||||
btn.disabled = false;
|
||||
if (d.ok) {
|
||||
btn.textContent = '✓ Saved';
|
||||
setTimeout(() => { btn.textContent = 'Save Config'; }, 2500);
|
||||
} else {
|
||||
btn.textContent = 'Save Config';
|
||||
alert('Save failed: ' + (d.error ?? 'Unknown error'));
|
||||
}
|
||||
})
|
||||
.catch(() => { btn.disabled = false; btn.textContent = 'Save Config'; });
|
||||
}
|
||||
|
||||
function vvToggleSugBlock(bi) {
|
||||
const body = document.getElementById('vv-sug-body-' + bi);
|
||||
const chevron = body.closest('.vv-sug-block').querySelector('.vv-sug-chevron');
|
||||
@@ -466,4 +796,18 @@ function vvToggleSugBlock(bi) {
|
||||
}
|
||||
</script>
|
||||
|
||||
<script>requestAnimationFrame(vvFitRight);</script>
|
||||
<script>
|
||||
requestAnimationFrame(function() {
|
||||
const last = localStorage.getItem('vv-last-job');
|
||||
if (last && document.querySelector('[data-id="' + CSS.escape(last) + '"]')) {
|
||||
vvOpenRight(last);
|
||||
} else {
|
||||
if (last) {
|
||||
const lname = last.replace(/\.sh$/, '').split('/').pop();
|
||||
document.getElementById('vv-restore-label').textContent = lname;
|
||||
document.getElementById('vv-restore-btn').style.display = '';
|
||||
}
|
||||
vvFitRight();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user