Scheduler: two-panel responsive layout with running indicator

Scripts list on left, persistent log panel on right. On screens ≤880px
falls back to stacked single-column. Right panel stays open until page
refresh or another selection. Running dot pulses green on the job row
and in the log header while script executes, clears when log goes stale.
This commit is contained in:
Gmer4Lfe
2026-05-23 17:59:51 -04:00
parent d0b842a489
commit 706ef2dd72
2 changed files with 212 additions and 150 deletions
@@ -38,8 +38,8 @@
.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: 8px; flex-wrap: wrap; } .vv-job-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.vv-job-label { flex: 1; font-size: 14px; min-width: 160px; } .vv-job-label { flex: 1; font-size: 14px; min-width: 120px; }
.vv-cron { width: 150px; background: #111; border: 1px solid #444; color: #ddd; .vv-cron { width: 140px; 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: 8px; border-top: 1px solid #333; margin-top: 8px; } .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;
@@ -51,6 +51,31 @@
.vv-btn-sm:hover { border-color: #888; color: #fff; } .vv-btn-sm:hover { border-color: #888; color: #fff; }
.vv-btn-sm.active { border-color: #4caf50; color: #4caf50; } .vv-btn-sm.active { border-color: #4caf50; color: #4caf50; }
/* Running dot */
.vv-job-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; flex-shrink: 0; }
.vv-dot-running { background: #4caf50; animation: vv-pulse-dot 1s ease-in-out infinite; }
#vv-log-dot { animation: vv-pulse-dot 1s ease-in-out infinite; }
@keyframes vv-pulse-dot { 0%, 100% { opacity: 1; } 50% { opacity: 0.2; } }
/* Selected job row */
.vv-row-selected { background: rgba(255,255,255,0.05); border-radius: 4px;
outline: 1px solid #555; }
/* Two-panel layout */
#vv-sched-layout { display: flex; gap: 16px; align-items: flex-start; }
#vv-sched-left { flex: 0 0 500px; min-width: 0; }
#vv-sched-right { display: none; flex: 1; min-width: 0; position: sticky; top: 16px; }
#vv-sched-right.vv-panel-visible { display: block; }
.vv-log-right-pre { min-height: 360px; max-height: calc(100vh - 200px); }
/* Mobile: stack vertically, right panel full-width below cards */
@media (max-width: 880px) {
#vv-sched-layout { flex-direction: column; }
#vv-sched-left { flex: none; width: 100%; }
#vv-sched-right { position: static; width: 100%; }
.vv-log-right-pre { min-height: 200px; max-height: 340px; }
}
/* Card footer */ /* Card footer */
.vv-card-footer { display: flex; align-items: center; gap: 10px; .vv-card-footer { display: flex; align-items: center; gap: 10px;
margin-top: 12px; padding-top: 10px; border-top: 1px solid #333; } margin-top: 12px; padding-top: 10px; border-top: 1px solid #333; }
@@ -4,16 +4,20 @@ $tree = vv_job_tree();
?> ?>
<div id="vv-scheduler"> <div id="vv-scheduler">
<p class="vv-hint">Toggle enables/saves immediately. Edit cron expressions then hit <p class="vv-hint">Toggle saves immediately. Edit cron then <strong>Save</strong>.
<strong>Save</strong> at the bottom of the card. <strong>Run</strong> fires the script <strong>Run</strong> fires now; <strong>Dry Run</strong> sets DRY_RUN=1.
now and opens its log.</p> Log opens on the right.</p>
<div id="vv-sched-layout">
<!-- ── Left: script cards ── -->
<div id="vv-sched-left">
<?php foreach ($tree as $orch): $oid = htmlspecialchars($orch['id']); ?> <?php foreach ($tree as $orch): $oid = htmlspecialchars($orch['id']); ?>
<div class="vv-card vv-wide vv-sched-card" data-id="<?= $oid ?>"> <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" title="Enable/disable (saves immediately)"> <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)">
@@ -24,29 +28,20 @@ $tree = vv_job_tree();
placeholder="cron expression"> placeholder="cron expression">
<button class="vv-btn-sm vv-run-btn" onclick="vvRunJob(this)">&#9654; Run</button> <button class="vv-btn-sm vv-run-btn" onclick="vvRunJob(this)">&#9654; Run</button>
<button class="vv-btn-sm vv-dry-btn" onclick="vvDryRun(this)">&#9654; Dry Run</button> <button class="vv-btn-sm vv-dry-btn" onclick="vvDryRun(this)">&#9654; Dry Run</button>
<button class="vv-log-btn vv-btn-sm" onclick="vvToggleLog(this)">Log</button> <button class="vv-btn-sm" onclick="vvSelectLog(this)">Log</button>
<span class="vv-job-dot"></span>
<span class="vv-job-status"></span> <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>
<!-- Orchestrator 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 (Advanced) -->
<?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): $cid = htmlspecialchars($child['id']); ?> <?php foreach ($orch['children'] as $child): $cid = htmlspecialchars($child['id']); ?>
<div class="vv-script" data-id="<?= $cid ?>"> <div class="vv-script" data-id="<?= $cid ?>">
<div class="vv-job-row"> <div class="vv-job-row">
<label class="vv-toggle" title="Enable/disable (saves immediately)"> <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)">
@@ -57,22 +52,15 @@ $tree = vv_job_tree();
placeholder="cron expression"> placeholder="cron expression">
<button class="vv-btn-sm vv-run-btn" onclick="vvRunJob(this)">&#9654; Run</button> <button class="vv-btn-sm vv-run-btn" onclick="vvRunJob(this)">&#9654; Run</button>
<button class="vv-btn-sm vv-dry-btn" onclick="vvDryRun(this)">&#9654; Dry Run</button> <button class="vv-btn-sm vv-dry-btn" onclick="vvDryRun(this)">&#9654; Dry Run</button>
<button class="vv-log-btn vv-btn-sm" onclick="vvToggleLog(this)">Log</button> <button class="vv-btn-sm" onclick="vvSelectLog(this)">Log</button>
<span class="vv-job-dot"></span>
<span class="vv-job-status"></span> <span class="vv-job-status"></span>
</div> </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>
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
<?php endif; ?> <?php endif; ?>
<!-- Card footer: single Save for all crons in this card -->
<div class="vv-card-footer"> <div class="vv-card-footer">
<button class="vv-save-btn" onclick="vvSaveCard(this)">Save Schedule</button> <button class="vv-save-btn" onclick="vvSaveCard(this)">Save Schedule</button>
<span class="vv-save-status"></span> <span class="vv-save-status"></span>
@@ -80,10 +68,35 @@ $tree = vv_job_tree();
</div> </div>
<?php endforeach; ?> <?php endforeach; ?>
</div><!-- /#vv-sched-left -->
<!-- ── Right: persistent log panel ── -->
<div id="vv-sched-right">
<div class="vv-card" style="min-height:200px;">
<div class="vv-log-toolbar" style="margin-bottom:8px;">
<div style="display:flex;align-items:center;gap:8px;">
<span id="vv-log-title" style="font-size:13px;font-weight:bold;color:#ccc;">No script selected</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:8px;">
<span id="vv-log-ts" class="vv-log-ts"></span>
<button class="vv-btn-sm" onclick="vvClearRightLog()">Clear</button>
</div>
</div>
<pre id="vv-log-pre" class="vv-log-pre vv-log-right-pre">Click Log, Run, or Dry Run on a script to view output here.</pre>
</div>
</div>
</div><!-- /#vv-sched-layout -->
</div> </div>
<script> <script>
const vvLogTimers = {}; let vvActiveId = null;
let vvRunningId = null;
let vvLastTs = 0;
let vvStaleCount = 0;
let vvPollTimer = null;
function vvPost(url, data) { function vvPost(url, data) {
const params = new URLSearchParams({csrf_token, ...data}); const params = new URLSearchParams({csrf_token, ...data});
@@ -94,7 +107,103 @@ function vvPost(url, data) {
}).then(r => r.json()); }).then(r => r.json());
} }
// Toggle auto-saves its single job immediately // Open the right panel for a given job id
function vvOpenRight(id) {
// Deselect old
if (vvActiveId) {
const old = document.querySelector('[data-id="' + CSS.escape(vvActiveId) + '"]');
if (old) old.querySelector('.vv-job-row').classList.remove('vv-row-selected');
}
vvActiveId = id;
// Highlight new
const job = document.querySelector('[data-id="' + CSS.escape(id) + '"]');
if (job) job.querySelector('.vv-job-row').classList.add('vv-row-selected');
const name = id.replace(/\.sh$/, '').split('/').pop();
document.getElementById('vv-log-title').textContent = name;
document.getElementById('vv-sched-right').classList.add('vv-panel-visible');
vvFetchRight();
if (!vvPollTimer) vvPollTimer = setInterval(vvFetchRight, 2000);
}
function vvFetchRight() {
if (!vvActiveId) return;
fetch('/plugins/varaverk/api/log.php?id=' + encodeURIComponent(vvActiveId))
.then(r => r.json())
.then(d => {
const pre = document.getElementById('vv-log-pre');
const ts = document.getElementById('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() : '';
// Running indicator: clear when log stops changing
if (vvRunningId === vvActiveId) {
if (d.ts && d.ts === vvLastTs) {
if (++vvStaleCount >= 2) vvSetRunning(null);
} else {
vvLastTs = d.ts || 0;
vvStaleCount = 0;
}
}
})
.catch(() => {});
}
function vvSetRunning(id) {
// Clear old dot
if (vvRunningId) {
const old = document.querySelector('[data-id="' + CSS.escape(vvRunningId) + '"]');
if (old) { const dot = old.querySelector('.vv-job-dot'); dot.className = 'vv-job-dot'; }
}
vvRunningId = id;
vvLastTs = 0;
vvStaleCount = 0;
const headerDot = document.getElementById('vv-log-dot');
if (id) {
headerDot.style.display = 'inline-block';
const job = document.querySelector('[data-id="' + CSS.escape(id) + '"]');
if (job) job.querySelector('.vv-job-dot').classList.add('vv-dot-running');
} else {
headerDot.style.display = 'none';
}
}
function vvSelectLog(btn) {
const job = btn.closest('[data-id]');
vvOpenRight(job.dataset.id);
}
function vvRunJob(btn) {
const job = btn.closest('[data-id]');
const id = job.dataset.id;
vvOpenRight(id);
vvSetRunning(id);
vvPost('/plugins/varaverk/api/run.php', {id})
.then(d => {
if (!d.ok) {
document.getElementById('vv-log-pre').textContent = '✗ ' + (d.error ?? 'Failed to start');
vvSetRunning(null);
}
});
}
function vvDryRun(btn) {
const job = btn.closest('[data-id]');
const id = job.dataset.id;
vvOpenRight(id);
vvSetRunning(id);
vvPost('/plugins/varaverk/api/dryrun.php', {id})
.then(d => {
if (!d.ok) {
document.getElementById('vv-log-pre').textContent = '✗ ' + (d.error ?? 'Failed to start');
vvSetRunning(null);
}
});
}
function vvSaveJob(el) { function vvSaveJob(el) {
const job = el.closest('[data-id]'); const job = el.closest('[data-id]');
const id = job.dataset.id; const id = job.dataset.id;
@@ -105,14 +214,11 @@ function vvSaveJob(el) {
.then(d => { if (status) vvFlashStatus(status, d.ok ? '✓' : '✗ ' + (d.error ?? ''), d.ok); }); .then(d => { if (status) vvFlashStatus(status, d.ok ? '✓' : '✗ ' + (d.error ?? ''), d.ok); });
} }
// Save button at card bottom: saves all jobs (orch + children) in this card
function vvSaveCard(btn) { function vvSaveCard(btn) {
const card = btn.closest('.vv-sched-card'); const card = btn.closest('.vv-sched-card');
const status = card.querySelector('.vv-save-status'); const status = card.querySelector('.vv-save-status');
const jobs = card.querySelectorAll('[data-id]'); const jobs = card.querySelectorAll('[data-id]');
let pending = jobs.length; let pending = jobs.length, allOk = true;
let allOk = true;
status.textContent = 'Saving…'; status.textContent = 'Saving…';
jobs.forEach(job => { jobs.forEach(job => {
const id = job.dataset.id; const id = job.dataset.id;
@@ -126,39 +232,6 @@ function vvSaveCard(btn) {
}); });
} }
// Run: fires script immediately, opens log panel
function vvRunJob(btn) {
const job = btn.closest('[data-id]');
const id = job.dataset.id;
const logBtn = job.querySelector('.vv-log-btn');
if (logBtn && !logBtn.classList.contains('active')) vvToggleLog(logBtn);
vvPost('/plugins/varaverk/api/run.php', {id})
.then(d => {
if (!d.ok) {
const pre = job.querySelector('.vv-log-pre');
if (pre) pre.textContent = '✗ ' + (d.error ?? 'Failed to start');
}
});
}
function vvDryRun(btn) {
const job = btn.closest('[data-id]');
const id = job.dataset.id;
const logBtn = job.querySelector('.vv-log-btn');
if (logBtn && !logBtn.classList.contains('active')) vvToggleLog(logBtn);
vvPost('/plugins/varaverk/api/dryrun.php', {id})
.then(d => {
if (!d.ok) {
const pre = job.querySelector('.vv-log-pre');
if (pre) pre.textContent = '✗ ' + (d.error ?? 'Failed to start dry run');
}
});
}
function vvToggleAdvanced(btn) { function vvToggleAdvanced(btn) {
const card = btn.closest('.vv-sched-card'); const card = btn.closest('.vv-sched-card');
const children = card.querySelector('.vv-children'); const children = card.querySelector('.vv-children');
@@ -167,46 +240,10 @@ function vvToggleAdvanced(btn) {
btn.textContent = visible ? 'Advanced ▸' : 'Advanced ▾'; btn.textContent = visible ? 'Advanced ▸' : 'Advanced ▾';
} }
function vvToggleLog(btn) { function vvClearRightLog() {
const job = btn.closest('[data-id]'); if (!vvActiveId) return;
const id = job.dataset.id; vvPost('/plugins/varaverk/api/log.php', {id: vvActiveId, clear: '1'})
const panel = job.querySelector('.vv-log-panel'); .then(() => vvFetchRight())
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]');
vvPost('/plugins/varaverk/api/log.php', {id: job.dataset.id, clear: '1'})
.then(() => vvFetchLog(panel, job.dataset.id))
.catch(() => {}); .catch(() => {});
} }
</script> </script>