Scheduler: card layout, per-job logs, live log panel

- Per-job log files replace single jobs.log: each script writes to
  /var/log/varaverk/<Category>/<script>.log mirroring the job ID
- api/log.php: serves last 200 lines of a job's log file with mtime
  timestamp; supports POST ?clear=1 to truncate
- Scheduler page redesigned as full-width cards (one per orchestrator)
  with Save + Log buttons; Log toggles an inline panel that polls
  every 3 seconds and auto-scrolls — same pattern as unRAID User Scripts
- Children shown under Advanced with identical per-job log panels
- CSS: vv-sched-card, vv-log-panel, vv-log-pre, vv-btn-sm
This commit is contained in:
Gmer4Lfe
2026-05-23 17:17:09 -04:00
parent 1b365d4fb7
commit 6050816fe4
4 changed files with 146 additions and 27 deletions
@@ -0,0 +1,30 @@
<?php
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/scheduler.php';
$id = trim($_GET['id'] ?? '');
if (!$id || !preg_match('/^[a-zA-Z0-9_.\/\-]+\.sh$/', $id) || str_contains($id, '..')) {
echo json_encode(['ok' => false, 'error' => 'Invalid job id']);
exit;
}
$logFile = vv_job_log_path($id);
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_GET['clear'])) {
if (file_exists($logFile)) file_put_contents($logFile, '');
echo json_encode(['ok' => true]);
exit;
}
if (!file_exists($logFile)) {
echo json_encode(['ok' => true, 'content' => '', 'ts' => 0]);
exit;
}
$lines = array_slice(file($logFile) ?: [], -200);
echo json_encode([
'ok' => true,
'content' => implode('', $lines),
'ts' => filemtime($logFile),
]);
@@ -33,21 +33,33 @@
.vv-status-down { color: #f44336; } .vv-status-down { color: #f44336; }
/* Scheduler */ /* Scheduler */
#vv-scheduler { max-width: 900px; }
.vv-hint { color: #888; font-size: 13px; margin-bottom: 16px; } .vv-hint { color: #888; font-size: 13px; margin-bottom: 16px; }
.vv-job { margin-bottom: 4px; } .vv-sched-card { margin-bottom: 10px; }
.vv-orch { background: #1e1e1e; border: 1px solid #444; border-radius: 6px;
padding: 10px 12px; margin-bottom: 8px; }
.vv-script { background: #161616; border: 1px solid #333; border-radius: 4px; .vv-script { background: #161616; border: 1px solid #333; border-radius: 4px;
padding: 6px 10px; margin: 4px 0; margin-left: 20px; } padding: 6px 10px; margin: 4px 0; margin-left: 20px; }
.vv-job-row { display: flex; align-items: center; gap: 10px; } .vv-job-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.vv-job-label { flex: 1; font-size: 14px; } .vv-job-label { flex: 1; font-size: 14px; min-width: 160px; }
.vv-cron { width: 160px; background: #111; border: 1px solid #444; color: #ddd; .vv-cron { width: 150px; background: #111; border: 1px solid #444; color: #ddd;
padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 13px; } padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 13px; }
.vv-children { padding-top: 6px; } .vv-children { padding-top: 8px; border-top: 1px solid #333; margin-top: 8px; }
.vv-advanced-toggle { background: none; border: 1px solid #555; color: #aaa; .vv-advanced-toggle { background: none; border: 1px solid #555; color: #aaa;
padding: 2px 8px; border-radius: 4px; cursor: pointer; font-size: 12px; } padding: 2px 8px; border-radius: 4px; cursor: pointer; font-size: 12px; }
.vv-advanced-toggle:hover { border-color: #888; color: #fff; } .vv-advanced-toggle:hover { border-color: #888; color: #fff; }
.vv-job-status { font-size: 12px; min-width: 20px; }
.vv-btn-sm { padding: 3px 10px; background: #2a2a2a; border: 1px solid #555; color: #ccc;
border-radius: 4px; cursor: pointer; font-size: 12px; white-space: nowrap; }
.vv-btn-sm:hover { border-color: #888; color: #fff; }
.vv-btn-sm.active { border-color: #4caf50; color: #4caf50; }
/* Log panel */
.vv-log-panel { margin-top: 10px; border-top: 1px solid #333; padding-top: 8px; }
.vv-log-toolbar { display: flex; justify-content: space-between; align-items: center;
margin-bottom: 4px; }
.vv-log-ts { font-size: 11px; color: #666; }
.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; }
/* Toggle switch */ /* Toggle switch */
.vv-toggle { position: relative; display: inline-block; width: 36px; height: 20px; flex-shrink: 0; } .vv-toggle { position: relative; display: inline-block; width: 36px; height: 20px; flex-shrink: 0; }
@@ -34,6 +34,10 @@ function vv_schedule_update(string $id, bool $enabled, string $cron): bool {
define('LOG_DIR', '/var/log/varaverk'); define('LOG_DIR', '/var/log/varaverk');
function vv_job_log_path(string $id): string {
return LOG_DIR . '/' . preg_replace('/\.sh$/', '.log', $id);
}
function vv_cron_rebuild(array $schedule): bool { function vv_cron_rebuild(array $schedule): bool {
if (!is_dir(LOG_DIR)) mkdir(LOG_DIR, 0755, true); if (!is_dir(LOG_DIR)) mkdir(LOG_DIR, 0755, true);
@@ -44,9 +48,11 @@ function vv_cron_rebuild(array $schedule): bool {
$scriptsDir = SCRIPTS_DIR; $scriptsDir = SCRIPTS_DIR;
foreach ($schedule as $entry) { foreach ($schedule as $entry) {
if (empty($entry['enabled']) || empty($entry['cron']) || empty($entry['id'])) continue; if (empty($entry['enabled']) || empty($entry['cron']) || empty($entry['id'])) continue;
$script = "$scriptsDir/{$entry['id']}"; $script = "$scriptsDir/{$entry['id']}";
$cron = $entry['cron']; $logFile = vv_job_log_path($entry['id']);
$lines[] = "$cron " . CRON_USER . " bash \"$script\" >> " . LOG_DIR . "/jobs.log 2>&1"; $logDir = dirname($logFile);
if (!is_dir($logDir)) mkdir($logDir, 0755, true);
$lines[] = "{$entry['cron']} " . CRON_USER . " bash \"$script\" >> \"$logFile\" 2>&1";
} }
$lines[] = ""; $lines[] = "";
@@ -4,15 +4,16 @@ $tree = vv_job_tree();
?> ?>
<div id="vv-scheduler"> <div id="vv-scheduler">
<p class="vv-hint">Orchestrators run their child scripts in the correct order. <p class="vv-hint">Orchestrators run their child scripts in the correct order.
Disable an orchestrator and enable individual scripts below it to run them Disable an orchestrator and enable individual scripts below it to run them
in isolation for debugging or testing.</p> in isolation for debugging or testing.</p>
<?php foreach ($tree as $orch): ?> <?php foreach ($tree as $orch): $oid = htmlspecialchars($orch['id']); ?>
<div class="vv-job vv-orch" data-id="<?= htmlspecialchars($orch['id']) ?>"> <div class="vv-card vv-wide vv-sched-card" data-id="<?= $oid ?>">
<!-- Orchestrator row -->
<div class="vv-job-row"> <div class="vv-job-row">
<label class="vv-toggle"> <label class="vv-toggle" title="Enable/disable">
<input type="checkbox" class="vv-enabled" <input type="checkbox" class="vv-enabled"
<?= $orch['enabled'] ? 'checked' : '' ?> <?= $orch['enabled'] ? 'checked' : '' ?>
onchange="vvSaveJob(this)"> onchange="vvSaveJob(this)">
@@ -21,18 +22,30 @@ $tree = vv_job_tree();
<span class="vv-job-label"><?= htmlspecialchars($orch['label']) ?></span> <span class="vv-job-label"><?= htmlspecialchars($orch['label']) ?></span>
<input type="text" class="vv-cron" value="<?= htmlspecialchars($orch['cron']) ?>" <input type="text" class="vv-cron" value="<?= htmlspecialchars($orch['cron']) ?>"
placeholder="cron expression" onblur="vvSaveJob(this)"> placeholder="cron expression" onblur="vvSaveJob(this)">
<span class="vv-job-status" id="vv-status-<?= md5($orch['id']) ?>"></span> <button class="vv-btn-sm" onclick="vvSaveJob(this)">Save</button>
<button class="vv-log-btn vv-btn-sm" onclick="vvToggleLog(this)">Log</button>
<span class="vv-job-status"></span>
<?php if (!empty($orch['children'])): ?> <?php if (!empty($orch['children'])): ?>
<button class="vv-advanced-toggle" onclick="vvToggleAdvanced(this)">Advanced ▸</button> <button class="vv-advanced-toggle" onclick="vvToggleAdvanced(this)">Advanced ▸</button>
<?php endif; ?> <?php endif; ?>
</div> </div>
<!-- Log panel -->
<div class="vv-log-panel" style="display:none;">
<div class="vv-log-toolbar">
<span class="vv-log-ts"></span>
<button class="vv-btn-sm" onclick="vvClearLog(this)">Clear</button>
</div>
<pre class="vv-log-pre">(no log yet)</pre>
</div>
<!-- Children -->
<?php if (!empty($orch['children'])): ?> <?php if (!empty($orch['children'])): ?>
<div class="vv-children" style="display:none;"> <div class="vv-children" style="display:none;">
<?php foreach ($orch['children'] as $child): ?> <?php foreach ($orch['children'] as $child): $cid = htmlspecialchars($child['id']); ?>
<div class="vv-job vv-script" data-id="<?= htmlspecialchars($child['id']) ?>"> <div class="vv-script" data-id="<?= $cid ?>">
<div class="vv-job-row"> <div class="vv-job-row">
<label class="vv-toggle"> <label class="vv-toggle" title="Enable/disable">
<input type="checkbox" class="vv-enabled" <input type="checkbox" class="vv-enabled"
<?= $child['enabled'] ? 'checked' : '' ?> <?= $child['enabled'] ? 'checked' : '' ?>
onchange="vvSaveJob(this)"> onchange="vvSaveJob(this)">
@@ -41,22 +54,36 @@ $tree = vv_job_tree();
<span class="vv-job-label"><?= htmlspecialchars($child['label']) ?></span> <span class="vv-job-label"><?= htmlspecialchars($child['label']) ?></span>
<input type="text" class="vv-cron" value="<?= htmlspecialchars($child['cron']) ?>" <input type="text" class="vv-cron" value="<?= htmlspecialchars($child['cron']) ?>"
placeholder="cron expression" onblur="vvSaveJob(this)"> placeholder="cron expression" onblur="vvSaveJob(this)">
<button class="vv-btn-sm" onclick="vvSaveJob(this)">Save</button>
<button class="vv-log-btn vv-btn-sm" onclick="vvToggleLog(this)">Log</button>
<span class="vv-job-status"></span>
</div>
<div class="vv-log-panel" style="display:none;">
<div class="vv-log-toolbar">
<span class="vv-log-ts"></span>
<button class="vv-btn-sm" onclick="vvClearLog(this)">Clear</button>
</div>
<pre class="vv-log-pre">(no log yet)</pre>
</div> </div>
</div> </div>
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
<?php endif; ?> <?php endif; ?>
</div> </div>
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
<script> <script>
const vvLogTimers = {};
function vvSaveJob(el) { function vvSaveJob(el) {
const row = el.closest('.vv-job'); const row = el.closest('.vv-job-row');
const id = row.dataset.id; const job = el.closest('[data-id]');
const enabled = row.querySelector('.vv-enabled').checked; const id = job.dataset.id;
const cron = row.querySelector('.vv-cron').value.trim(); const enabled = job.querySelector('.vv-enabled').checked;
const cron = job.querySelector('.vv-cron').value.trim();
const status = row.querySelector('.vv-job-status');
fetch('/plugins/varaverk/api/scheduler.php', { fetch('/plugins/varaverk/api/scheduler.php', {
method: 'POST', method: 'POST',
@@ -65,15 +92,59 @@ function vvSaveJob(el) {
}) })
.then(r => r.json()) .then(r => r.json())
.then(d => { .then(d => {
const statusEl = document.getElementById('vv-status-' + btoa(id).replace(/=/g,'')); if (status) vvFlashStatus(status, d.ok ? '✓' : '✗ ' + (d.error ?? ''), d.ok);
if (statusEl) statusEl.textContent = d.ok ? '✓' : '✗ ' + (d.error ?? '');
}); });
} }
function vvToggleAdvanced(btn) { function vvToggleAdvanced(btn) {
const children = btn.closest('.vv-job').querySelector('.vv-children'); const card = btn.closest('.vv-sched-card');
const children = card.querySelector('.vv-children');
const visible = children.style.display !== 'none'; const visible = children.style.display !== 'none';
children.style.display = visible ? 'none' : 'block'; children.style.display = visible ? 'none' : 'block';
btn.textContent = visible ? 'Advanced ▸' : 'Advanced ▾'; btn.textContent = visible ? 'Advanced ▸' : 'Advanced ▾';
} }
function vvToggleLog(btn) {
const job = btn.closest('[data-id]');
const id = job.dataset.id;
const panel = job.querySelector('.vv-log-panel');
const open = panel.style.display !== 'none';
if (open) {
panel.style.display = 'none';
btn.textContent = 'Log';
btn.classList.remove('active');
clearInterval(vvLogTimers[id]);
delete vvLogTimers[id];
} else {
panel.style.display = 'block';
btn.textContent = 'Log ▾';
btn.classList.add('active');
vvFetchLog(panel, id);
vvLogTimers[id] = setInterval(() => vvFetchLog(panel, id), 3000);
}
}
function vvFetchLog(panel, id) {
fetch('/plugins/varaverk/api/log.php?id=' + encodeURIComponent(id))
.then(r => r.json())
.then(d => {
const pre = panel.querySelector('.vv-log-pre');
const ts = panel.querySelector('.vv-log-ts');
if (!d.ok) { pre.textContent = '✗ ' + (d.error ?? 'Error'); return; }
pre.textContent = d.content || '(no log yet)';
pre.scrollTop = pre.scrollHeight;
ts.textContent = d.ts ? 'Last run: ' + new Date(d.ts * 1000).toLocaleString() : '';
})
.catch(() => {});
}
function vvClearLog(btn) {
const panel = btn.closest('.vv-log-panel');
const job = btn.closest('[data-id]');
const id = job.dataset.id;
fetch('/plugins/varaverk/api/log.php?id=' + encodeURIComponent(id) + '&clear=1', {method:'POST'})
.then(() => vvFetchLog(panel, id))
.catch(() => {});
}
</script> </script>