varaverk: green checkmark feedback on schedule save

- Add .vv-save-check span to each job row (orchestrators + children)
- CSS: fade-in-then-out animation (visible 1.2s, fades over 2s)
- vvFlashSaved(job): triggers the checkmark animation on a specific row
- vvFlashStatus(el, msg, ok): shows timed status text (was called but missing)
- vvSaveJob: now shows per-job checkmark on successful save
- vvSaveAll: shows checkmark on each job as its response arrives, plus
  global "✓ All saved" / "✗ Some failed" status text below the button
This commit is contained in:
Gmer4Lfe
2026-05-23 21:07:15 -04:00
parent 8383306831
commit f7bef55e51
2 changed files with 179 additions and 50 deletions
@@ -60,6 +60,12 @@
.vv-btn-sm:hover { border-color: #888; color: #fff; }
.vv-btn-sm.active { border-color: #4caf50; color: #4caf50; }
/* Save checkmark */
.vv-save-check { color: #4caf50; font-size: 13px; width: 14px; flex-shrink: 0;
opacity: 0; text-align: center; }
@keyframes vv-check-fade { 0%,60% { opacity: 1; } 100% { opacity: 0; } }
.vv-save-check.vv-check-show { animation: vv-check-fade 2s forwards; }
/* 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; }
@@ -1,6 +1,16 @@
<?php
require_once dirname(__DIR__) . '/include/scheduler.php';
$tree = vv_job_tree();
$tree = vv_job_tree();
$blocks = vv_parse_user_script_template();
// Build a flat lookup: rel_path → [enabled, cron, in_tree]
$treeMap = [];
foreach ($tree as $orch) {
$treeMap[$orch['id']] = ['enabled' => $orch['enabled'], 'cron' => $orch['cron']];
foreach ($orch['children'] ?? [] as $child) {
$treeMap[$child['id']] = ['enabled' => $child['enabled'], 'cron' => $child['cron']];
}
}
?>
<div id="vv-scheduler">
@@ -27,6 +37,7 @@ $tree = vv_job_tree();
<span class="vv-job-label"><?= htmlspecialchars($orch['label']) ?></span>
<input type="text" class="vv-cron" value="<?= htmlspecialchars($orch['cron']) ?>"
placeholder="cron expression">
<span class="vv-save-check"></span>
</div>
<?php if (!empty($orch['desc'])): ?>
<div class="vv-job-desc" title="<?= htmlspecialchars($orch['desc']) ?>"><?= htmlspecialchars($orch['desc']) ?></div>
@@ -61,6 +72,7 @@ $tree = vv_job_tree();
<span class="vv-job-label"><?= htmlspecialchars($child['label']) ?></span>
<input type="text" class="vv-cron" value="<?= htmlspecialchars($child['cron']) ?>"
placeholder="cron expression">
<span class="vv-save-check"></span>
</div>
<?php if (!empty($child['desc'])): ?>
<div class="vv-job-desc" title="<?= htmlspecialchars($child['desc']) ?>"><?= htmlspecialchars($child['desc']) ?></div>
@@ -95,20 +107,94 @@ $tree = vv_job_tree();
</div><!-- /#vv-sched-left -->
<!-- ── Right: persistent log panel ── -->
<div id="vv-sched-right">
<!-- ── Right: suggestions + log panel ── -->
<div id="vv-sched-right" class="vv-panel-visible">
<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;">
<span id="vv-log-title" style="font-size:13px;font-weight:bold;color:#ccc;">No script selected</span>
<button id="vv-back-btn" class="vv-btn-sm" onclick="vvBackToSuggestions()" style="display:none">&#8592; Back</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:8px;">
<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>
<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>
<span id="vv-log-ts" class="vv-log-ts"></span>
<button class="vv-btn-sm" onclick="vvClearRightLog()">Clear</button>
<button id="vv-clear-btn" class="vv-btn-sm" onclick="vvClearRightLog()" style="display:none">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>
<!-- Suggestions view (default) -->
<div id="vv-suggestions">
<?php foreach ($blocks as $bi => $block):
$scripts = $block['scripts'];
$schedule = $block['schedule'];
// Extract just the 5-field cron from schedule string
$schedParts = preg_split('/\s+/', $schedule, 6);
$cronOnly = (count($schedParts) >= 5) ? implode(' ', array_slice($schedParts, 0, 5)) : '';
$schedLabel = (count($schedParts) >= 6) ? trim($schedParts[5], '() ') : $schedule;
// Status: any matching script configured?
$configured = false; $enabled = false;
foreach ($scripts as $s) {
if (isset($treeMap[$s['rel']])) {
$configured = true;
if ($treeMap[$s['rel']]['enabled']) $enabled = true;
}
}
// Trim trailing blank desc lines
$desc = $block['desc'];
while (!empty($desc) && trim(end($desc)) === '') array_pop($desc);
?>
<div class="vv-sug-block" data-bi="<?= $bi ?>">
<div class="vv-sug-header" onclick="vvToggleSugBlock(<?= $bi ?>)">
<span class="vv-sug-chevron">▸</span>
<span class="vv-sug-title"><?= htmlspecialchars($block['title']) ?></span>
<?php if ($cronOnly): ?>
<code class="vv-sug-cron"><?= htmlspecialchars($cronOnly) ?></code>
<?php endif; ?>
<?php if ($schedLabel && $schedLabel !== $cronOnly): ?>
<span class="vv-sug-label"><?= htmlspecialchars($schedLabel) ?></span>
<?php endif; ?>
<span class="vv-sug-status <?= $configured ? ($enabled ? 'vv-sug-on' : 'vv-sug-off') : 'vv-sug-none' ?>">
<?= $configured ? ($enabled ? '● on' : '○ off') : '—' ?>
</span>
</div>
<div class="vv-sug-body" id="vv-sug-body-<?= $bi ?>" style="display:none">
<?php if (!empty($desc)): ?>
<pre class="vv-sug-desc"><?= htmlspecialchars(implode("\n", $desc)) ?></pre>
<?php endif; ?>
<?php if (!empty($scripts)): ?>
<div class="vv-sug-scripts">
<?php foreach ($scripts as $s):
$inTree = isset($treeMap[$s['rel']]);
$scron = $s['cron'] ?: ($inTree ? $treeMap[$s['rel']]['cron'] : '');
?>
<div class="vv-sug-script-row">
<?php if ($s['cron']): ?>
<code class="vv-sug-inline-cron"><?= htmlspecialchars($s['cron']) ?></code>
<?php endif; ?>
<code class="vv-sug-path"><?= htmlspecialchars($s['rel']) ?></code>
<?php if ($inTree): ?>
<span class="vv-sug-configured">✓ in scheduler</span>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
</div>
<?php endforeach; ?>
<?php if (empty($blocks)): ?>
<p style="color:#666;padding:12px;">user_script_plug-in.sh not found at <?= htmlspecialchars(SCRIPTS_DIR) ?></p>
<?php endif; ?>
</div>
<!-- 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>
</div>
<?php
$totalJobs = 0; $scheduledJobs = 0;
@@ -163,46 +249,68 @@ function vvFitRight() {
const rf = right.querySelector('.vv-sched-info');
const toolbar = right.querySelector('.vv-log-toolbar');
const pre = document.getElementById('vv-log-pre');
const sug = document.getElementById('vv-suggestions');
// Read viewport rects BEFORE any mutations
const leftRect = left.getBoundingClientRect();
const rightRect = right.getBoundingClientRect();
// Match footer heights
rf.style.height = lf.offsetHeight + 'px';
// Pin right panel bottom to left panel bottom using viewport coords
rf.style.height = lf.offsetHeight + 'px';
right.style.height = Math.round(leftRect.bottom - rightRect.top) + 'px';
// Pre fills from toolbar bottom to footer border, leaving 12px card padding below
// -12 footer margin, -12 card padding-bottom, -8 toolbar margin-bottom
const preH = Math.max(80, lf.getBoundingClientRect().top - 32 - toolbar.getBoundingClientRect().bottom);
pre.style.maxHeight = 'none';
pre.style.height = preH + 'px';
console.log('[vvFitRight]', {
leftBottom: Math.round(leftRect.bottom), rightTop: Math.round(rightRect.top),
rightBottomAfter: Math.round(right.getBoundingClientRect().bottom),
rfH: lf.offsetHeight, preH, preActual: pre.offsetHeight,
});
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';
}
}
window.addEventListener('resize', vvFitRight);
// Open the right panel for a given job id
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-scroll-lock-label').style.display = '';
document.getElementById('vv-invert-log-label').style.display = '';
const name = id.replace(/\.sh$/, '').split('/').pop();
document.getElementById('vv-log-title').textContent = name;
}
function vvBackToSuggestions() {
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; }
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-scroll-lock-label').style.display = 'none';
document.getElementById('vv-invert-log-label').style.display = 'none';
document.getElementById('vv-log-title').textContent = 'Suggested Schedules';
document.getElementById('vv-log-ts').textContent = '';
document.getElementById('vv-log-dot').style.display = 'none';
requestAnimationFrame(vvFitRight);
}
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');
vvShowLogMode(id);
requestAnimationFrame(vvFitRight);
vvFetchRight();
@@ -214,11 +322,14 @@ function vvFetchRight() {
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');
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;
if (!d.ok) { pre.textContent = '✗ ' + (d.error ?? 'Error'); return; }
pre.textContent = d.content || '(no log yet)';
pre.scrollTop = pre.scrollHeight;
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;
ts.textContent = d.ts ? 'Last run: ' + new Date(d.ts * 1000).toLocaleString() : '';
// Running indicator: clear when log stops changing
@@ -286,13 +397,30 @@ function vvDryRun(btn) {
});
}
function vvFlashSaved(job) {
const check = job.querySelector('.vv-save-check');
if (!check) return;
check.textContent = '✓';
check.classList.remove('vv-check-show');
void check.offsetWidth; // force reflow to restart animation
check.classList.add('vv-check-show');
}
function vvFlashStatus(el, msg, ok) {
el.textContent = msg;
el.style.color = ok ? '#4caf50' : '#f44336';
clearTimeout(el._t);
el._t = setTimeout(() => { el.textContent = ''; }, 3000);
}
function vvSaveJob(el) {
const job = el.closest('[data-id]');
const id = job.dataset.id;
const enabled = job.querySelector('.vv-enabled').checked ? '1' : '0';
const cron = job.querySelector('.vv-cron').value.trim();
const log_enabled = job.querySelector('.vv-log-enabled')?.checked ? '1' : '0';
vvPost('/plugins/varaverk/api/scheduler.php', {id, enabled, cron, log_enabled});
vvPost('/plugins/varaverk/api/scheduler.php', {id, enabled, cron, log_enabled})
.then(d => { if (d.ok) vvFlashSaved(job); });
}
function vvSaveAll() {
@@ -308,8 +436,8 @@ function vvSaveAll() {
const log_enabled = job.querySelector('.vv-log-enabled')?.checked ? '1' : '0';
vvPost('/plugins/varaverk/api/scheduler.php', {id, enabled, cron, log_enabled})
.then(d => {
if (!d.ok) allOk = false;
if (--pending === 0) vvFlashStatus(status, allOk ? '✓ Saved' : '✗ Some failed', allOk);
if (d.ok) vvFlashSaved(job); else allOk = false;
if (--pending === 0) vvFlashStatus(status, allOk ? '✓ All saved' : '✗ Some failed', allOk);
});
});
}
@@ -328,19 +456,14 @@ function vvClearRightLog() {
.then(() => vvFetchRight())
.catch(() => {});
}
function vvToggleSugBlock(bi) {
const body = document.getElementById('vv-sug-body-' + bi);
const chevron = body.closest('.vv-sug-block').querySelector('.vv-sug-chevron');
const open = body.style.display !== 'none';
body.style.display = open ? 'none' : 'block';
chevron.textContent = open ? '▸' : '▾';
}
</script>
<?php
// Auto-open the most recently run script on page load
$lastId = null; $lastMtime = 0;
foreach ($tree as $orch) {
$log = vv_job_log_path($orch['id']);
if (file_exists($log) && ($m = filemtime($log)) > $lastMtime) { $lastMtime = $m; $lastId = $orch['id']; }
foreach (($orch['children'] ?? []) as $child) {
$log = vv_job_log_path($child['id']);
if (file_exists($log) && ($m = filemtime($log)) > $lastMtime) { $lastMtime = $m; $lastId = $child['id']; }
}
}
if ($lastId): ?>
<script>vvOpenRight(<?= json_encode($lastId) ?>);</script>
<?php endif; ?>
<script>requestAnimationFrame(vvFitRight);</script>