Files
Varaverk/Plugin/usr/local/emhttp/plugins/varaverk/pages/scheduler.php
T
Gmer4Lfe 6078af0dbb Varaverk: arrange mode, folder management, rsync standalone, layout fixes
Scheduler UI:
- Arrange mode: drag scripts between orchs and reorder within arrays; right
  panel shows unassigned script pool; Save Arrangement commits to master.conf
- + Folder: named collapsible subfolders for Custom Scripts stored in schedule.json
- Rsync children: hide Run/Dry Run/Log/location when orch is ON; show standalone
  location + cron controls when orch is OFF; cron only fires when both filled
- Non-conf-managed children (transcode): toggles now show enabled when orch is on
- Right panel height sync: fix ResizeObserver feedback loop via align-self:flex-start
  on left panel and left.offsetHeight in vvFitRight
- How do I use this: updated to cover arrange, folders, rsync standalone, transcode

New API endpoints:
- board.php, clearlock.php, movescript.php, rawconf.php, readscript.php
- reorderarray.php, rsync_standalone.php, savefolders.php

run.php / dryrun.php: accept optional --location= arg for standalone rsync calls
2026-05-25 21:46:12 -04:00

2706 lines
126 KiB
PHP

<?php
require_once dirname(__DIR__) . '/include/scheduler.php';
$tree = vv_job_tree();
$customs = vv_custom_scripts();
$_library = vv_script_library();
$_folders = vv_folders_load();
// Full README and Manual for collapsed accordion panels
$readmePath = SCRIPTS_DIR . '/README.md';
$manualPath = SCRIPTS_DIR . '/Manual.md';
$readmeText = file_exists($readmePath) ? file_get_contents($readmePath) : '';
$manualText = file_exists($manualPath) ? file_get_contents($manualPath) : '';
// Repository tree: all .sh + .md files in git folder order, minus Plugin/
$_repoFiles = [];
$_repoBase = rtrim(SCRIPTS_DIR, '/') . '/';
$_repoSkip = ['Plugin', '.git'];
try {
$_ri = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator(SCRIPTS_DIR, RecursiveDirectoryIterator::SKIP_DOTS)
);
foreach ($_ri as $_rf) {
if (!$_rf->isFile()) continue;
if (!preg_match('/\.(sh|md)$/i', $_rf->getFilename())) continue;
$_rel = ltrim(str_replace($_repoBase, '', $_rf->getPathname()), '/');
$_parts = explode('/', $_rel);
if (in_array($_parts[0], $_repoSkip)) continue;
$_repoFiles[] = $_rel;
}
} catch (Exception $_re) {}
sort($_repoFiles);
// Docs tree: README and Manual with their per-module children
$_mainReadme = in_array('README.md', $_repoFiles) ? 'README.md' : null;
$_childReadmes = array_values(array_filter($_repoFiles, function($f) {
return preg_match('/^README/i', basename($f)) && $f !== 'README.md';
}));
sort($_childReadmes);
$_mainManual = in_array('Manual.md', $_repoFiles) ? 'Manual.md' : null;
$_childManuals = array_values(array_filter($_repoFiles, function($f) {
return preg_match('/^Manual/i', basename($f)) && $f !== 'Manual.md';
}));
sort($_childManuals);
// Job stats for notification board
$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 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 ?>" data-conf-arrays="<?= htmlspecialchars(implode(',', $orch['conf_arrays'] ?? [])) ?>">
<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" onclick="vvClickLabel(this)" style="cursor:pointer"><?= 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: ?>
<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 $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" data-conf-arrays="<?= htmlspecialchars(implode(',', $orch['conf_arrays'] ?? [])) ?>" style="display:none;">
<?php foreach ($orch['children'] as $child): $cid = htmlspecialchars($child['id']); ?>
<?php if (($child['type'] ?? 'script') === 'conf_flag'):
$_rsync = vv_rsync_standalone($child['flag_name']);
$_rsyncFn = htmlspecialchars($child['flag_name']);
$_rsyncOid = htmlspecialchars($orch['id']);
?>
<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>
<input type="text" class="vv-cron vv-rsync-standalone"
value="<?= htmlspecialchars($_rsync['cron']) ?>"
placeholder="standalone cron">
<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 vv-rsync-standalone" onclick="vvRunJob(this)">&#9654; Run</button>
<button class="vv-btn-sm vv-dry-btn vv-rsync-standalone" 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' : '' ?> vv-rsync-standalone" onclick="vvSelectLog(this)">Log</button>
<input type="text" class="vv-rsync-location vv-rsync-standalone"
value="<?= htmlspecialchars($_rsync['location']) ?>"
placeholder="/mnt/user/path"
data-flag="<?= $_rsyncFn ?>"
data-orch-id="<?= $_rsyncOid ?>">
<button class="vv-btn-sm vv-rsync-save-btn vv-rsync-standalone"
onclick="vvSaveRsyncStandalone(this)">Save</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' : '') ?>"
data-conf-array="<?= htmlspecialchars($child['conf_array'] ?? '') ?>">
<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" onclick="vvClickLabel(this)" style="cursor:pointer"><?= htmlspecialchars($child['label']) ?></span>
<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 $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 -->
<?php
// Build set of IDs that belong to a folder
$_inFolder = [];
foreach ($_folders as $_fn => $_fs) foreach ($_fs as $_fid) $_inFolder[] = $_fid;
?>
<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" id="vv-custom-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 // ── Folder groups ──────────────────────────────────────────────
foreach ($_folders as $_fname => $_fscripts):
$_folderScripts = array_filter($customs, fn($c) => in_array($c['id'], $_fscripts));
?>
<div class="vv-folder-group" data-folder="<?= htmlspecialchars($_fname) ?>">
<div class="vv-folder-row" onclick="vvToggleFolder(this)">
<span class="vv-folder-chevron">▸</span>
<span class="vv-folder-name"><?= htmlspecialchars($_fname) ?></span>
<span class="vv-folder-count"><?= count($_folderScripts) ?></span>
</div>
<div class="vv-folder-children" style="display:none">
<?php foreach ($_folderScripts 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" onclick="vvClickLabel(this)" style="cursor:pointer"><?= 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"><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; ?>
</div>
</div>
<?php endforeach; ?>
<?php // ── Unfoldered scripts ──────────────────────────────────────
foreach ($customs as $cs):
if (in_array($cs['id'], $_inFolder)) continue;
$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" onclick="vvClickLabel(this)" style="cursor:pointer"><?= 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 class="vv-save-btn vv-new-folder-btn" onclick="vvNewFolder()" title="Create a folder in Custom Scripts">+ Folder</button>
<button id="vv-arrange-btn" class="vv-save-btn" onclick="vvToggleArrange()" title="Drag scripts between orchestrators">Arrange</button>
<button id="vv-arrange-save-btn" class="vv-save-btn vv-arrange-save-btn" onclick="vvSaveArrange()" style="display:none">Save Arrangement</button>
<button id="vv-arrange-cancel-btn" class="vv-save-btn vv-delete-btn" onclick="vvCancelArrange()" style="display:none">Cancel</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>
<button id="vv-save-rawconf-btn" class="vv-btn-sm vv-save-script-btn-style" onclick="vvSaveRawConf()" style="display:none">Save Conf</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>
<button id="vv-advanced-mode-btn" class="vv-btn-sm vv-adv-mode-btn" onclick="vvToggleAdvancedMode()" title="Toggle Advanced mode — shows full script source and raw conf editing">Advanced</button>
</div>
</div>
<!-- Suggestions view (default) -->
<div id="vv-suggestions">
<!-- ── Notification Board ── -->
<div class="vv-nb-board">
<span class="vv-nb-stat"><?= $scheduledJobs ?> / <?= $totalJobs ?> scheduled</span>
<span class="vv-nb-sep">·</span>
<span class="vv-nb-stat <?= !empty($runningScripts) ? 'vv-nb-running' : '' ?>">
<?= !empty($runningScripts) ? 'Running: ' . htmlspecialchars(implode(', ', $runningScripts)) : 'Idle' ?>
</span>
<span class="vv-nb-sep vv-adv-only" style="display:none">·</span>
<?php $confFiles = vv_get_conf_files(); ?>
<?php if (!empty($confFiles)): ?>
<span class="vv-nb-conf-btns vv-adv-only" style="display:none">
<?php foreach ($confFiles as $cf): ?>
<button class="vv-btn-sm vv-nb-conf-btn" onclick="vvEditRawConf('<?= htmlspecialchars($cf) ?>')"><?= htmlspecialchars($cf) ?></button>
<?php endforeach; ?>
</span>
<?php endif; ?>
</div>
<!-- Plugin settings row (Advanced mode only) -->
<div class="vv-nb-settings vv-adv-only" style="display:none">
<span style="color:#555;font-size:11px;white-space:nowrap;">Scripts dir:</span>
<input type="text" id="vv-scripts-dir" value="<?= htmlspecialchars(SCRIPTS_DIR) ?>"
style="flex:1;min-width:180px;background:#111;border:1px solid #333;color:#aaa;
padding:2px 6px;border-radius:3px;font-family:monospace;font-size:11px;">
<button type="button" onclick="vvSaveSettings()"
style="padding:2px 10px;background:#252525;border:1px solid #444;color:#aaa;
border-radius:3px;cursor:pointer;font-size:11px;white-space:nowrap;">Save</button>
<span id="vv-settings-status" style="font-size:11px;color:#666;"></span>
</div>
<!-- ── How do I use this (pinned) ── -->
<div class="vv-sug-block vv-info-block" id="vv-how-to-use">
<div class="vv-sug-header" onclick="vvToggleSug(this)">
<span class="vv-sug-chevron">▾</span>
<span class="vv-sug-title">How do I use this</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> — SIGTERM → 3 s → SIGKILL; clears stuck locks</li>
<li><strong>Verbose</strong> — appends <code>--log</code> for detailed per-step output</li>
<li><strong>Orch ON</strong> — sole trigger; cron fires it; it calls children in order</li>
<li><strong>Orch OFF</strong> — never runs; all children suppressed and get individual cron fields</li>
<li><strong>Child (orch ON)</strong> — toggle comments/uncomments the script's line in master.conf</li>
<li><strong>Child (orch ON, no array)</strong> — orch hardcodes the call; toggle shows active but is display-only</li>
<li><strong>Child (orch OFF)</strong> — give it its own cron to run standalone</li>
<li><strong>⚡ Array events</strong> — triggered by Unraid array start/stop; no cron field</li>
<li><strong>Rsync badge</strong> — writes TIER_RSYNC_ENABLED flag to master.conf directly</li>
<li><strong>Rsync (orch ON)</strong> — Run/Dry Run/Log hidden; orch controls the rsync call</li>
<li><strong>Rsync (orch OFF)</strong> — enter a <em>location</em> path and standalone <em>cron</em>, then Save; both must be filled for the cron to fire rsync independently</li>
<li><strong>Arrange</strong> — drag scripts between orchs or reorder within an orch; right panel shows unassigned scripts that can be dragged in; drag to the right panel removes from orch</li>
<li><strong>Save Arrangement</strong> — commits all moves and reorders to master.conf</li>
<li><strong>+ Folder</strong> — creates a named collapsible subfolder in Custom Scripts; scripts can be dragged into folders</li>
<li><strong>Script name</strong> — click opens settings (non-Advanced) or full source editor (Advanced)</li>
<li><strong>Suggested cron</strong> — click the cron code in the Scripts panel to apply &amp; save it to that card instantly</li>
<li><strong>Advanced</strong> — top-right button; turns blue when on; script-name click shows full editable source; conf file buttons appear in the status bar</li>
<li><strong>← Scheduler Info</strong> — back button returns here from any log, editor, or script view</li>
</ul>
</div>
</div>
<!-- ── Next Runs ── -->
<div class="vv-sug-block" data-save-key="next-runs">
<div class="vv-sug-header" onclick="vvToggleSug(this)">
<span class="vv-sug-chevron">▾</span>
<span class="vv-sug-title">Next Runs</span>
</div>
<div class="vv-sug-body" id="vv-nextruns-body">
<div class="vv-board-placeholder">calculating…</div>
</div>
</div>
<!-- ── Recent Errors ── -->
<div class="vv-sug-block" data-save-key="errors">
<div class="vv-sug-header" onclick="vvToggleSug(this)">
<span class="vv-sug-chevron">▸</span>
<span class="vv-sug-title">Recent Errors</span>
<span id="vv-errors-badge" class="vv-hdr-badge vv-hdr-badge-red" style="display:none"></span>
</div>
<div class="vv-sug-body" style="display:none" id="vv-errors-body">
<div class="vv-board-placeholder">loading…</div>
</div>
</div>
<!-- ── Active Locks ── -->
<div class="vv-sug-block" data-save-key="locks">
<div class="vv-sug-header" onclick="vvToggleSug(this)">
<span class="vv-sug-chevron">▸</span>
<span class="vv-sug-title">Active Locks</span>
<span id="vv-locks-badge" class="vv-hdr-badge vv-hdr-badge-orange" style="display:none"></span>
</div>
<div class="vv-sug-body" style="display:none" id="vv-locks-body">
<div class="vv-board-placeholder">loading…</div>
</div>
</div>
<!-- ── Partner ── -->
<div class="vv-sug-block" data-save-key="partner">
<div class="vv-sug-header" onclick="vvToggleSug(this)">
<span class="vv-sug-chevron">▸</span>
<span class="vv-sug-title">Partner</span>
<span id="vv-partner-hdr" style="font-size:11px;color:#555;flex-shrink:0;">—</span>
</div>
<div class="vv-sug-body" style="display:none" id="vv-partner-body">
<div class="vv-board-placeholder">loading…</div>
</div>
</div>
<!-- ── Disabled on this host ── -->
<?php
$_confMap = vv_conf_script_map();
$_disabled = array_filter($_confMap, fn($v) => $v['enabled'] === false);
?>
<div class="vv-sug-block" data-save-key="disabled">
<div class="vv-sug-header" onclick="vvToggleSug(this)">
<span class="vv-sug-chevron">▸</span>
<span class="vv-sug-title">Disabled on this host</span>
<?php if (!empty($_disabled)): ?>
<span class="vv-hdr-badge vv-hdr-badge-gray"><?= count($_disabled) ?></span>
<?php endif; ?>
</div>
<div class="vv-sug-body" style="display:none">
<?php if (empty($_disabled)): ?>
<div class="vv-board-placeholder">All scripts enabled.</div>
<?php else: ?>
<div class="vv-disabled-list">
<?php foreach ($_disabled as $_drel => $_dv):
$_dname = str_replace('_', ' ', strtolower(preg_replace('/_SCRIPTS$/', '', $_dv['array'] ?? '')));
?>
<div class="vv-disabled-row">
<span class="vv-disabled-name"><?= htmlspecialchars(basename($_drel, '.sh')) ?></span>
<span class="vv-disabled-grp"><?= htmlspecialchars($_dname) ?></span>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
</div>
<!-- ── Fallback ── -->
<div class="vv-sug-block vv-info-block" data-save-key="fallback">
<div class="vv-sug-header" onclick="vvToggleSug(this)">
<span class="vv-sug-chevron">▾</span>
<span class="vv-sug-title">Fallback</span>
</div>
<div class="vv-sug-body">
<pre class="vv-readme-body">Not enterprise HA. No SLAs, no quorum nodes, no guaranteed uptime — don't put your billing system on this.
What it is: mutual automatic failover between two independent unRAID servers. When one goes down the other starts its containers, cuts over DNS, and keeps users online. When it comes back, everything hands back in the correct sequence — DDNS stops, containers stop, rsync writeback runs, containers start on the primary, primary DDNS starts last — so users hit the returning server only after it's actually ready.
Built from scratch. Refined through a year of production testing. The DDNS sequencing and handback order were the hardest parts to get right. Both directions are exercised regularly with fallback_test.sh.</pre>
</div>
</div>
<!-- ── How it works ── -->
<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">How it works</span>
</div>
<div class="vv-sug-body" style="display:none">
<pre class="vv-readme-body">Varaverk owns the cron layer. schedule.json stores every orchestrator's enabled state, cron expression, and log setting. When you save, the plugin rebuilds /etc/cron.d/varaverk — one entry per enabled orchestrator. That's all cron knows about.
Orchestrators are the only scripts with cron schedules. Each one reads its SCRIPTS list out of master.conf and runs each child in sequence. The children do the actual work. The orchestrator controls the order and the timing. A child failure stops the chain.
master.conf is the per-host control plane. Comment out a line to disable that script on this host without touching any code. Both servers run the same repository — different master.conf comments produce different behaviour per machine.
Children only get their own cron when their orchestrator is OFF. When the orch is on, it owns the timing completely.
common.sh provides the shared runtime every script sources: acquire_lock(), log_msg(), info(), success(), warn(), error(). Lock files live in /tmp/varaverk_locks/. acquire_lock() exits cleanly if a prior run is still in progress — nothing piles up.
Logs are written per script to the appdata path. The Log button shows the latest run. Verbose mode passes --log for per-step detail.
Array events bypass cron entirely. @array_start fires when Unraid brings the array up; @array_stop fires before shutdown. array_started handles all post-start setup in sequence; array_stopping handles the graceful teardown.
The watchdog tier runs every 15 minutes as a single-pass chain: resource_watchdog frees memory pressure first, docker_watchdog restarts down containers into that headroom, system_watchdog checks storage/WebGUI/network, stability_watchdog reboots as a last resort. The order matters — restarting containers before freeing memory just restarts them into the same pressure.</pre>
</div>
</div>
<!-- ── This started as ── -->
<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">This started as...</span>
</div>
<div class="vv-sug-body" style="display:none">
<pre class="vv-readme-body">One script. A few lines — checking whether a container was running and restarting it if not. Backgrounded through User Scripts, sleeping a minute between checks, essentially forgotten. It did its job.
Then a conversation with a buddy: wouldn't it be cool if it emailed when something crashed? Sure, added it. Wouldn't it be cool if it handled more containers? Easy — grow the list. Wouldn't it be cool if both servers stayed in sync automatically so neither of us had to remember to do it manually?
That one took a while.
Order started to matter. Freeing memory before attempting container restarts meant the containers actually stayed up. Sync had to happen before the media database refresh, not after. Watchdogs couldn't block each other. The big loop that tried to do everything started breaking into pieces — one piece per job, and a sequencer that knew which piece went first.
master.conf became the control plane: one file, two different sets of comments for two different hosts. Same codebase, different behaviour per machine. The orchestrator pattern came out of not wanting to duplicate logic.
By the time the User Scripts plugin couldn't clearly show what was actually scheduled, whether something was running, or why a sync had quietly stopped working two weeks ago — the interface became the next thing to build.
Varaverk started as a status table and a schedule editor. The scheduler became the main thing. The monitor followed.
Still the same two servers, two households, the same media stack running itself. The scripts just grew up around it.</pre>
</div>
</div>
<!-- ── Docs tree ── -->
<div class="vv-info-divider">Docs</div>
<div id="vv-docs-tree">
<?php if ($_mainReadme || !empty($_childReadmes)): ?>
<div class="vv-sb-entry">
<div class="vv-sb-row vv-sb-orch-row"
data-id="README.md"
data-hdr=""
data-desc="Project README"
onclick="vvSelectScript(this)">
<?php if (!empty($_childReadmes)): ?>
<span class="vv-sb-expand" onclick="vvToggleSbChildren(this,event)">▸</span>
<?php else: ?>
<span class="vv-sb-expand vv-sb-leaf"> </span>
<?php endif; ?>
<span class="vv-sb-name">README</span>
</div>
<?php if (!empty($_childReadmes)): ?>
<div class="vv-sb-children" style="display:none">
<?php foreach ($_childReadmes as $_cr):
$_crLabel = preg_replace('/^README-?/i', '', basename($_cr));
$_crLabel = preg_replace('/\.md$/i', '', $_crLabel);
$_crLabel = str_replace('_', ' ', $_crLabel) ?: dirname($_cr);
?>
<div class="vv-sb-row vv-sb-child-row"
data-id="<?= htmlspecialchars($_cr) ?>"
data-hdr=""
data-desc=""
onclick="vvSelectScript(this)">
<span class="vv-sb-child-indent">└</span>
<span class="vv-sb-name"><?= htmlspecialchars($_crLabel) ?></span>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
<?php endif; ?>
<?php if ($_mainManual || !empty($_childManuals)): ?>
<div class="vv-sb-entry">
<div class="vv-sb-row vv-sb-orch-row"
data-id="Manual.md"
data-hdr=""
data-desc="Project Manual"
onclick="vvSelectScript(this)">
<?php if (!empty($_childManuals)): ?>
<span class="vv-sb-expand" onclick="vvToggleSbChildren(this,event)">▸</span>
<?php else: ?>
<span class="vv-sb-expand vv-sb-leaf"> </span>
<?php endif; ?>
<span class="vv-sb-name">Manual</span>
</div>
<?php if (!empty($_childManuals)): ?>
<div class="vv-sb-children" style="display:none">
<?php foreach ($_childManuals as $_cm):
$_cmLabel = preg_replace('/^Manual-?/i', '', basename($_cm));
$_cmLabel = preg_replace('/\.md$/i', '', $_cmLabel);
$_cmLabel = str_replace('_', ' ', $_cmLabel) ?: dirname($_cm);
?>
<div class="vv-sb-row vv-sb-child-row"
data-id="<?= htmlspecialchars($_cm) ?>"
data-hdr=""
data-desc=""
onclick="vvSelectScript(this)">
<span class="vv-sb-child-indent">└</span>
<span class="vv-sb-name"><?= htmlspecialchars($_cmLabel) ?></span>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
<?php endif; ?>
</div><!-- /#vv-docs-tree -->
<!-- ── Script browser tree ── -->
<div class="vv-info-divider">Orch</div>
<div id="vv-sb-tree">
<?php foreach ($tree as $orch):
$orchId = $orch['id'];
$orchHdr = vv_script_header_clean(SCRIPTS_DIR . '/' . $orchId);
$hasKids = !empty($orch['children']);
?>
<div class="vv-sb-entry">
<div class="vv-sb-row vv-sb-orch-row"
data-id="<?= htmlspecialchars($orchId) ?>"
data-hdr="<?= htmlspecialchars($orchHdr) ?>"
data-desc="<?= htmlspecialchars($orch['desc']) ?>"
onclick="vvSelectScript(this)">
<?php if ($hasKids): ?>
<span class="vv-sb-expand" onclick="vvToggleSbChildren(this,event)">▸</span>
<?php else: ?>
<span class="vv-sb-expand vv-sb-leaf"> </span>
<?php endif; ?>
<span class="vv-sb-name"><?= htmlspecialchars($orch['label']) ?></span>
<?php if ($orch['type'] === 'event'): ?>
<span class="vv-event-badge vv-sb-badge"><?= $orch['cron'] === '@array_start' ? '⚡' : '⚡' ?></span>
<?php elseif ($orch['suggested_cron']): ?>
<code class="vv-sug-cron vv-sb-cron" style="cursor:pointer" title="Click to apply cron" onclick="vvApplySugCron(this,event)"><?= htmlspecialchars($orch['suggested_cron']) ?></code>
<?php endif; ?>
<span class="vv-sug-status <?= $orch['enabled'] ? 'vv-sug-on' : 'vv-sug-off' ?> vv-sb-status">
<?= $orch['enabled'] ? '●' : '○' ?>
</span>
</div>
<?php if ($hasKids): ?>
<div class="vv-sb-children" style="display:none">
<?php foreach ($orch['children'] as $child):
$childHdr = vv_script_header_clean(SCRIPTS_DIR . '/' . $child['id']);
?>
<div class="vv-sb-row vv-sb-child-row"
data-id="<?= htmlspecialchars($child['id']) ?>"
data-hdr="<?= htmlspecialchars($childHdr) ?>"
data-desc="<?= htmlspecialchars($child['desc']) ?>"
onclick="vvSelectScript(this)">
<span class="vv-sb-child-indent">└</span>
<span class="vv-sb-name"><?= htmlspecialchars($child['label']) ?></span>
<?php if (!empty($child['suggested_cron'])): ?>
<code class="vv-sug-cron vv-sb-cron" style="cursor:pointer" title="Click to apply cron" onclick="vvApplySugCron(this,event)"><?= htmlspecialchars($child['suggested_cron']) ?></code>
<?php endif; ?>
<span class="vv-sug-status <?= !empty($child['conf_enabled']) ? 'vv-sug-on' : 'vv-sug-off' ?> vv-sb-status">
<?= !empty($child['conf_enabled']) ? '●' : '○' ?>
</span>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div><!-- /#vv-sb-tree -->
<!-- ── Repository separator + tree (Advanced mode only) ── -->
<div class="vv-info-divider vv-adv-only" style="display:none;margin-top:8px;">Repository</div>
<?php if (!empty($_repoFiles)): ?>
<div class="vv-sug-block vv-info-block vv-adv-only" style="display:none;margin-top:4px;">
<div class="vv-sug-header" onclick="vvToggleSug(this)">
<span class="vv-sug-chevron">▸</span>
<span class="vv-sug-title">Varaverk</span>
</div>
<div class="vv-sug-body" style="display:none;padding:0;">
<?php
$_rootFiles = array_values(array_filter($_repoFiles, function($f) { return strpos($f, '/') === false; }));
$_subFiles = array_values(array_filter($_repoFiles, function($f) { return strpos($f, '/') !== false; }));
// Root-level files under Varaverk/ divider
if (!empty($_rootFiles)):
?>
<div class="vv-info-divider" style="padding-left:8px;font-size:10px;">Varaverk/</div>
<?php foreach ($_rootFiles as $_rel):
$_isScript = (bool)preg_match('/\.sh$/i', $_rel);
$_hdr = $_isScript ? vv_script_header_clean(SCRIPTS_DIR . '/' . $_rel) : '';
?>
<div class="vv-sb-row"
data-id="<?= htmlspecialchars($_rel) ?>"
data-hdr="<?= htmlspecialchars($_hdr) ?>"
data-desc=""
onclick="vvSelectScript(this)"
style="padding-left:18px">
<span class="vv-sb-expand vv-sb-leaf"> </span>
<span class="vv-sb-name" style="color:<?= $_isScript ? '#b0c4d0' : '#7a9eb5' ?>;font-size:12px;">
<?= htmlspecialchars($_rel) ?>
</span>
</div>
<?php endforeach; endif; ?>
<?php
// Subdirectory files
$_seenDirs = [];
foreach ($_subFiles as $_rel):
$_parts = explode('/', $_rel);
$_dirParts = array_slice($_parts, 0, -1);
$_filename = end($_parts);
$_isScript = (bool)preg_match('/\.sh$/i', $_filename);
for ($_di = 0; $_di < count($_dirParts); $_di++):
$_dirPath = implode('/', array_slice($_dirParts, 0, $_di + 1));
if (!in_array($_dirPath, $_seenDirs)):
$_seenDirs[] = $_dirPath;
?>
<div class="vv-info-divider" style="padding-left:<?= $_di * 14 + 8 ?>px;font-size:10px;">
<?= htmlspecialchars($_dirParts[$_di]) ?>/
</div>
<?php
endif;
endfor;
$_depth = count($_dirParts);
$_hdr = $_isScript ? vv_script_header_clean(SCRIPTS_DIR . '/' . $_rel) : '';
?>
<div class="vv-sb-row"
data-id="<?= htmlspecialchars($_rel) ?>"
data-hdr="<?= htmlspecialchars($_hdr) ?>"
data-desc=""
onclick="vvSelectScript(this)"
style="padding-left:<?= $_depth * 14 + 4 ?>px">
<span class="vv-sb-expand vv-sb-leaf"> </span>
<span class="vv-sb-name" style="color:<?= $_isScript ? '#b0c4d0' : '#7a9eb5' ?>;font-size:12px;">
<?= htmlspecialchars($_filename) ?>
</span>
</div>
<?php endforeach; ?>
</div>
</div>
<?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>
<!-- Script info view (header or full source for selected tree row) -->
<div id="vv-si-view" style="display:none; overflow-y:auto;"></div>
<!-- Editor view (custom scripts + raw conf) -->
<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>
<div id="vv-editor-wrap">
<pre id="vv-hl-overlay" aria-hidden="true"></pre>
<textarea id="vv-editor-body" class="vv-editor-body" spellcheck="false"
oninput="vvSyncHlOverlay()" onscroll="vvSyncHlScroll()"></textarea>
</div>
</div>
<!-- Arrange workspace (shown when arrange mode is active) -->
<div id="vv-arrange-workspace" style="display:none; overflow-y:auto; padding:8px 12px;">
<div class="vv-arrange-ws-hdr">
<span>Unassigned Scripts</span>
<span id="vv-pending-badge" style="display:none"></span>
</div>
<div id="vv-arrange-pending" style="display:none; margin-bottom:10px;">
<div class="vv-arrange-pending-hdr">Pending Changes</div>
<div id="vv-pending-list"></div>
</div>
<div id="vv-library-drop-zone" class="vv-library-zone"
ondragover="event.preventDefault(); this.classList.add('vv-drop-target')"
ondragleave="if(!this.contains(event.relatedTarget)) this.classList.remove('vv-drop-target')"
ondrop="vvDropToLibrary(event, this)">
<div class="vv-arrange-drop-hint">Drop here to remove from any orchestrator</div>
<div id="vv-library-cards">
<?php foreach ($_library as $_lib): ?>
<div class="vv-lib-card"
data-script="<?= htmlspecialchars($_lib['id']) ?>"
draggable="true"
ondragstart="vvLibDragStart(event, this)"
ondragend="vvDragEnd(event, this)">
<span class="vv-lib-card-name"><?= htmlspecialchars($_lib['label']) ?></span>
<span class="vv-lib-card-path"><?= htmlspecialchars(dirname($_lib['id'])) ?></span>
</div>
<?php endforeach; ?>
<?php if (empty($_library)): ?>
<div class="vv-board-placeholder">All scripts are assigned.</div>
<?php endif; ?>
</div>
</div>
</div>
</div>
<div class="vv-sched-footer vv-sched-info" id="vv-sched-info-footer">
<span id="vv-footer-scheduled"><?= $scheduledJobs ?> of <?= $totalJobs ?> job<?= $totalJobs !== 1 ? 's' : '' ?> scheduled</span>
<span id="vv-footer-running"><?= !empty($runningScripts) ? 'Running: ' . htmlspecialchars(implode(', ', $runningScripts)) : 'Idle' ?></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;
let vvSelectedRow = null;
let vvCurrentSiId = null;
let vvCurrentSiHdr = null;
function vvEscHtml(s) {
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
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 vvSaveSettings() {
const dir = document.getElementById('vv-scripts-dir').value.trim();
const status = document.getElementById('vv-settings-status');
if (!dir) { status.textContent = '✗ Path required'; return; }
status.textContent = 'Saving…';
vvPost('/plugins/varaverk/api/settings.php', {scripts_dir: dir})
.then(d => { status.textContent = d.ok ? '✓ Saved — reload to apply' : '✗ ' + (d.error ?? 'Error'); })
.catch(() => { status.textContent = '✗ Request failed'; });
}
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();
// Stacked layout (narrow viewport) — right panel is below left; don't force height
if (rightRect.top > leftRect.bottom + 4) {
right.style.height = '';
return;
}
rf.style.height = lf.offsetHeight + 'px';
right.style.height = left.offsetHeight + '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';
}
const si = document.getElementById('vv-si-view');
if (si && si.style.display !== 'none') {
si.style.height = contentH + 'px';
}
}
window.addEventListener('resize', vvFitRight);
// Whenever the left panel changes height (expand/collapse), sync the right panel immediately.
new ResizeObserver(vvFitRight).observe(document.getElementById('vv-sched-left'));
function vvShowLogMode(id) {
document.getElementById('vv-suggestions').style.display = 'none';
document.getElementById('vv-editor').style.display = 'none';
document.getElementById('vv-si-view').style.display = 'none';
document.getElementById('vv-arrange-workspace').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-save-rawconf-btn').style.display = 'none';
document.getElementById('vv-advanced-mode-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-si-view').style.display = 'none';
document.getElementById('vv-arrange-workspace').style.display = 'none';
document.getElementById('vv-suggestions').style.display = '';
document.getElementById('vv-back-btn').style.display = 'none';
// Clear script info selection
if (vvSelectedRow) { vvSelectedRow.classList.remove('vv-sb-selected'); vvSelectedRow = null; }
vvCurrentSiId = null;
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-save-rawconf-btn').style.display = 'none';
const _advBtn = document.getElementById('vv-advanced-mode-btn');
_advBtn.style.display = '';
_advBtn.classList.toggle('vv-adv-mode-on', vvAdvancedMode);
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;
const location = job.querySelector('.vv-rsync-location')?.value.trim() || '';
vvOpenRight(id);
vvSetDot(id);
vvRunningSet.add(id);
const data = location ? {id, location} : {id};
vvPost('/plugins/varaverk/api/run.php', data)
.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;
const location = job.querySelector('.vv-rsync-location')?.value.trim() || '';
vvOpenRight(id);
vvSetDot(id);
vvRunningSet.add(id);
const data = location ? {id, location} : {id};
vvPost('/plugins/varaverk/api/dryrun.php', data)
.then(d => {
if (!d.ok) {
document.getElementById('vv-log-pre').textContent = '✗ ' + (d.error ?? 'Failed to start');
vvClearDot(id);
vvRunningSet.delete(id);
}
});
}
function vvSaveRsyncStandalone(btn) {
const job = btn.closest('[data-id]');
const locInput = job.querySelector('.vv-rsync-location');
const cronEl = job.querySelector('.vv-cron.vv-rsync-standalone');
const flagName = locInput?.dataset.flag || '';
const orchId = locInput?.dataset.orchId || '';
if (!flagName) return;
const location = locInput?.value.trim() || '';
const cron = cronEl?.value.trim() || '';
btn.disabled = true;
btn.textContent = 'Saving…';
vvPost('/plugins/varaverk/api/rsync_standalone.php', {flag_name: flagName, orch_id: orchId, location, cron})
.then(d => {
btn.disabled = false;
if (d.ok) {
btn.textContent = 'Save';
vvFlashSaved(job);
} else {
btn.textContent = 'Error!';
setTimeout(() => btn.textContent = 'Save', 2000);
}
})
.catch(() => { btn.disabled = false; btn.textContent = 'Error!'; setTimeout(() => btn.textContent = 'Save', 2000); });
}
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; standalone controls visible only when orch is off
if (child.dataset.type === 'conf_flag') {
toggle.checked = child.dataset.flagValue === '1';
toggle.disabled = !orchEnabled;
// Show standalone controls (Run/Dry Run/Log/location/cron/save) only when orch is off
child.querySelectorAll('.vv-rsync-standalone').forEach(el => {
el.style.display = orchEnabled ? 'none' : '';
});
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.
// If not conf-managed (no *_SCRIPTS array, e.g. transcode), orch hardcodes the call —
// show as enabled so the user sees the script is active.
toggle.checked = confManaged ? confEnabled : true;
toggle.disabled = false;
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-editor').classList.remove('vv-editor-hl');
document.getElementById('vv-suggestions').style.display = 'none';
document.getElementById('vv-log-pre').style.display = 'none';
document.getElementById('vv-si-view').style.display = 'none';
document.getElementById('vv-arrange-workspace').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-save-rawconf-btn').style.display = 'none';
document.getElementById('vv-advanced-mode-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-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;
}
if (!confirm('Save changes to "' + name + '.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-si-view').style.display = 'none';
document.getElementById('vv-arrange-workspace').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-save-rawconf-btn').style.display = 'none';
document.getElementById('vv-advanced-mode-btn').style.display = 'none';
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 _cfName = vvConfId.replace(/\.sh$/, '').split('/').pop();
if (!confirm('Save configuration changes for "' + _cfName + '"?')) 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'; });
}
// vvToggleSugBlock: used by script browser blocks (header element passed as arg)
function vvToggleSugBlock(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 ? '▸' : '▾';
// When opening a script browser block in advanced mode, load full content
if (!open && vvAdvancedMode) vvLoadScriptFull(body);
}
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 ? '▸' : '▾';
const block = header.closest('[data-save-key]');
if (block) localStorage.setItem('vv-sug-' + block.dataset.saveKey, open ? '0' : '1');
}
function vvRestoreSugStates() {
document.querySelectorAll('[data-save-key]').forEach(block => {
const saved = localStorage.getItem('vv-sug-' + block.dataset.saveKey);
if (saved === null) return;
const body = block.querySelector('.vv-sug-body');
const chevron = block.querySelector('.vv-sug-chevron');
if (!body) return;
const open = saved === '1';
body.style.display = open ? '' : 'none';
if (chevron) chevron.textContent = open ? '▾' : '▸';
});
}
// Suggested cron click in script browser tree: apply to the matching card cron input and save
function vvApplySugCron(el, event) {
event.stopPropagation();
const cron = el.textContent.trim();
const row = el.closest('[data-id]');
const id = row.dataset.id;
const jobEl = document.querySelector('#vv-sched-left [data-id="' + CSS.escape(id) + '"]');
if (!jobEl) return;
const cronInput = jobEl.querySelector('input.vv-cron');
if (!cronInput || cronInput.disabled) {
el.style.color = '#f44336';
setTimeout(() => { el.style.color = ''; }, 1200);
return;
}
cronInput.value = cron;
el.style.color = '#4caf50';
setTimeout(() => { el.style.color = ''; }, 1500);
const enabled = jobEl.querySelector('.vv-enabled')?.checked ? '1' : '0';
const log_enabled = jobEl.querySelector('.vv-log-enabled')?.checked ? '1' : '0';
vvPost('/plugins/varaverk/api/scheduler.php', {id, enabled, cron, log_enabled})
.then(d => { if (d.ok) vvFlashSaved(jobEl); });
}
// Script name label click: non-advanced → conf settings; advanced → full script/editor
function vvClickLabel(el) {
const job = el.closest('[data-id]');
const id = job.dataset.id;
if (vvAdvancedMode) {
if (id.startsWith('Custom/')) {
vvEditScript(id);
} else {
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; }
if (vvSelectedRow) { vvSelectedRow.classList.remove('vv-sb-selected'); vvSelectedRow = null; }
vvCurrentSiId = id;
vvCurrentSiHdr = '';
vvShowScriptInfoMode(id.replace(/\.sh$/, '').split('/').pop(), '', id);
}
} else {
vvEditConf(id);
}
}
// ── Advanced mode toggle ──────────────────────────────────────────────────────
let vvAdvancedMode = false;
let vvRawConfFile = null;
function vvToggleAdvancedMode() {
vvAdvancedMode = !vvAdvancedMode;
localStorage.setItem('vv-advanced-mode', vvAdvancedMode ? '1' : '0');
const btn = document.getElementById('vv-advanced-mode-btn');
btn.classList.toggle('vv-adv-mode-on', vvAdvancedMode);
document.querySelectorAll('.vv-adv-only').forEach(el => el.style.display = vvAdvancedMode ? '' : 'none');
// If si-view is open, refresh it with the new mode
if (vvCurrentSiId && document.getElementById('vv-si-view').style.display !== 'none') {
const name = vvCurrentSiId.replace(/\.sh$/, '').split('/').pop();
vvShowScriptInfoMode(name, vvCurrentSiHdr, vvCurrentSiId);
}
}
// Load full script content into .vv-sb-full elements that still show "(loading…)"
function vvLoadScriptFull(body) {
body.querySelectorAll('.vv-sb-full').forEach(el => {
if (el.dataset.loaded) return;
el.dataset.loaded = '1';
const id = el.dataset.srcId;
fetch('/plugins/varaverk/api/readscript.php?id=' + encodeURIComponent(id))
.then(r => r.json())
.then(d => { el.innerHTML = d.ok ? vvHl(d.content, false) : '<span class="vv-hl-comment"># Error loading script</span>'; })
.catch(() => { el.textContent = '# Load failed'; });
});
}
// ── Board: Next Runs, Errors, Locks, Partner ─────────────────────────────
let vvBoardTimer = null;
// Minimal cron parser — returns next Date after now, or null if unparseable.
function vvCronNext(expr) {
if (!expr || /^@/.test(expr)) return null;
const parts = expr.trim().split(/\s+/);
if (parts.length !== 5) return null;
const [mF, hF, dF, moF, wF] = parts;
const matches = (field, val) => {
if (field === '*') return true;
if (/^\*\/\d+$/.test(field)) return val % parseInt(field.slice(2)) === 0;
if (/^\d+$/.test(field)) return parseInt(field) === val;
if (/^\d+-\d+$/.test(field)) { const [a,b] = field.split('-').map(Number); return val >= a && val <= b; }
if (field.includes(',')) return field.split(',').some(f => parseInt(f) === val);
return false;
};
const t = new Date();
t.setSeconds(0, 0);
t.setMinutes(t.getMinutes() + 1);
for (let i = 0; i < 10080; i++, t.setMinutes(t.getMinutes() + 1)) {
if (matches(moF, t.getMonth()+1) && matches(dF, t.getDate()) &&
matches(wF, t.getDay()) && matches(hF, t.getHours()) &&
matches(mF, t.getMinutes())) return new Date(t);
}
return null;
}
function vvFmtDuration(ms) {
const s = Math.floor(ms / 1000);
if (s < 60) return s + 's';
if (s < 3600) return Math.floor(s/60) + 'm ' + (s%60) + 's';
const h = Math.floor(s/3600), m = Math.floor((s%3600)/60);
return h + 'h' + (m ? ' ' + m + 'm' : '');
}
function vvFmtAge(seconds) {
if (seconds < 60) return seconds + 's ago';
if (seconds < 3600) return Math.floor(seconds/60) + 'm ago';
if (seconds < 86400) return Math.floor(seconds/3600) + 'h ago';
return Math.floor(seconds/86400) + 'd ago';
}
function vvBuildNextRuns() {
const body = document.getElementById('vv-nextruns-body');
if (!body) return;
const now = new Date();
const rows = [];
document.querySelectorAll('.vv-sched-card').forEach(card => {
const orchRow = card.querySelector('.vv-orch-row');
if (!orchRow) return;
const enabled = orchRow.querySelector('.vv-enabled')?.checked;
if (!enabled) return;
const cronEl = orchRow.querySelector('.vv-cron');
const cron = cronEl?.value.trim() ?? '';
if (!cron || cron.startsWith('@')) return;
const label = orchRow.querySelector('.vv-job-label')?.textContent.trim() ?? card.dataset.id;
const next = vvCronNext(cron);
if (next) rows.push({ label, cron, next, diffMs: next - now });
});
// Custom scripts with their own cron
document.querySelectorAll('.vv-custom-card .vv-script').forEach(script => {
const enabled = script.querySelector('.vv-enabled')?.checked;
if (!enabled) return;
const cronEl = script.querySelector('input.vv-cron');
const cron = cronEl?.value.trim() ?? '';
if (!cron) return;
const label = script.querySelector('.vv-job-label')?.textContent.trim() ?? script.dataset.id;
const next = vvCronNext(cron);
if (next) rows.push({ label, cron, next, diffMs: next - now });
});
if (!rows.length) {
body.innerHTML = '<div class="vv-board-placeholder">No enabled scheduled jobs.</div>';
return;
}
rows.sort((a,b) => a.diffMs - b.diffMs);
let html = '<div class="vv-nextrun-list">';
for (const r of rows) {
const atStr = r.next.toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'});
html += '<div class="vv-nextrun-row">'
+ '<span class="vv-nr-label">' + vvEscHtml(r.label) + '</span>'
+ '<span class="vv-nr-cron">' + vvEscHtml(r.cron) + '</span>'
+ '<span class="vv-nr-in">in ' + vvFmtDuration(r.diffMs) + '</span>'
+ '<span class="vv-nr-at">' + atStr + '</span>'
+ '</div>';
}
body.innerHTML = html + '</div>';
}
function vvBoardPoll() {
fetch('/plugins/varaverk/api/board.php')
.then(r => r.json())
.then(d => {
if (!d.ok) return;
vvUpdateLocks(d.locks ?? []);
vvUpdateErrors(d.errors ?? []);
vvUpdatePartner(d.partner);
})
.catch(() => {});
}
function vvUpdateLocks(locks) {
const body = document.getElementById('vv-locks-body');
const badge = document.getElementById('vv-locks-badge');
if (!body) return;
badge.style.display = locks.length ? '' : 'none';
badge.textContent = locks.length || '';
if (!locks.length) {
body.innerHTML = '<div class="vv-board-placeholder">No stale locks.</div>';
return;
}
let html = '<div class="vv-locks-list">';
for (const lk of locks) {
html += '<div class="vv-lock-row">'
+ '<span class="vv-lk-name">' + vvEscHtml(lk.name) + '</span>'
+ '<span class="vv-lk-age">' + vvFmtAge(lk.age) + '</span>'
+ '<button class="vv-btn-sm vv-lock-clear" onclick="vvClearLock(\''
+ vvEscHtml(lk.file) + '\',this)">Clear</button>'
+ '</div>';
}
body.innerHTML = html + '</div>';
}
function vvUpdateErrors(errors) {
const body = document.getElementById('vv-errors-body');
const badge = document.getElementById('vv-errors-badge');
if (!body) return;
badge.style.display = errors.length ? '' : 'none';
badge.textContent = errors.length || '';
if (!errors.length) {
body.innerHTML = '<div class="vv-board-placeholder">No recent errors.</div>';
return;
}
const now = Math.floor(Date.now() / 1000);
let html = '<div class="vv-errors-list">';
for (const e of errors) {
html += '<div class="vv-err-row">'
+ '<div class="vv-err-top"><span class="vv-err-script">' + vvEscHtml(e.script) + '</span>'
+ '<span class="vv-err-age">' + vvFmtAge(now - e.ts) + '</span></div>'
+ '<div class="vv-err-line">' + vvEscHtml(e.line) + '</div>'
+ '</div>';
}
body.innerHTML = html + '</div>';
}
function vvUpdatePartner(partner) {
const hdr = document.getElementById('vv-partner-hdr');
const body = document.getElementById('vv-partner-body');
if (!hdr || !body) return;
if (!partner) {
hdr.textContent = '—';
hdr.style.color = '#555';
body.innerHTML = '<div class="vv-board-placeholder">No partner configured.</div>';
return;
}
const col = partner.reachable ? '#4caf50' : '#f44336';
hdr.style.color = col;
hdr.textContent = partner.reachable
? '● ' + partner.host + (partner.latency ? ' ' + partner.latency + 'ms' : '')
: '● ' + partner.host;
body.innerHTML = '<div class="vv-partner-row">'
+ '<span style="color:' + col + ';font-size:16px;line-height:1;">●</span>'
+ '<span class="vv-partner-name">' + vvEscHtml(partner.host) + '</span>'
+ (partner.reachable
? '<span class="vv-partner-detail">' + (partner.latency ?? '?') + ' ms</span>'
: '<span class="vv-partner-detail vv-partner-down">unreachable</span>')
+ '</div>';
}
function vvClearLock(file, btn) {
if (!confirm('Clear lock "' + file + '"?\nOnly do this if the script has crashed and the lock is stale.')) return;
btn.disabled = true; btn.textContent = '…';
vvPost('/plugins/varaverk/api/clearlock.php', {file})
.then(d => { if (d.ok) vvBoardPoll(); else { btn.disabled = false; btn.textContent = 'Clear'; }})
.catch(() => { btn.disabled = false; btn.textContent = 'Clear'; });
}
// ── Cron validation (inline red border on bad expressions) ────────────────
function vvValidateCronExpr(expr) {
if (!expr) return true;
if (/^@/.test(expr)) return true;
const parts = expr.trim().split(/\s+/);
if (parts.length !== 5) return false;
const ranges = [[0,59],[0,23],[1,31],[1,12],[0,7]];
return parts.every((f,i) => {
if (f === '*') return true;
if (/^\*\/\d+$/.test(f)) { const n=parseInt(f.slice(2)); return n>0 && n<=ranges[i][1]; }
if (/^\d+$/.test(f)) return parseInt(f)>=ranges[i][0] && parseInt(f)<=ranges[i][1];
if (/^\d+-\d+$/.test(f)) { const [a,b]=f.split('-').map(Number); return a<=b && a>=ranges[i][0] && b<=ranges[i][1]; }
if (f.includes(',')) return f.split(',').every(p=>/^\d+$/.test(p));
return false;
});
}
function vvValidateCrons() {
document.querySelectorAll('input.vv-cron').forEach(inp => {
if (inp.disabled || inp.type === 'hidden') return;
const ok = vvValidateCronExpr(inp.value.trim());
inp.style.borderColor = inp.value.trim() && !ok ? '#f44336' : '';
inp.title = ok ? '' : 'Invalid cron expression';
});
}
function vvBoardInit() {
vvBuildNextRuns();
vvBoardPoll();
vvValidateCrons();
if (!vvBoardTimer) {
vvBoardTimer = setInterval(() => { vvBuildNextRuns(); vvBoardPoll(); }, 30000);
}
// Live cron validation on input
document.querySelectorAll('input.vv-cron').forEach(inp => {
inp.addEventListener('input', () => {
const ok = vvValidateCronExpr(inp.value.trim());
inp.style.borderColor = inp.value.trim() && !ok ? '#f44336' : '';
});
});
}
// Apply syntax highlighting to all embedded header blocks
function vvApplyHighlighting() {
document.querySelectorAll('.vv-sb-hdr').forEach(el => {
const text = el.textContent;
el.innerHTML = vvHl(text, true);
});
}
// ── Bash syntax highlighter ───────────────────────────────────────────────────
function vvHl(code, isHeader) {
const esc = s => s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
return code.split('\n').map(line => isHeader ? vvHlHdr(line, esc) : vvHlSrc(line, esc)).join('\n');
}
// Header lines (# already stripped by PHP)
function vvHlHdr(line, esc) {
if (!line.trim()) return '';
const t = line.trim();
// Separator / banner lines
if (/^[─━═\-=#+\s]{4,}$/.test(t) || /^={2,}.*={2,}$/.test(t) || /^[─━]{3,}/.test(t)) {
return '<span class="vv-hl-sep">' + esc(line) + '</span>';
}
// ALL CAPS section labels (PURPOSE, DESCRIPTION, HOST, etc.)
if (/^[A-Z][A-Z_\s]{3,}:?\s*$/.test(t)) {
return '<span class="vv-hl-section">' + esc(line) + '</span>';
}
// Key: value (e.g. "Schedule: */7 * * * *", "Dependencies: common.sh")
const kv = line.match(/^(\s*)([A-Za-z][A-Za-z _\-]{2,})(\s*:\s*)(.*)/);
if (kv) {
const key = kv[2].trim();
const val = kv[4];
if (/^schedule$/i.test(key)) {
const cm = val.match(/^([@\d\/\*,\-]+ [\d\/\*,\-]+ [\d\/\*,\-]+ [\d\/\*,\-]+ [\d\/\*,\-]+|@[a-z_]+)(.*)/i);
return esc(kv[1]) + '<span class="vv-hl-key">' + esc(kv[2]) + '</span>' + esc(kv[3])
+ (cm ? '<span class="vv-hl-cron">' + esc(cm[1]) + '</span>'
+ '<span class="vv-hl-comment">' + esc(cm[2]) + '</span>'
: '<span class="vv-hl-cron">' + esc(val) + '</span>');
}
return esc(kv[1]) + '<span class="vv-hl-key">' + esc(kv[2]) + '</span>' + esc(kv[3])
+ '<span class="vv-hl-value">' + esc(val) + '</span>';
}
return '<span class="vv-hl-text">' + esc(line) + '</span>';
}
// Full source lines
function vvHlSrc(line, esc) {
if (!line.trim()) return '';
const t = line.trimStart();
// Shebang
if (t.startsWith('#!')) return '<span class="vv-hl-shebang">' + esc(line) + '</span>';
// Full-line comment
if (t.startsWith('#')) {
const m = line.match(/^(\s*)(#+\s?)(.*)/);
if (!m) return '<span class="vv-hl-comment">' + esc(line) + '</span>';
const [, indent, hash, rest] = m;
const rt = rest.trim();
// Separator
if (!rt || /^[─━═\-=#+\s]{4,}$/.test(rt) || /^={2,}.*={2,}$/.test(rt)) {
return esc(indent) + '<span class="vv-hl-sep">' + esc(hash + rest) + '</span>';
}
// ALL CAPS label
if (/^[A-Z][A-Z_\s]{3,}:?\s*$/.test(rt)) {
return esc(indent) + '<span class="vv-hl-hash">' + esc(hash) + '</span>'
+ '<span class="vv-hl-section">' + esc(rest) + '</span>';
}
// Key: value
const kv = rest.match(/^([A-Za-z][A-Za-z _\-]{2,})(\s*:\s*)(.*)/);
if (kv) {
const key = kv[1].trim();
if (/^schedule$/i.test(key)) {
const cm = kv[3].match(/^([@\d\/\*,\-]+ [\d\/\*,\-]+ [\d\/\*,\-]+ [\d\/\*,\-]+ [\d\/\*,\-]+|@[a-z_]+)(.*)/i);
return esc(indent) + '<span class="vv-hl-hash">' + esc(hash) + '</span>'
+ '<span class="vv-hl-key">' + esc(kv[1]) + '</span>' + esc(kv[2])
+ (cm ? '<span class="vv-hl-cron">' + esc(cm[1]) + '</span>'
+ '<span class="vv-hl-comment">' + esc(cm[2]) + '</span>'
: '<span class="vv-hl-cron">' + esc(kv[3]) + '</span>');
}
return esc(indent) + '<span class="vv-hl-hash">' + esc(hash) + '</span>'
+ '<span class="vv-hl-key">' + esc(kv[1]) + '</span>' + esc(kv[2])
+ '<span class="vv-hl-value">' + esc(kv[3]) + '</span>';
}
return esc(indent) + '<span class="vv-hl-comment">' + esc(hash + rest) + '</span>';
}
// Code line
return vvHlCode(line, esc);
}
// Tokenize a bash code line
const VV_KEYWORDS = new Set('if then else elif fi for do done while until case esac in function return local export declare readonly unset shift break continue exit true false'.split(' '));
const VV_BUILTINS = new Set('echo printf read cd mkdir rm cp mv find grep sed awk cat chmod chown basename dirname date which command source eval exec wait kill trap sleep'.split(' '));
function vvHlCode(line, esc) {
let result = '';
let pos = 0;
const s = line;
while (pos < s.length) {
const rest = s.slice(pos);
let m;
// Single-quoted string
if ((m = rest.match(/^'([^']*)'/))) {
result += '<span class="vv-hl-string">' + esc(m[0]) + '</span>';
pos += m[0].length; continue;
}
// Double-quoted string (no nested escape for brevity)
if ((m = rest.match(/^"[^"]*"/))) {
result += '<span class="vv-hl-string">' + esc(m[0]) + '</span>';
pos += m[0].length; continue;
}
// Variable: $((...)), $(...), ${...}, $WORD, $special
if ((m = rest.match(/^\$\(\([^)]*\)\)/s))) { result += '<span class="vv-hl-var">' + esc(m[0]) + '</span>'; pos += m[0].length; continue; }
if ((m = rest.match(/^\$\([^)]*\)/s))) { result += '<span class="vv-hl-var">' + esc(m[0]) + '</span>'; pos += m[0].length; continue; }
if ((m = rest.match(/^\$\{[^}]*\}/))) { result += '<span class="vv-hl-var">' + esc(m[0]) + '</span>'; pos += m[0].length; continue; }
if ((m = rest.match(/^\$[A-Za-z_]\w*/))) { result += '<span class="vv-hl-var">' + esc(m[0]) + '</span>'; pos += m[0].length; continue; }
if ((m = rest.match(/^\$[#@*?!0-9]/))) { result += '<span class="vv-hl-var">' + esc(m[0]) + '</span>'; pos += m[0].length; continue; }
// Word / keyword / builtin / identifier
if ((m = rest.match(/^[A-Za-z_]\w*/))) {
const word = m[0];
if (VV_KEYWORDS.has(word)) result += '<span class="vv-hl-keyword">' + esc(word) + '</span>';
else if (VV_BUILTINS.has(word)) result += '<span class="vv-hl-builtin">' + esc(word) + '</span>';
else result += esc(word);
pos += word.length; continue;
}
// Number
if ((m = rest.match(/^[0-9]+(?:\.[0-9]+)?/))) {
result += '<span class="vv-hl-number">' + esc(m[0]) + '</span>';
pos += m[0].length; continue;
}
// Operator / punctuation
if ((m = rest.match(/^[|&;<>!={}\[\]()+\-*\/\\@%^~,.:]+/))) {
result += '<span class="vv-hl-op">' + esc(m[0]) + '</span>';
pos += m[0].length; continue;
}
// Anything else (spaces, tabs, etc.)
result += esc(s[pos]);
pos++;
}
return result;
}
function vvRestoreAdvancedMode() {
const saved = localStorage.getItem('vv-advanced-mode');
if (saved === '1') {
vvAdvancedMode = true;
document.getElementById('vv-advanced-mode-btn').classList.add('vv-adv-mode-on');
document.querySelectorAll('.vv-adv-only').forEach(el => el.style.display = '');
document.querySelectorAll('.vv-sb-hdr').forEach(el => el.style.display = 'none');
// full .vv-sb-full remain hidden until their block is opened
}
}
// ── Raw conf editing ──────────────────────────────────────────────────────────
function vvEditRawConf(file) {
vvRawConfFile = file;
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-editor-name').value = file;
document.getElementById('vv-editor-name').readOnly = true;
document.getElementById('vv-editor-body').value = 'Loading…';
vvShowRawConfMode(file);
fetch('/plugins/varaverk/api/rawconf.php?file=' + encodeURIComponent(file))
.then(r => r.json())
.then(d => {
document.getElementById('vv-editor-body').value = d.ok ? d.content : '# Error loading file';
document.getElementById('vv-editor').classList.add('vv-editor-hl');
vvSyncHlOverlay();
requestAnimationFrame(vvFitRight);
})
.catch(() => { document.getElementById('vv-editor-body').value = '# Load failed'; });
}
function vvShowRawConfMode(title) {
document.getElementById('vv-suggestions').style.display = 'none';
document.getElementById('vv-log-pre').style.display = 'none';
document.getElementById('vv-si-view').style.display = 'none';
document.getElementById('vv-editor').style.display = 'flex';
document.getElementById('vv-confform').style.display = 'none';
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 = 'none';
document.getElementById('vv-save-rawconf-btn').style.display = '';
document.getElementById('vv-advanced-mode-btn').style.display = 'none';
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 vvSaveRawConf() {
if (!vvRawConfFile) return;
if (!confirm('Save changes to "' + vvRawConfFile + '"?')) return;
const content = document.getElementById('vv-editor-body').value;
const btn = document.getElementById('vv-save-rawconf-btn');
btn.disabled = true;
btn.textContent = 'Saving…';
vvPost('/plugins/varaverk/api/rawconf.php', {file: vvRawConfFile, content})
.then(d => {
btn.disabled = false;
if (d.ok) {
btn.textContent = '✓ Saved';
setTimeout(() => { btn.textContent = 'Save Conf'; }, 2500);
} else {
btn.textContent = 'Save Conf';
alert('Save failed: ' + (d.error ?? 'Unknown error'));
}
})
.catch(() => { btn.disabled = false; btn.textContent = 'Save Conf'; });
}
// ── Highlighted conf editor overlay ──────────────────────────────────────────
function vvSyncHlOverlay() {
const ov = document.getElementById('vv-hl-overlay');
if (!ov || !document.getElementById('vv-editor').classList.contains('vv-editor-hl')) return;
const ta = document.getElementById('vv-editor-body');
ov.innerHTML = vvHl(ta.value, false) + '\n';
ov.scrollTop = ta.scrollTop;
}
function vvSyncHlScroll() {
const ov = document.getElementById('vv-hl-overlay');
if (!ov || !document.getElementById('vv-editor').classList.contains('vv-editor-hl')) return;
ov.scrollTop = document.getElementById('vv-editor-body').scrollTop;
}
// ── Script browser tree ───────────────────────────────────────────────────────
function vvToggleSbChildren(expand, event) {
event.stopPropagation();
const entry = expand.closest('.vv-sb-entry');
const kids = entry.querySelector('.vv-sb-children');
if (!kids) return;
const open = kids.style.display !== 'none';
kids.style.display = open ? 'none' : '';
expand.textContent = open ? '▸' : '▾';
}
function vvSelectScript(row) {
if (vvSelectedRow) vvSelectedRow.classList.remove('vv-sb-selected');
vvSelectedRow = row;
row.classList.add('vv-sb-selected');
const id = row.dataset.id;
const hdr = row.dataset.hdr;
const name = id.replace(/\.sh$/, '').split('/').pop();
vvCurrentSiId = id;
vvCurrentSiHdr = hdr;
vvShowScriptInfoMode(name, hdr, id);
}
function vvShowScriptInfoMode(name, hdr, id) {
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-arrange-workspace').style.display = 'none';
document.getElementById('vv-confform').style.display = 'none';
document.getElementById('vv-si-view').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 = 'none';
document.getElementById('vv-save-rawconf-btn').style.display = 'none';
const _advBtn = document.getElementById('vv-advanced-mode-btn');
_advBtn.style.display = '';
_advBtn.classList.toggle('vv-adv-mode-on', vvAdvancedMode);
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 = name;
document.getElementById('vv-log-ts').textContent = '';
document.getElementById('vv-save-schedule-btn').disabled = false;
document.getElementById('vv-save-schedule-btn').style.opacity = '';
if (vvPollTimer) { clearInterval(vvPollTimer); vvPollTimer = null; }
const si = document.getElementById('vv-si-view');
const _isMd = /\.md$/i.test(id);
if (vvAdvancedMode || _isMd) {
si.innerHTML = '<pre class="vv-si-src">(loading…)</pre>';
fetch('/plugins/varaverk/api/readscript.php?id=' + encodeURIComponent(id))
.then(r => r.json())
.then(d => {
si.innerHTML = '<pre class="vv-si-src">'
+ (d.ok
? (_isMd ? vvEscHtml(d.content) : vvHl(d.content, false))
: '<span class="vv-hl-comment"># Error loading</span>')
+ '</pre>';
requestAnimationFrame(vvFitRight);
})
.catch(() => { si.innerHTML = '<pre class="vv-si-src"># Load failed</pre>'; });
} else {
si.innerHTML = '<pre class="vv-si-hdr">' + vvHl(hdr, true) + '</pre>';
}
requestAnimationFrame(vvFitRight);
}
</script>
<script>
// ── Arrange mode ─────────────────────────────────────────────────────────────
let vvArrangeMode = false;
let vvArrangePending = []; // [{script, fromArray, toArray, _undoFn}]
let vvDragScript = null;
let vvDragFromArray = null; // null = unassigned/library
let vvDragEl = null;
function vvToggleArrange() {
vvArrangeMode ? vvCancelArrange() : vvEnterArrangeMode();
}
function vvEnterArrangeMode() {
vvArrangeMode = true;
document.getElementById('vv-sched-cards').classList.add('vv-arrange-active');
// Show arrange workspace in right panel
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-si-view').style.display = 'none';
document.getElementById('vv-confform').style.display = 'none';
document.getElementById('vv-arrange-workspace').style.display = '';
document.getElementById('vv-back-btn').style.display = '';
document.getElementById('vv-restore-btn').style.display = 'none';
document.getElementById('vv-arrange-btn').textContent = 'Arranging…';
document.getElementById('vv-arrange-btn').classList.add('vv-arrange-btn-active');
document.getElementById('vv-arrange-save-btn').style.display = '';
document.getElementById('vv-arrange-cancel-btn').style.display = '';
// Expand all orch children and make them droppable
document.querySelectorAll('.vv-sched-card:not(.vv-custom-card)').forEach(card => {
const childrenDiv = card.querySelector('.vv-children');
if (!childrenDiv) return;
if (childrenDiv.style.display === 'none') {
childrenDiv.style.display = '';
childrenDiv.dataset.arrangeExpanded = '1';
}
childrenDiv.addEventListener('dragover', _vvChildDragOver);
childrenDiv.addEventListener('dragleave', _vvChildDragLeave);
childrenDiv.addEventListener('drop', _vvChildDrop);
// Add drag handles to conf-managed script children
childrenDiv.querySelectorAll('.vv-script').forEach(s => {
if (s.dataset.type === 'conf_flag' || !s.dataset.confArray) return;
s.setAttribute('draggable', 'true');
const h = document.createElement('span');
h.className = 'vv-drag-handle';
h.textContent = '⠿';
s.querySelector('.vv-job-row').prepend(h);
s.addEventListener('dragstart', _vvOrchScriptDragStart);
s.addEventListener('dragend', vvDragEnd);
});
});
// Add drag handles to custom scripts (for folder drag)
document.querySelectorAll('.vv-custom-card .vv-script').forEach(s => {
s.setAttribute('draggable', 'true');
const h = document.createElement('span');
h.className = 'vv-drag-handle';
h.textContent = '⠿';
s.querySelector('.vv-job-row').prepend(h);
s.addEventListener('dragstart', _vvCustomDragStart);
s.addEventListener('dragend', vvDragEnd);
});
// Make folder children droppable
document.querySelectorAll('.vv-folder-children').forEach(fc => {
fc.addEventListener('dragover', _vvFolderDragOver);
fc.addEventListener('dragleave', _vvFolderDragLeave);
fc.addEventListener('drop', _vvFolderDrop);
});
vvFitRight();
}
function vvExitArrangeMode() {
vvArrangeMode = false;
document.getElementById('vv-sched-cards').classList.remove('vv-arrange-active');
document.querySelectorAll('.vv-drag-handle').forEach(h => h.remove());
document.querySelectorAll('.vv-script[draggable]').forEach(s => {
s.removeAttribute('draggable');
s.removeEventListener('dragstart', _vvOrchScriptDragStart);
s.removeEventListener('dragstart', _vvCustomDragStart);
s.removeEventListener('dragend', vvDragEnd);
});
document.querySelectorAll('.vv-children[data-arrange-expanded]').forEach(c => {
c.style.display = 'none';
delete c.dataset.arrangeExpanded;
});
document.querySelectorAll('.vv-children').forEach(c => {
c.removeEventListener('dragover', _vvChildDragOver);
c.removeEventListener('dragleave', _vvChildDragLeave);
c.removeEventListener('drop', _vvChildDrop);
});
document.querySelectorAll('.vv-folder-children').forEach(fc => {
fc.removeEventListener('dragover', _vvFolderDragOver);
fc.removeEventListener('dragleave', _vvFolderDragLeave);
fc.removeEventListener('drop', _vvFolderDrop);
});
const btn = document.getElementById('vv-arrange-btn');
btn.textContent = 'Arrange';
btn.classList.remove('vv-arrange-btn-active');
document.getElementById('vv-arrange-save-btn').style.display = 'none';
document.getElementById('vv-arrange-cancel-btn').style.display = 'none';
vvBackToSuggestions();
}
function vvCancelArrange() {
vvArrangePending.forEach(p => { if (p._undoFn) p._undoFn(); });
vvArrangePending = [];
vvExitArrangeMode();
}
async function vvSaveArrange() {
if (!vvArrangePending.length) { vvExitArrangeMode(); return; }
const btn = document.getElementById('vv-arrange-save-btn');
btn.textContent = 'Saving…';
btn.disabled = true;
// Collect the final ordered state of every orch array from the DOM.
// Each .vv-children[data-conf-arrays] holds the live order; scripts that were
// moved here from other arrays already have their data-conf-array updated.
const arrayMap = new Map(); // arrayName → [{id, enabled}]
document.querySelectorAll('.vv-children[data-conf-arrays]').forEach(div => {
const primaryArray = (div.dataset.confArrays || '').split(',').filter(Boolean)[0];
if (!primaryArray) return;
const scripts = [];
div.querySelectorAll('.vv-script[data-id]').forEach(s => {
if (s.classList.contains('vv-drag-ghost')) return;
const id = s.dataset.id;
const enabled = s.dataset.confEnabled !== '0';
const arr = s.dataset.confArray || primaryArray;
// Group by target array (scripts may have been moved here from a different array)
if (!arrayMap.has(arr)) arrayMap.set(arr, []);
arrayMap.get(arr).push({ id, enabled });
});
// Mark primary array as explicitly visited (even if empty after removals)
if (!arrayMap.has(primaryArray)) arrayMap.set(primaryArray, []);
});
let failed = false;
for (const [arrayName, scripts] of arrayMap) {
const r = await vvPost('/plugins/varaverk/api/reorderarray.php', {
array_name: arrayName,
scripts: JSON.stringify(scripts)
}).then(r => r.json()).catch(() => ({ ok: false }));
if (!r.ok) { failed = true; break; }
}
if (failed) {
btn.textContent = 'Error!';
btn.style.color = '#f44336';
setTimeout(() => { btn.textContent = 'Save Arrangement'; btn.disabled = false; btn.style.color = ''; }, 2500);
return;
}
location.reload();
}
// ── Drag handlers ─────────────────────────────────────────────────────────────
function _vvOrchScriptDragStart(e) {
vvDragEl = this;
vvDragScript = this.dataset.id;
vvDragFromArray = this.dataset.confArray || null;
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', vvDragScript);
setTimeout(() => this.classList.add('vv-drag-ghost'), 0);
}
function _vvCustomDragStart(e) {
vvDragEl = this;
vvDragScript = this.dataset.id;
vvDragFromArray = null;
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', vvDragScript);
setTimeout(() => this.classList.add('vv-drag-ghost'), 0);
}
function vvLibDragStart(e, el) {
vvDragEl = el;
vvDragScript = el.dataset.script;
vvDragFromArray = null;
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', vvDragScript);
setTimeout(() => el.classList.add('vv-drag-ghost'), 0);
}
function vvDragEnd(e) {
if (vvDragEl) vvDragEl.classList.remove('vv-drag-ghost');
vvDragEl = null;
}
// Return the .vv-script element that the cursor is above the midpoint of,
// or null if cursor is below all items (meaning: append).
function _vvFindInsertBefore(container, clientY) {
const items = [...container.querySelectorAll('.vv-script:not(.vv-drag-ghost):not(.vv-drop-line)')];
for (const item of items) {
const rect = item.getBoundingClientRect();
if (clientY < rect.top + rect.height / 2) return item;
}
return null;
}
// Drop on an orch's children div → move to that orch array (with position)
function _vvChildDragOver(e) {
if (!vvDragScript || vvDragScript.startsWith('Custom/')) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
this.classList.add('vv-drop-target');
// Show insertion line indicator
let line = this.querySelector('.vv-drop-line');
if (!line) {
line = document.createElement('div');
line.className = 'vv-drop-line';
}
const insertBefore = _vvFindInsertBefore(this, e.clientY);
if (insertBefore) {
this.insertBefore(line, insertBefore);
} else {
this.appendChild(line);
}
}
function _vvChildDragLeave(e) {
if (!this.contains(e.relatedTarget)) {
this.classList.remove('vv-drop-target');
this.querySelector('.vv-drop-line')?.remove();
}
}
function _vvChildDrop(e) {
e.preventDefault();
const line = this.querySelector('.vv-drop-line');
const insertBefore = line ? line.nextElementSibling : null;
line?.remove();
this.classList.remove('vv-drop-target');
const toArrays = (this.dataset.confArrays || '').split(',').filter(Boolean);
const toArray = toArrays[0];
if (!toArray || !vvDragScript) return;
if (vvDragScript.startsWith('Custom/')) return;
const srcEl = vvDragEl;
const fromArray = vvDragFromArray;
const script = vvDragScript;
if (!srcEl) return;
const srcParent = srcEl.parentElement;
const oldArray = srcEl.dataset.confArray || fromArray;
// Library card dropped onto orch
if (!fromArray) {
_vvMoveLibCardToOrch(srcEl, this, script, toArray, insertBefore);
return;
}
// Reorder within same orch or move across — insert at position
if (insertBefore && insertBefore !== srcEl && this.contains(insertBefore)) {
this.insertBefore(srcEl, insertBefore);
} else if (!insertBefore) {
this.appendChild(srcEl);
}
// (if insertBefore === srcEl: dropped in same slot, no DOM change needed)
srcEl.dataset.confArray = toArray;
// Only record a pending entry if something actually changed
const orderChanged = srcParent !== this || oldArray !== toArray;
if (orderChanged) {
vvArrangePending.push({
script, fromArray: oldArray, toArray,
_undoFn: () => { srcParent?.appendChild(srcEl); srcEl.dataset.confArray = oldArray; }
});
vvUpdatePendingUI();
}
}
// Move a library card into an orch (dragging from unassigned pool)
function _vvMoveLibCardToOrch(libCard, destDiv, script, toArray, insertBefore = null) {
const label = script.split('/').pop().replace(/\.sh$/, '');
const row = document.createElement('div');
row.className = 'vv-script';
row.dataset.id = script;
row.dataset.confArray = toArray;
row.dataset.confEnabled = '1';
row.setAttribute('draggable', 'true');
row.innerHTML = `<div class="vv-job-row"><span class="vv-drag-handle">⠿</span><span class="vv-job-label">${vvEscHtml(label)}</span><span style="font-size:10px;color:#666;margin-left:4px;">(new)</span></div>`;
row.addEventListener('dragstart', _vvOrchScriptDragStart);
row.addEventListener('dragend', vvDragEnd);
if (insertBefore && destDiv.contains(insertBefore)) {
destDiv.insertBefore(row, insertBefore);
} else {
destDiv.appendChild(row);
}
libCard.remove();
// Remove "all assigned" placeholder if present
const ph = document.getElementById('vv-library-cards').querySelector('.vv-board-placeholder');
if (ph) ph.remove();
vvArrangePending.push({
script, fromArray: null, toArray,
_undoFn: () => {
row.remove();
const libCards = document.getElementById('vv-library-cards');
const restored = document.createElement('div');
restored.className = 'vv-lib-card';
restored.dataset.script = script;
restored.draggable = true;
const dir = script.split('/').slice(0,-1).join('/');
restored.innerHTML = `<span class="vv-lib-card-name">${vvEscHtml(label)}</span><span class="vv-lib-card-path">${vvEscHtml(dir)}</span>`;
restored.addEventListener('dragstart', function(ev){ vvLibDragStart(ev,this); });
restored.addEventListener('dragend', vvDragEnd);
libCards.appendChild(restored);
}
});
vvUpdatePendingUI();
}
// Drop on library zone → remove from orch
function vvDropToLibrary(e, el) {
e.preventDefault();
el.classList.remove('vv-drop-target');
const script = vvDragScript;
const fromArray = vvDragFromArray;
const srcEl = vvDragEl;
if (!script || !fromArray || !srcEl) return; // library→library or custom script noop
const label = script.split('/').pop().replace(/\.sh$/, '');
const dir = script.split('/').slice(0,-1).join('/');
const srcParent = srcEl.parentElement;
srcEl.remove();
// Add a lib card
const libCards = document.getElementById('vv-library-cards');
const ph = libCards.querySelector('.vv-board-placeholder');
if (ph) ph.remove();
const libCard = document.createElement('div');
libCard.className = 'vv-lib-card';
libCard.dataset.script = script;
libCard.draggable = true;
libCard.innerHTML = `<span class="vv-lib-card-name">${vvEscHtml(label)}</span><span class="vv-lib-card-path">${vvEscHtml(dir)}</span>`;
libCard.addEventListener('dragstart', function(ev){ vvLibDragStart(ev,this); });
libCard.addEventListener('dragend', vvDragEnd);
libCards.appendChild(libCard);
vvArrangePending.push({
script, fromArray, toArray: null,
_undoFn: () => {
libCard.remove();
srcParent.appendChild(srcEl);
srcEl.dataset.confArray = fromArray;
}
});
vvUpdatePendingUI();
}
// Folder drag handlers (custom scripts only)
function _vvFolderDragOver(e) {
if (!vvDragScript || !vvDragScript.startsWith('Custom/')) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
this.classList.add('vv-drop-target');
}
function _vvFolderDragLeave(e) {
if (!this.contains(e.relatedTarget)) this.classList.remove('vv-drop-target');
}
async function _vvFolderDrop(e) {
e.preventDefault();
this.classList.remove('vv-drop-target');
const script = vvDragScript;
const srcEl = vvDragEl;
if (!script || !script.startsWith('Custom/') || !srcEl) return;
const oldParent = srcEl.parentElement;
this.appendChild(srcEl);
const folders = vvGetCurrentFolders();
const r = await vvPost('/plugins/varaverk/api/savefolders.php', {
folders: JSON.stringify(folders)
}).then(r => r.json()).catch(() => ({ ok: false }));
if (!r.ok) {
oldParent.appendChild(srcEl);
alert('Failed to save folder assignment.');
}
}
function vvUpdatePendingUI() {
const count = vvArrangePending.length;
const badge = document.getElementById('vv-pending-badge');
const pending = document.getElementById('vv-arrange-pending');
const list = document.getElementById('vv-pending-list');
badge.textContent = `${count} pending`;
badge.style.display = count > 0 ? '' : 'none';
pending.style.display = count > 0 ? '' : 'none';
list.innerHTML = vvArrangePending.map(p => {
const label = p.script.split('/').pop().replace(/\.sh$/, '');
const from = p.fromArray ? p.fromArray.replace(/_SCRIPTS$/, '') : 'unassigned';
const to = p.toArray ? p.toArray.replace(/_SCRIPTS$/, '') : 'unassigned';
return `<div class="vv-pending-row"><span class="vv-pending-script">${vvEscHtml(label)}</span><span class="vv-pending-arrow">${vvEscHtml(from)} → ${vvEscHtml(to)}</span></div>`;
}).join('');
}
// ── Custom script folders ─────────────────────────────────────────────────────
function vvToggleFolder(header) {
const body = header.nextElementSibling;
const chevron = header.querySelector('.vv-folder-chevron');
if (!body) return;
const open = body.style.display === 'none';
body.style.display = open ? '' : 'none';
if (chevron) chevron.textContent = open ? '▾' : '▸';
}
function vvGetCurrentFolders() {
const folders = {};
document.querySelectorAll('.vv-folder-group').forEach(fg => {
const name = fg.dataset.folder;
folders[name] = [];
fg.querySelectorAll('.vv-folder-children .vv-script[data-id]').forEach(s => {
folders[name].push(s.dataset.id);
});
});
return folders;
}
function vvNewFolder() {
const customChildren = document.getElementById('vv-custom-children');
if (!customChildren) return;
// Expand if collapsed
if (customChildren.style.display === 'none') customChildren.style.display = '';
if (customChildren.querySelector('.vv-folder-new-row')) return;
const wrap = document.createElement('div');
wrap.className = 'vv-folder-new-row';
const inp = document.createElement('input');
inp.type = 'text';
inp.className = 'vv-cron vv-folder-new-input';
inp.placeholder = 'Folder name…';
inp.style.cssText = 'width:160px;flex:none';
const saveBtn = document.createElement('button');
saveBtn.className = 'vv-btn-sm vv-save-script-btn-style';
saveBtn.textContent = 'Create';
saveBtn.onclick = () => _vvDoCreateFolder(inp.value.trim(), wrap);
const cancelBtn = document.createElement('button');
cancelBtn.className = 'vv-btn-sm';
cancelBtn.textContent = '✕';
cancelBtn.onclick = () => wrap.remove();
inp.addEventListener('keydown', e => {
if (e.key === 'Enter') _vvDoCreateFolder(inp.value.trim(), wrap);
if (e.key === 'Escape') wrap.remove();
});
wrap.append(inp, saveBtn, cancelBtn);
customChildren.insertBefore(wrap, customChildren.firstChild);
inp.focus();
}
async function _vvDoCreateFolder(name, wrap) {
if (!name) return;
const folders = vvGetCurrentFolders();
if (folders[name] !== undefined) { alert(`Folder "${name}" already exists.`); return; }
folders[name] = [];
const r = await vvPost('/plugins/varaverk/api/savefolders.php', {
folders: JSON.stringify(folders)
}).then(r => r.json()).catch(() => ({ ok: false }));
if (!r.ok) { alert('Failed to create folder.'); return; }
wrap.remove();
// Add folder group to DOM
const customChildren = document.getElementById('vv-custom-children');
const fg = document.createElement('div');
fg.className = 'vv-folder-group';
fg.dataset.folder = name;
fg.innerHTML = `
<div class="vv-folder-row" onclick="vvToggleFolder(this)">
<span class="vv-folder-chevron">▸</span>
<span class="vv-folder-name">${vvEscHtml(name)}</span>
<span class="vv-folder-count">0</span>
</div>
<div class="vv-folder-children" style="display:none"></div>
`;
customChildren.insertBefore(fg, customChildren.firstChild);
// Wire up drop listeners if arrange mode is active
if (vvArrangeMode) {
const fc = fg.querySelector('.vv-folder-children');
fc.addEventListener('dragover', _vvFolderDragOver);
fc.addEventListener('dragleave', _vvFolderDragLeave);
fc.addEventListener('drop', _vvFolderDrop);
}
}
</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();
vvRestoreAdvancedMode();
vvRestoreSugStates();
vvApplyHighlighting();
vvBoardInit();
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>