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;
padding: 6px 10px; margin: 4px 0; margin-left: 20px; }
.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-cron { width: 150px; background: #111; border: 1px solid #444; color: #ddd;
.vv-job-label { flex: 1; font-size: 14px; min-width: 120px; }
.vv-cron { width: 140px; background: #111; border: 1px solid #444; color: #ddd;
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-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.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 */
.vv-card-footer { display: flex; align-items: center; gap: 10px;
margin-top: 12px; padding-top: 10px; border-top: 1px solid #333; }
@@ -4,86 +4,99 @@ $tree = vv_job_tree();
?>
<div id="vv-scheduler">
<p class="vv-hint">Toggle enables/saves immediately. Edit cron expressions then hit
<strong>Save</strong> at the bottom of the card. <strong>Run</strong> fires the script
now and opens its log.</p>
<p class="vv-hint">Toggle saves immediately. Edit cron then <strong>Save</strong>.
<strong>Run</strong> fires now; <strong>Dry Run</strong> sets DRY_RUN=1.
Log opens on the right.</p>
<?php foreach ($tree as $orch): $oid = htmlspecialchars($orch['id']); ?>
<div class="vv-card vv-wide vv-sched-card" data-id="<?= $oid ?>">
<div id="vv-sched-layout">
<!-- Orchestrator row -->
<div class="vv-job-row">
<label class="vv-toggle" title="Enable/disable (saves immediately)">
<input type="checkbox" class="vv-enabled"
<?= $orch['enabled'] ? 'checked' : '' ?>
onchange="vvSaveJob(this)">
<span class="vv-slider"></span>
</label>
<span class="vv-job-label"><?= htmlspecialchars($orch['label']) ?></span>
<input type="text" class="vv-cron" value="<?= htmlspecialchars($orch['cron']) ?>"
placeholder="cron expression">
<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-log-btn vv-btn-sm" onclick="vvToggleLog(this)">Log</button>
<span class="vv-job-status"></span>
<?php if (!empty($orch['children'])): ?>
<button class="vv-advanced-toggle" onclick="vvToggleAdvanced(this)">Advanced ▸</button>
<?php endif; ?>
</div>
<!-- ── Left: script cards ── -->
<div id="vv-sched-left">
<!-- 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>
<?php foreach ($tree as $orch): $oid = htmlspecialchars($orch['id']); ?>
<div class="vv-card vv-wide vv-sched-card" data-id="<?= $oid ?>">
<!-- Children (Advanced) -->
<?php if (!empty($orch['children'])): ?>
<div class="vv-children" style="display:none;">
<?php foreach ($orch['children'] as $child): $cid = htmlspecialchars($child['id']); ?>
<div class="vv-script" data-id="<?= $cid ?>">
<div class="vv-job-row">
<label class="vv-toggle" title="Enable/disable (saves immediately)">
<input type="checkbox" class="vv-enabled"
<?= $child['enabled'] ? 'checked' : '' ?>
onchange="vvSaveJob(this)">
<span class="vv-slider"></span>
</label>
<span class="vv-job-label"><?= htmlspecialchars($child['label']) ?></span>
<input type="text" class="vv-cron" value="<?= htmlspecialchars($child['cron']) ?>"
placeholder="cron expression">
<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-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 class="vv-job-row">
<label class="vv-toggle" title="Enable/disable">
<input type="checkbox" class="vv-enabled"
<?= $orch['enabled'] ? 'checked' : '' ?>
onchange="vvSaveJob(this)">
<span class="vv-slider"></span>
</label>
<span class="vv-job-label"><?= htmlspecialchars($orch['label']) ?></span>
<input type="text" class="vv-cron" value="<?= htmlspecialchars($orch['cron']) ?>"
placeholder="cron expression">
<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" onclick="vvSelectLog(this)">Log</button>
<span class="vv-job-dot"></span>
<span class="vv-job-status"></span>
<?php if (!empty($orch['children'])): ?>
<button class="vv-advanced-toggle" onclick="vvToggleAdvanced(this)">Advanced ▸</button>
<?php endif; ?>
</div>
<?php if (!empty($orch['children'])): ?>
<div class="vv-children" style="display:none;">
<?php foreach ($orch['children'] as $child): $cid = htmlspecialchars($child['id']); ?>
<div class="vv-script" data-id="<?= $cid ?>">
<div class="vv-job-row">
<label class="vv-toggle" title="Enable/disable">
<input type="checkbox" class="vv-enabled"
<?= $child['enabled'] ? 'checked' : '' ?>
onchange="vvSaveJob(this)">
<span class="vv-slider"></span>
</label>
<span class="vv-job-label"><?= htmlspecialchars($child['label']) ?></span>
<input type="text" class="vv-cron" value="<?= htmlspecialchars($child['cron']) ?>"
placeholder="cron expression">
<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" onclick="vvSelectLog(this)">Log</button>
<span class="vv-job-dot"></span>
<span class="vv-job-status"></span>
</div>
</div>
<pre class="vv-log-pre">(no log yet)</pre>
</div>
<?php endforeach; ?>
</div>
<?php endforeach; ?>
<?php endif; ?>
<div class="vv-card-footer">
<button class="vv-save-btn" onclick="vvSaveCard(this)">Save Schedule</button>
<span class="vv-save-status"></span>
</div>
</div>
<?php endif; ?>
<?php endforeach; ?>
<!-- Card footer: single Save for all crons in this card -->
<div class="vv-card-footer">
<button class="vv-save-btn" onclick="vvSaveCard(this)">Save Schedule</button>
<span class="vv-save-status"></span>
</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>
<?php endforeach; ?>
</div><!-- /#vv-sched-layout -->
</div>
<script>
const vvLogTimers = {};
let vvActiveId = null;
let vvRunningId = null;
let vvLastTs = 0;
let vvStaleCount = 0;
let vvPollTimer = null;
function vvPost(url, data) {
const params = new URLSearchParams({csrf_token, ...data});
@@ -94,7 +107,103 @@ function vvPost(url, data) {
}).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) {
const job = el.closest('[data-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); });
}
// Save button at card bottom: saves all jobs (orch + children) in this card
function vvSaveCard(btn) {
const card = btn.closest('.vv-sched-card');
const status = card.querySelector('.vv-save-status');
const jobs = card.querySelectorAll('[data-id]');
let pending = jobs.length;
let allOk = true;
const card = btn.closest('.vv-sched-card');
const status = card.querySelector('.vv-save-status');
const jobs = card.querySelectorAll('[data-id]');
let pending = jobs.length, allOk = true;
status.textContent = 'Saving…';
jobs.forEach(job => {
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) {
const card = btn.closest('.vv-sched-card');
const children = card.querySelector('.vv-children');
@@ -167,46 +240,10 @@ function vvToggleAdvanced(btn) {
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]');
vvPost('/plugins/varaverk/api/log.php', {id: job.dataset.id, clear: '1'})
.then(() => vvFetchLog(panel, job.dataset.id))
function vvClearRightLog() {
if (!vvActiveId) return;
vvPost('/plugins/varaverk/api/log.php', {id: vvActiveId, clear: '1'})
.then(() => vvFetchRight())
.catch(() => {});
}
</script>