Files
Varaverk/Plugin/usr/local/emhttp/plugins/varaverk/pages/scheduler.php
T
Gmer4Lfe dfb462a18e Varaverk: suggested cron hints left of every cron input
- vv_script_children() now calls vv_script_suggested_cron() per child,
  adding suggested_cron and suggested_label to each child entry
- Orch rows (non-event) and regular script child rows render a faint
  .vv-cron-hint span to the left of the cron input showing the suggested
  cron from the script's # Schedule: header
- Clicking the hint fills in the cron input (data-cron + onclick)
- Tooltip shows full "suggested_cron — label" string on hover
- .vv-cron-hint: color #444, brightens to #888 on hover
2026-05-24 22:59:33 -04:00

1079 lines
51 KiB
PHP

<?php
require_once dirname(__DIR__) . '/include/scheduler.php';
$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 = [];
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">
<div id="vv-sched-layout">
<!-- ── Left: script cards ── -->
<div id="vv-sched-left">
<div id="vv-sched-cards">
<?php foreach ($tree as $orch): $oid = htmlspecialchars($orch['id']); ?>
<div class="vv-card vv-wide vv-sched-card" data-id="<?= $oid ?>">
<div class="vv-job-row vv-orch-row">
<label class="vv-toggle" title="Enable/disable">
<input type="checkbox" class="vv-enabled"
<?= $orch['enabled'] ? 'checked' : '' ?>
onchange="vvSaveOrch(this)">
<span class="vv-slider"></span>
</label>
<span class="vv-job-label"><?= htmlspecialchars($orch['label']) ?></span>
<?php if ($orch['type'] === 'event'): ?>
<span class="vv-event-badge"><?= $orch['cron'] === '@array_start' ? '⚡ Array Start' : '⚡ Array Stop' ?></span>
<input type="hidden" class="vv-cron" value="<?= htmlspecialchars($orch['cron']) ?>">
<?php else: ?>
<?php if ($orch['suggested_cron']): ?>
<span class="vv-cron-hint"
data-cron="<?= htmlspecialchars($orch['suggested_cron']) ?>"
onclick="this.nextElementSibling.value=this.dataset.cron"
title="Suggested: <?= htmlspecialchars($orch['suggested_cron'] . ($orch['suggested_label'] ? ' — ' . $orch['suggested_label'] : '')) ?>">
<?= htmlspecialchars($orch['suggested_cron']) ?>
</span>
<?php endif; ?>
<input type="text" class="vv-cron" value="<?= htmlspecialchars($orch['cron']) ?>"
placeholder="cron expression">
<?php endif; ?>
<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>
<?php endif; ?>
<div class="vv-job-actions">
<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>
<?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' : '' ?>
onchange="vvSaveJob(this)">
<span>Verbose</span>
</label>
<span class="vv-job-dot"></span>
<?php if (!empty($orch['children'])): ?>
<button class="vv-advanced-toggle" style="margin-left:auto;width:110px" 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']); ?>
<?php if (($child['type'] ?? 'script') === 'conf_flag'): ?>
<div class="vv-script" data-id="<?= $cid ?>"
data-type="conf_flag"
data-flag-name="<?= htmlspecialchars($child['flag_name']) ?>"
data-flag-value="<?= !empty($child['flag_value']) ? '1' : '0' ?>">
<div class="vv-job-row">
<label class="vv-toggle" title="Toggle <?= htmlspecialchars($child['flag_name']) ?> in master.conf">
<input type="checkbox" class="vv-enabled"
<?= !empty($child['flag_value']) ? 'checked' : '' ?>
onchange="vvSaveChild(this)">
<span class="vv-slider"></span>
</label>
<span class="vv-job-label"><?= htmlspecialchars($child['label']) ?></span>
<span class="vv-flag-badge"><?= htmlspecialchars($child['flag_name']) ?></span>
<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>
<?php endif; ?>
<div class="vv-job-actions">
<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>
<?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>
<span class="vv-job-dot"></span>
</div>
</div>
<?php else: ?>
<div class="vv-script" data-id="<?= $cid ?>"
data-conf-managed="<?= $child['conf_managed'] ? '1' : '0' ?>"
data-conf-enabled="<?= $child['conf_enabled'] === true ? '1' : ($child['conf_enabled'] === false ? '0' : '') ?>">
<div class="vv-job-row">
<label class="vv-toggle" title="Enable/disable">
<input type="checkbox" class="vv-enabled"
<?= $child['enabled'] ? 'checked' : '' ?>
onchange="vvSaveChild(this)">
<span class="vv-slider"></span>
</label>
<span class="vv-job-label"><?= htmlspecialchars($child['label']) ?></span>
<?php if (!empty($child['suggested_cron'])): ?>
<span class="vv-cron-hint"
data-cron="<?= htmlspecialchars($child['suggested_cron']) ?>"
onclick="this.nextElementSibling.value=this.dataset.cron"
title="Suggested: <?= htmlspecialchars($child['suggested_cron'] . (!empty($child['suggested_label']) ? ' — ' . $child['suggested_label'] : '')) ?>">
<?= htmlspecialchars($child['suggested_cron']) ?>
</span>
<?php endif; ?>
<input type="text" class="vv-cron" value="<?= htmlspecialchars($child['cron']) ?>"
placeholder="cron (orch off only)">
<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>
<?php endif; ?>
<div class="vv-job-actions">
<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>
<?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' : '' ?>
onchange="vvSaveJob(this)">
<span>Verbose</span>
</label>
<span class="vv-job-dot"></span>
</div>
</div>
<?php endif; ?>
<?php endforeach; ?>
</div>
<?php endif; ?>
</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)">&#9654; Run</button>
<button class="vv-btn-sm vv-dry-btn" onclick="vvDryRun(this)">&#9654; 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 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">&#128465; Delete</button>
<span id="vv-save-all-status" class="vv-save-status"></span>
</div>
</div><!-- /#vv-sched-left -->
<!-- ── 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;">
<button id="vv-back-btn" class="vv-btn-sm" onclick="vvBackToSuggestions()" style="display:none">&#9776; Scheduler Info</button>
<button id="vv-restore-btn" class="vv-btn-sm" onclick="vvRestoreLastLog()" style="display:none">&#8592; <span id="vv-restore-label"></span></button>
<span id="vv-log-title" style="font-size:13px;font-weight:bold;color:#ccc;">Scheduler Information</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-auto-scroll-label" class="vv-log-label" title="Auto-scroll to newest line (pauses when you scroll up, resumes at bottom)" style="display:none">
<input type="checkbox" id="vv-auto-scroll" checked> <span>Auto Scroll</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="vvToggleInvert(this)"> <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-stop-btn" class="vv-btn-sm" onclick="vvStopJob()" style="display:none" title="Stop running script and clear any stuck locks">■ Stop</button>
<button id="vv-clear-btn" class="vv-btn-sm" onclick="vvClearRightLog()" style="display:none">Clear</button>
</div>
</div>
<!-- Suggestions view (default) -->
<div id="vv-suggestions">
<!-- ── Static info: Controls ── -->
<div class="vv-sug-block vv-info-block">
<div class="vv-sug-header" onclick="vvToggleSug(this)">
<span class="vv-sug-chevron">▾</span>
<span class="vv-sug-title">Controls</span>
</div>
<div class="vv-sug-body vv-info-body">
<ul class="vv-info-cols">
<li><strong>Toggle</strong> — saves to schedule.json immediately</li>
<li><strong>Cron</strong> — 5-field expression; hit <strong>Save Schedule</strong> to apply all at once</li>
<li><strong>Run</strong> — fires script immediately regardless of schedule</li>
<li><strong>Dry Run</strong> — same as Run with <code>DRY_RUN=1</code></li>
<li><strong>Log</strong> — opens script log in this panel; auto-scrolls to latest</li>
<li><strong>Stop</strong> — sends SIGTERM to process group → 3 s → SIGKILL; clears stuck locks</li>
<li><strong>Verbose</strong> — appends <code>--log</code> for detailed per-step output</li>
<li><strong>Config</strong> — edit host-scoped settings for scripts that support it</li>
</ul>
</div>
</div>
<!-- ── Static info: Orchestrators ── -->
<div class="vv-sug-block vv-info-block">
<div class="vv-sug-header" onclick="vvToggleSug(this)">
<span class="vv-sug-chevron">▾</span>
<span class="vv-sug-title">Orchestrators</span>
</div>
<div class="vv-sug-body vv-info-body">
<ul class="vv-info-cols">
<li><strong>Enabled (ON)</strong> — orchestrator is the sole trigger; its cron fires it and it calls all children in order</li>
<li><strong>Disabled (OFF)</strong> — orchestrator never runs; all children switch off and crons are suppressed</li>
<li><strong>Child toggles (orch ON)</strong> — comment or uncomment the script's line in master.conf <code>*_SCRIPTS</code> array</li>
<li><strong>Child cron (orch ON)</strong> — ignored; orch is the only trigger while it is enabled</li>
<li><strong>Child cron (orch OFF)</strong> — enable a child and give it a cron to run it on its own schedule, independent of the orch</li>
<li><strong>⚡ Array Start / Stop</strong> — triggered by Unraid array events; no cron field; toggle enables or disables the handler</li>
</ul>
</div>
</div>
<!-- ── Static info: Children & Advanced ── -->
<div class="vv-sug-block vv-info-block">
<div class="vv-sug-header" onclick="vvToggleSug(this)">
<span class="vv-sug-chevron">▾</span>
<span class="vv-sug-title">Children &amp; Advanced</span>
</div>
<div class="vv-sug-body vv-info-body">
<ul class="vv-info-cols">
<li><strong>Advanced ▸</strong> — expand to see child scripts managed by the orchestrator</li>
<li><strong>Toggle (orch ON)</strong> — comments or uncomments the script entry in master.conf; determines whether the orch calls it</li>
<li><strong>Toggle (orch OFF)</strong> — has no effect without a cron; pair with a cron expression to run standalone</li>
<li><strong>Cron (orch OFF)</strong> — child runs on its own schedule; completely independent of the parent orchestrator</li>
<li><strong>Rsync child</strong> — shows amber <code>TIER_RSYNC_ENABLED</code> badge; toggle writes <code>true</code> / <code>false</code> directly to master.conf — controls whether rsync runs inside that orchestrator</li>
<li><strong>Rsync (orch OFF)</strong> — toggle is disabled; rsync flag only matters when the orchestrator is running</li>
</ul>
</div>
</div>
<div class="vv-info-divider">Suggested Schedules</div>
<?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>
<!-- 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;
foreach ($tree as $orch) {
$totalJobs++;
if (!empty($orch['cron']) && $orch['enabled']) $scheduledJobs++;
foreach (($orch['children'] ?? []) as $child) {
$totalJobs++;
if (!empty($child['cron']) && $child['enabled']) $scheduledJobs++;
}
}
$runningScripts = [];
exec('pgrep -af bash 2>/dev/null', $psLines);
foreach ($psLines as $line) {
if (preg_match('#' . preg_quote(SCRIPTS_DIR, '#') . '/([^\s]+\.sh)#', $line, $rm)) {
$runningScripts[] = basename($rm[1], '.sh');
}
}
$runningScripts = array_unique($runningScripts);
?>
<div class="vv-sched-footer vv-sched-info">
<span><?= $scheduledJobs ?> of <?= $totalJobs ?> job<?= $totalJobs !== 1 ? 's' : '' ?> scheduled</span>
<span><?= !empty($runningScripts) ? 'Currently Running: ' . htmlspecialchars(implode(', ', $runningScripts)) : 'Currently Running scripts: none' ?></span>
</div>
</div>
</div><!-- /#vv-sched-layout -->
</div>
<script>
let vvActiveId = null;
let vvRunningSet = new Set();
let vvPollTimer = null;
let vvStatusTimer = null;
let vvEditorId = null;
let vvConfId = null;
function vvPost(url, data) {
const params = new URLSearchParams({csrf_token, ...data});
return fetch(url, {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: params
}).then(r => r.json());
}
function vvFitRight() {
const right = document.getElementById('vv-sched-right');
if (!right.classList.contains('vv-panel-visible')) return;
const left = document.getElementById('vv-sched-left');
const lf = left.querySelector('.vv-sched-footer');
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');
const leftRect = left.getBoundingClientRect();
const rightRect = right.getBoundingClientRect();
rf.style.height = lf.offsetHeight + 'px';
right.style.height = Math.round(leftRect.bottom - rightRect.top) + 'px';
const contentH = Math.max(80, lf.getBoundingClientRect().top - 32 - toolbar.getBoundingClientRect().bottom);
if (pre.style.display !== 'none') {
pre.style.maxHeight = 'none';
pre.style.height = contentH + 'px';
}
if (sug.style.display !== 'none') {
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-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-invert-log-label').style.display = '';
document.getElementById('vv-stop-btn').style.display = '';
document.getElementById('vv-auto-scroll').checked = true;
const _pre = document.getElementById('vv-log-pre');
_pre.removeEventListener('scroll', vvOnLogScroll);
_pre.addEventListener('scroll', vvOnLogScroll);
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 vvOnLogScroll() {
const pre = document.getElementById('vv-log-pre');
const atBottom = pre.scrollHeight - pre.scrollTop - pre.clientHeight < 20;
const cb = document.getElementById('vv-auto-scroll');
if (cb) cb.checked = atBottom;
}
function vvToggleInvert(cb) {
localStorage.setItem('vv-invert-log', cb.checked ? '1' : '0');
vvFetchRight();
}
function vvRestoreInvert() {
const val = localStorage.getItem('vv-invert-log');
if (val !== null) document.getElementById('vv-invert-log').checked = val === '1';
}
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; }
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-invert-log-label').style.display = 'none';
document.getElementById('vv-stop-btn').style.display = 'none';
document.getElementById('vv-auto-scroll').checked = true;
const _preBack = document.getElementById('vv-log-pre');
_preBack.removeEventListener('scroll', vvOnLogScroll);
_preBack.style.overflowY = '';
document.getElementById('vv-log-title').textContent = 'Scheduler Information';
document.getElementById('vv-log-ts').textContent = '';
document.getElementById('vv-log-dot').style.display = 'none';
requestAnimationFrame(vvFitRight);
}
function vvOpenRight(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 = 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');
vvShowLogMode(id);
vvSetStopBtn(vvRunningSet.has(id));
requestAnimationFrame(vvFitRight);
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');
const autoScroll = document.getElementById('vv-auto-scroll').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 || '';
vvSetLogBtnState(vvActiveId, content.trim().length > 0);
pre.textContent = content.trim() ? (invert ? content.split('\n').reverse().join('\n') : content) : '(no log yet)';
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() : '';
})
.catch(() => {});
}
function vvSetStopBtn(running) {
const btn = document.getElementById('vv-stop-btn');
if (!btn || btn.style.display === 'none') return;
btn.disabled = !running;
btn.style.background = running ? '#c62828' : '#444';
btn.style.color = running ? '#fff' : '#888';
btn.style.cursor = running ? 'pointer' : 'default';
}
function vvSetDot(id) {
const job = document.querySelector('[data-id="' + CSS.escape(id) + '"]');
if (job) job.querySelector('.vv-job-dot').classList.add('vv-dot-running');
if (id === vvActiveId) {
document.getElementById('vv-log-dot').style.display = 'inline-block';
vvSetStopBtn(true);
}
}
function vvClearDot(id) {
const job = document.querySelector('[data-id="' + CSS.escape(id) + '"]');
if (job) job.querySelector('.vv-job-dot').classList.remove('vv-dot-running');
if (id === vvActiveId) {
document.getElementById('vv-log-dot').style.display = 'none';
vvSetStopBtn(false);
}
}
function vvStopJob() {
if (!vvActiveId || !vvRunningSet.has(vvActiveId)) return;
const btn = document.getElementById('vv-stop-btn');
btn.disabled = true;
btn.textContent = '…';
vvPost('/plugins/varaverk/api/stop.php', {id: vvActiveId})
.then(d => {
btn.textContent = '■ Stop';
if (d.ok) {
vvRunningSet.delete(vvActiveId);
vvClearDot(vvActiveId);
vvFetchRight();
} else {
btn.disabled = false;
vvSetStopBtn(true);
}
})
.catch(() => { btn.textContent = '■ Stop'; btn.disabled = false; vvSetStopBtn(true); });
}
function vvPollStatus() {
fetch('/plugins/varaverk/api/status.php')
.then(r => r.json())
.then(statuses => {
const nowRunning = new Set(
Object.entries(statuses).filter(([, s]) => s === 'running').map(([id]) => id)
);
for (const id of vvRunningSet) {
if (!nowRunning.has(id)) { vvClearDot(id); if (id === vvActiveId) vvFetchRight(); }
}
for (const id of nowRunning) {
if (!vvRunningSet.has(id)) { vvSetDot(id); if (id === vvActiveId) vvFetchRight(); }
}
vvRunningSet = nowRunning;
})
.catch(() => {});
}
function vvStartStatusPoll() {
if (vvStatusTimer) return;
vvPollStatus();
vvStatusTimer = setInterval(vvPollStatus, 3000);
}
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);
vvSetDot(id);
vvRunningSet.add(id);
vvPost('/plugins/varaverk/api/run.php', {id})
.then(d => {
if (!d.ok) {
document.getElementById('vv-log-pre').textContent = '✗ ' + (d.error ?? 'Failed to start');
vvClearDot(id);
vvRunningSet.delete(id);
}
});
}
function vvDryRun(btn) {
const job = btn.closest('[data-id]');
const id = job.dataset.id;
vvOpenRight(id);
vvSetDot(id);
vvRunningSet.add(id);
vvPost('/plugins/varaverk/api/dryrun.php', {id})
.then(d => {
if (!d.ok) {
document.getElementById('vv-log-pre').textContent = '✗ ' + (d.error ?? 'Failed to start');
vvClearDot(id);
vvRunningSet.delete(id);
}
});
}
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);
}
// Generic save — used for log_enabled toggles and custom scripts.
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})
.then(d => { if (d.ok) vvFlashSaved(job); });
}
// Orch toggle: saves orch state and propagates to children.
function vvSaveOrch(el) {
const card = el.closest('[data-id]');
const id = card.dataset.id;
const enabled = el.checked;
const cron = card.querySelector('.vv-orch-row .vv-cron')?.value.trim() ?? '';
const log_e = card.querySelector('.vv-log-enabled')?.checked ? '1' : '0';
vvPost('/plugins/varaverk/api/scheduler.php', {id, enabled: enabled ? '1' : '0', cron, log_enabled: log_e})
.then(d => { if (d.ok) vvFlashSaved(card); });
vvApplyOrchState(card, enabled);
}
// Apply visual + interactive state to children based on whether orch is on or off.
function vvApplyOrchState(orchCard, orchEnabled) {
orchCard.querySelectorAll('.vv-script').forEach(child => {
const toggle = child.querySelector('.vv-enabled');
const cronInput = child.querySelector('input.vv-cron');
// conf_flag children (e.g. Rsync): toggle reflects real flag value, enabled only when orch is on
if (child.dataset.type === 'conf_flag') {
toggle.checked = child.dataset.flagValue === '1';
toggle.disabled = !orchEnabled;
if (cronInput) { cronInput.disabled = true; cronInput.style.opacity = '0.35'; }
return;
}
const confManaged = child.dataset.confManaged === '1';
const confEnabled = child.dataset.confEnabled === '1';
if (orchEnabled) {
// Orch is god: set toggle from master.conf state; dim independent cron
toggle.checked = confManaged ? confEnabled : false;
toggle.disabled = !confManaged;
if (cronInput) { cronInput.disabled = true; cronInput.style.opacity = '0.35'; }
} else {
// Orch off: all children switch off; cron field becomes active
toggle.checked = false;
toggle.disabled = false;
if (cronInput) { cronInput.disabled = false; cronInput.style.opacity = ''; }
// Persist the off state to schedule.json so cron rebuild reflects it
const cid = child.dataset.id;
const ccron = cronInput?.value.trim() ?? '';
const clog = child.querySelector('.vv-log-enabled')?.checked ? '1' : '0';
vvPost('/plugins/varaverk/api/scheduler.php', {id: cid, enabled: '0', cron: ccron, log_enabled: clog});
}
});
}
// Child toggle: conf_toggle when orch is on; scheduler save when orch is off.
// conf_flag children always write directly to master.conf regardless of orch state.
function vvSaveChild(el) {
const child = el.closest('[data-id]');
const orchCard = child.closest('.vv-sched-card');
const orchOn = orchCard?.querySelector('.vv-orch-row .vv-enabled')?.checked ?? false;
const id = child.dataset.id;
const enabled = el.checked;
if (child.dataset.type === 'conf_flag') {
const name = child.dataset.flagName;
child.dataset.flagValue = enabled ? '1' : '0';
vvPost('/plugins/varaverk/api/flag_toggle.php', {name, enabled: enabled ? '1' : '0'})
.then(d => { if (d.ok) vvFlashSaved(child); });
return;
}
if (orchOn) {
vvPost('/plugins/varaverk/api/conf_toggle.php', {id, enabled: enabled ? '1' : '0'})
.then(d => { if (d.ok) vvFlashSaved(child); });
} else {
const cron = child.querySelector('input.vv-cron')?.value.trim() ?? '';
const log_e = child.querySelector('.vv-log-enabled')?.checked ? '1' : '0';
vvPost('/plugins/varaverk/api/scheduler.php', {id, enabled: enabled ? '1' : '0', cron, log_enabled: log_e})
.then(d => { if (d.ok) vvFlashSaved(child); });
}
}
function vvSaveAll() {
const status = document.getElementById('vv-save-all-status');
// Collect jobs to save: orchs always; children only when their orch is OFF (orch manages them when on).
// conf_flag children are always immediate-save (flag_toggle.php), never batch-saved.
const toSave = [];
document.querySelectorAll('#vv-sched-left [data-id]').forEach(job => {
if (job.classList.contains('vv-script')) {
if (job.dataset.type === 'conf_flag') return; // immediate-save only
const orchCard = job.closest('.vv-sched-card');
const orchOn = orchCard?.querySelector('.vv-orch-row .vv-enabled')?.checked ?? false;
if (orchOn) return; // child under active orch — cron suppressed, skip
}
toSave.push(job);
});
let pending = toSave.length, allOk = true;
if (!pending) { vvFlashStatus(status, '✓ Saved', true); return; }
status.textContent = 'Saving…';
toSave.forEach(job => {
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})
.then(d => {
if (d.ok) vvFlashSaved(job); else allOk = false;
if (--pending === 0) vvFlashStatus(status, allOk ? '✓ All saved' : '✗ Some failed', allOk);
});
});
}
function vvToggleAdvanced(btn) {
const card = btn.closest('.vv-sched-card');
const children = card.querySelector('.vv-children');
const visible = children.style.display !== 'none';
children.style.display = visible ? 'none' : 'block';
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(() => { 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-stop-btn').style.display = 'none';
document.getElementById('vv-auto-scroll-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-stop-btn').style.display = 'none';
document.getElementById('vv-auto-scroll-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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
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');
const open = body.style.display !== 'none';
body.style.display = open ? 'none' : 'block';
chevron.textContent = open ? '▸' : '▾';
}
function vvToggleSug(header) {
const body = header.nextElementSibling;
const chevron = header.querySelector('.vv-sug-chevron');
const open = body.style.display !== 'none';
body.style.display = open ? 'none' : '';
chevron.textContent = open ? '▸' : '▾';
}
</script>
<script>
// On page load: apply orch state for any orch that is already enabled.
document.querySelectorAll('.vv-sched-card').forEach(card => {
const orchOn = card.querySelector('.vv-orch-row .vv-enabled')?.checked ?? false;
if (orchOn) vvApplyOrchState(card, true);
});
requestAnimationFrame(function() {
vvRestoreInvert();
const last = localStorage.getItem('vv-last-job');
if (last) {
const lname = last.replace(/\.sh$/, '').split('/').pop();
document.getElementById('vv-restore-label').textContent = lname;
document.getElementById('vv-restore-btn').style.display = '';
}
vvFitRight();
vvStartStatusPoll();
});
</script>