4480 lines
207 KiB
PHP
4480 lines
207 KiB
PHP
<?php
|
||
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
||
// PURPOSE
|
||
// Scheduler tab. The largest page in the plugin and the main operational surface: the full
|
||
// script library, per-job schedules, live run status and logs, dry-run output, conf editing
|
||
// per script, and the custom-script editor.
|
||
//
|
||
// OPERATIONAL MODEL
|
||
// Writes state that changes what the machine does on a timer. Enabling, disabling or
|
||
// rescheduling a job here takes effect on the next cron rebuild — this page decides what
|
||
// runs unattended at 1am.
|
||
//
|
||
// DESIGN PRINCIPLES
|
||
// Script descriptions come from the scripts themselves.
|
||
// Requires include/scheduler.php, which parses the PURPOSE block out of each bash
|
||
// header. The library listing and the script's own documentation are the same text and
|
||
// cannot drift. This page is the reason the repo-wide header convention exists.
|
||
//
|
||
// The job tree is derived from conf, not maintained here.
|
||
// Orchestrator job lists come from master.conf arrays; custom scripts are whatever
|
||
// *.sh sits in CUSTOM_SCRIPTS_DIR. Nothing has to be registered in two places.
|
||
//
|
||
// Long operations never block the page.
|
||
// Runs, dry-runs and imports go through api/run.php and api/dryrun.php with status
|
||
// polled separately, so a script that takes ten minutes does not hold a request open.
|
||
//
|
||
// OPERATIONAL SAFEGUARDS
|
||
// Running a script from the UI is confirmed first. Several of these delete files.
|
||
//
|
||
// Dry-run is offered alongside run for anything destructive, and its output is shown in
|
||
// full rather than summarised — reading the list is the point.
|
||
//
|
||
// Stale locks are cleared explicitly through api/clearlock.php, never automatically. A lock
|
||
// that looks stale may belong to a job still running.
|
||
//
|
||
// Conf edits are surgical, preserving the comment blocks that document every threshold.
|
||
//
|
||
// Custom scripts live outside the git repo, so a user's own scripts are never touched by a
|
||
// pull and never committed by accident.
|
||
//
|
||
// Script content and log output render escaped throughout — this page displays arbitrary
|
||
// file contents.
|
||
//
|
||
// RENDERS
|
||
// Job tree by orchestrator tier, per-job schedule and toggle controls, live status and
|
||
// logs, dry-run output, per-script conf forms, custom script editor, run history
|
||
//
|
||
// DEPENDS ON
|
||
// include/scheduler.php required directly — schedule, cron rebuild, script library
|
||
// api/scheduler.php schedule read/write api/run.php execute
|
||
// api/dryrun.php dry-run execute api/stop.php terminate
|
||
// api/status.php live run status api/log.php log tail
|
||
// api/script.php script read/write api/readscript.php source
|
||
// api/scriptinfo.php header metadata api/import_script.php
|
||
// api/conf_toggle.php per-script enable api/flag_toggle.php
|
||
// api/confform.php conf forms api/rawconf.php raw conf edit
|
||
// api/board.php overview board api/recent.php recent runs
|
||
// api/snapshot.php state snapshot api/clearlock.php stale lock clear
|
||
// api/savefolders.php folder grouping api/reorderarray.php
|
||
// api/rsync_standalone.php
|
||
require_once dirname(__DIR__) . '/include/scheduler.php';
|
||
require_once dirname(__DIR__) . '/include/docs.php';
|
||
|
||
// Live values for the `$VAR` markers in pages/readme/*.md. Conf variables, plus the derived
|
||
// path constants — those are not conf keys, but they are exactly what a reader needs resolved
|
||
// rather than described.
|
||
$_vv_doc_vars = array_merge(vv_conf_vars(), [
|
||
'CUSTOM_SCRIPTS_DIR' => CUSTOM_SCRIPTS_DIR,
|
||
'SCRIPTS_DIR' => SCRIPTS_DIR,
|
||
]);
|
||
|
||
// Setup mode — auto-open a conf file and force the editing sequence
|
||
$vv_setup_conf = preg_match('/^[\w.]+\.conf$/', $_GET['vv_setup'] ?? '')
|
||
? $_GET['vv_setup'] : '';
|
||
|
||
// Deep-link from Monitor → open a specific script on load (e.g. ?tab=scheduler&vv_script=Media/x.sh)
|
||
$vv_open_script = preg_match('#^[\w./-]+\.sh$#', $_GET['vv_script'] ?? '')
|
||
? $_GET['vv_script'] : '';
|
||
$_vv_my_host = vv_detect_host();
|
||
$_vv_local_host_conf = ($_vv_my_host !== 'unknown') ? $_vv_my_host . '.conf' : 'host1.conf';
|
||
|
||
$tree = vv_job_tree();
|
||
$customs = vv_custom_scripts();
|
||
$tools = vv_tools_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-cog-btn" onclick="vvClickCog(this)" title="Settings">⚙</span><span class="vv-job-label" onclick="vvClickLabel(this)" style="cursor:pointer"><?= htmlspecialchars($orch['label']) ?></span>
|
||
<?php if (str_starts_with($orch['cron'], 'array_')): ?>
|
||
<span class="vv-event-badge"><?= $orch['cron'] === 'array_start' ? '⚡ Array Start' : '⚡ Array Stop' ?></span>
|
||
<?php endif; ?>
|
||
<input type="text" class="vv-cron" value="<?= htmlspecialchars($orch['cron']) ?>"
|
||
placeholder="cron or array_start / array_stop"
|
||
onblur="vvSaveCronBlur(this)">
|
||
<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)">▶ Run</button>
|
||
<button class="vv-btn-sm vv-dry-btn" onclick="vvDryRun(this)">▶ 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="vvToggleSteps(this)">Steps ▸</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"<?= empty($child['flag_value']) ? ' style="display:none"' : '' ?>><?= 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)">▶ Run</button>
|
||
<button class="vv-btn-sm vv-dry-btn vv-rsync-standalone" onclick="vvDryRun(this)">▶ 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-cog-btn" onclick="vvClickCog(this)" title="Settings">⚙</span><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)"
|
||
onblur="vvSaveCronBlur(this)">
|
||
<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)">▶ Run</button>
|
||
<button class="vv-btn-sm vv-dry-btn" onclick="vvDryRun(this)">▶ 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>
|
||
<input type="text" class="vv-script-args" placeholder="extra args"
|
||
style="display:<?= $orch['enabled'] ? 'none' : '' ?>">
|
||
<span class="vv-job-dot"></span>
|
||
</div>
|
||
</div>
|
||
<?php endif; ?>
|
||
<?php endforeach; ?>
|
||
</div>
|
||
<?php endif; ?>
|
||
|
||
</div>
|
||
<?php endforeach; ?>
|
||
|
||
<!-- Tools container -->
|
||
<div class="vv-card vv-wide vv-sched-card vv-tools-card">
|
||
<div class="vv-job-row">
|
||
<span class="vv-job-label" style="font-weight:bold;">Tools</span>
|
||
<span class="vv-custom-count"><?= count($tools) ?> tool<?= count($tools) !== 1 ? 's' : '' ?></span>
|
||
<button class="vv-advanced-toggle" style="margin-left:auto;width:100px" onclick="vvToggleTools(this)">Tools ▸</button>
|
||
</div>
|
||
<div class="vv-children" id="vv-tools-children" style="display:none;">
|
||
<?php if (empty($tools)): ?>
|
||
<p class="vv-custom-empty">No scripts found in Tools/ or Plugin/*/tools/.</p>
|
||
<?php else: ?>
|
||
<?php foreach ($tools as $ts): $tsid = htmlspecialchars($ts['id']); ?>
|
||
<div class="vv-script" data-id="<?= $tsid ?>">
|
||
<div class="vv-job-row">
|
||
<label class="vv-toggle" title="Enable/disable cron">
|
||
<input type="checkbox" class="vv-enabled"
|
||
<?= $ts['enabled'] ? 'checked' : '' ?>
|
||
onchange="vvSaveJob(this)">
|
||
<span class="vv-slider"></span>
|
||
</label>
|
||
<span class="vv-job-label"><?= htmlspecialchars($ts['label']) ?></span>
|
||
<input type="text" class="vv-cron" value="<?= htmlspecialchars($ts['cron']) ?>"
|
||
placeholder="cron expression"
|
||
onblur="vvSaveCronBlur(this)">
|
||
<span class="vv-save-check"></span>
|
||
</div>
|
||
<?php if (!empty($ts['desc'])): ?>
|
||
<div class="vv-job-desc" title="<?= htmlspecialchars($ts['desc']) ?>"><?= htmlspecialchars($ts['desc']) ?></div>
|
||
<?php endif; ?>
|
||
<div class="vv-job-actions">
|
||
<button class="vv-btn-sm vv-run-btn" onclick="vvRunJob(this)">▶ Run</button>
|
||
<button class="vv-btn-sm vv-dry-btn" onclick="vvDryRun(this)">▶ Dry Run</button>
|
||
<?php $tsLog = vv_job_log_path($ts['id']); ?>
|
||
<button class="vv-btn-sm vv-log-btn<?= (file_exists($tsLog) && filesize($tsLog) > 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"
|
||
<?= $ts['log_enabled'] ? 'checked' : '' ?>
|
||
onchange="vvSaveJob(this)">
|
||
<span>Verbose</span>
|
||
</label>
|
||
<input type="text" class="vv-script-args" placeholder="extra args">
|
||
<span class="vv-job-dot"></span>
|
||
</div>
|
||
</div>
|
||
<?php endforeach; ?>
|
||
<?php endif; ?>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 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>+ Create 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-cog-btn" onclick="vvClickCog(this)" title="Settings">⚙</span><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" onblur="vvSaveCronBlur(this)">
|
||
<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)">▶ Run</button>
|
||
<button class="vv-btn-sm vv-dry-btn" onclick="vvDryRun(this)">▶ 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>
|
||
<input type="text" class="vv-script-args" placeholder="extra args">
|
||
<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-cog-btn" onclick="vvClickCog(this)" title="Settings">⚙</span><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"
|
||
onblur="vvSaveCronBlur(this)">
|
||
<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)">▶ Run</button>
|
||
<button class="vv-btn-sm vv-dry-btn" onclick="vvDryRun(this)">▶ 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>
|
||
<input type="text" class="vv-script-args" placeholder="extra args">
|
||
<span class="vv-job-dot"></span>
|
||
</div>
|
||
</div>
|
||
<?php endforeach; ?>
|
||
<?php endif; ?>
|
||
</div>
|
||
</div>
|
||
|
||
</div><!-- /#vv-sched-cards -->
|
||
|
||
<div class="vv-sched-footer">
|
||
<button class="vv-save-btn vv-add-script-btn" onclick="vvAddScript()">+ Create Script</button>
|
||
<button class="vv-save-btn vv-import-script-btn" onclick="vvImportScriptOpen()" title="Move an existing script from anywhere on the server into Custom Scripts">+ Import 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">🗑 Delete</button>
|
||
</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">☰ Scheduler Info</button>
|
||
<button id="vv-restore-btn" class="vv-btn-sm" onclick="vvRestoreLastLog()" style="display:none">← <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:8px;flex-wrap:wrap;">
|
||
<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-cancel-edit-btn" class="vv-btn-sm vv-delete-btn" onclick="vvBackToSuggestions()" style="display:none">Cancel</button>
|
||
<button id="vv-undo-btn" class="vv-btn-sm vv-undo-redo-btn" onclick="vvUndo()" style="display:none" disabled title="Undo (Ctrl+Z)">↩ Undo<span class="vv-undo-count" id="vv-undo-count"></span></button>
|
||
<button id="vv-redo-btn" class="vv-btn-sm vv-undo-redo-btn" onclick="vvRedo()" style="display:none" disabled title="Redo (Ctrl+Y / Ctrl+Shift+Z)">↪ Redo</button>
|
||
<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" style="display:inline-flex;flex-direction:column;line-height:1.3;white-space:nowrap"></span>
|
||
<input type="text" id="vv-log-search" placeholder="Search…"
|
||
oninput="vvFilterLog(this.value)" style="display:none;">
|
||
<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; ?>
|
||
<button class="vv-btn-sm vv-adv-only" id="vv-git-pull-btn"
|
||
onclick="vvGitPull(this)"
|
||
title="Pull latest scripts from repository and run conf upgrade"
|
||
style="display:none;margin-left:auto;">↻ Git Pull</button>
|
||
</div>
|
||
|
||
<!-- ── How do I use this (pinned) ── -->
|
||
<div class="vv-sug-block vv-info-block" id="vv-how-to-use" data-save-key="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>
|
||
<!-- Rendered from pages/readme/scheduler-readme.md, not maintained here. That file is
|
||
also what the AI tab retrieves, so the panel you read and the answer the
|
||
assistant gives are the same text and cannot drift — the same argument that
|
||
makes this page parse script PURPOSE blocks instead of restating them.
|
||
Two tiers, one file. Collapsed leaves the reference tables visible: that is the
|
||
terse per-control lookup this panel has always been, and it is what you want
|
||
open while actually working. Expanding adds the task guide above it — the
|
||
walkthroughs you need once and then stop reading. The split is taken from the
|
||
section titles, so neither tier is a second copy of anything.
|
||
vvToggleSug() toggles the header's immediate next sibling, so the guide must
|
||
stay directly after the header and the quick tier must sit outside it. -->
|
||
<div class="vv-sug-body vv-info-body">
|
||
<div class="vv-doc">
|
||
<?= vv_docs_render('Plugin/unraid/pages/readme/scheduler-readme.md', $_vv_doc_vars,
|
||
fn($h) => !is_string($h) || !str_starts_with($h, 'Reference —')) ?>
|
||
</div>
|
||
</div>
|
||
<div class="vv-info-body vv-doc vv-doc-quick">
|
||
<?= vv_docs_render('Plugin/unraid/pages/readme/scheduler-readme.md', $_vv_doc_vars,
|
||
fn($h) => is_string($h) && str_starts_with($h, 'Reference —')) ?>
|
||
</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>
|
||
|
||
<!-- ── Cron Calculator ── -->
|
||
<div class="vv-sug-block" data-save-key="calc">
|
||
<div class="vv-sug-header" onclick="vvToggleSug(this)">
|
||
<span class="vv-sug-chevron">▸</span>
|
||
<span class="vv-sug-title">Cron Calculator</span>
|
||
</div>
|
||
<div class="vv-sug-body" style="display:none">
|
||
<div class="vv-calc-wrap">
|
||
<input type="text" id="vv-calc-input" class="vv-calc-in"
|
||
placeholder="*/15 * * * * or every day at 3am"
|
||
oninput="vvCalcUpdate(this.value)"
|
||
onkeydown="if(event.key==='Enter')vvCalcApply()">
|
||
<div id="vv-calc-result"></div>
|
||
<button class="vv-btn-sm vv-calc-apply-btn" id="vv-calc-apply"
|
||
onclick="vvCalcApply()" style="display:none">
|
||
Apply to <span id="vv-calc-target-label">selected</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── Recent Activity ── -->
|
||
<div class="vv-sug-block" data-save-key="activity">
|
||
<div class="vv-sug-header" onclick="vvToggleSug(this)">
|
||
<span class="vv-sug-chevron">▸</span>
|
||
<span class="vv-sug-title">Recent Activity</span>
|
||
<span id="vv-activity-badge" class="vv-hdr-badge vv-hdr-badge-red" style="display:none"></span>
|
||
</div>
|
||
<div class="vv-sug-body" style="display:none" id="vv-activity-body">
|
||
<div class="vv-board-placeholder">loading…</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 (str_starts_with($orch['cron'], 'array_')): ?>
|
||
<span class="vv-event-badge vv-sb-badge">⚡</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; ?>
|
||
<?php
|
||
$_os = @json_decode(@file_get_contents(vv_job_stat_path($orchId)), true);
|
||
$_or = $_os['status'] ?? '';
|
||
$_oc = $_or === 'ok' ? 'vv-stat-ok' : ($_or === 'warn' ? 'vv-stat-warn' : ($_or === 'error' ? 'vv-stat-error' : ($_or === 'skipped' ? 'vv-stat-skip' : ($orch['enabled'] ? 'vv-sug-on' : 'vv-stat-none'))));
|
||
?>
|
||
<span class="<?= $_oc ?> vv-sb-status" title="<?= $_or ? htmlspecialchars("last: $_or") : ($orch['enabled'] ? 'enabled' : 'never run') ?>">
|
||
<?= ($_or || $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; ?>
|
||
<?php
|
||
$_cs = @json_decode(@file_get_contents(vv_job_stat_path($child['id'])), true);
|
||
$_cr = $_cs['status'] ?? '';
|
||
$_cc = $_cr === 'ok' ? 'vv-stat-ok' : ($_cr === 'warn' ? 'vv-stat-warn' : ($_cr === 'error' ? 'vv-stat-error' : ($_cr === 'skipped' ? 'vv-stat-skip' : (!empty($child['conf_enabled']) ? 'vv-sug-on' : 'vv-stat-none'))));
|
||
?>
|
||
<span class="<?= $_cc ?> vv-sb-status" title="<?= $_cr ? htmlspecialchars("last: $_cr") : (!empty($child['conf_enabled']) ? 'enabled' : 'never run') ?>">
|
||
<?= ($_cr || !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-find">
|
||
<div class="vv-find-row">
|
||
<span class="vv-find-expand" id="vv-find-expand" onclick="vvFindToggleReplace()" title="Toggle Replace (Ctrl+H)">›</span>
|
||
<input type="text" id="vv-find-input" placeholder="Find…"
|
||
oninput="vvFindSearch()" onkeydown="vvFindKeydown(event)">
|
||
<span class="vv-find-count" id="vv-find-count"></span>
|
||
<button class="vv-btn-sm vv-find-nav-btn" onclick="vvFindNav(-1)" title="Previous (Shift+Enter)">↑</button>
|
||
<button class="vv-btn-sm vv-find-nav-btn" onclick="vvFindNav(1)" title="Next (Enter)">↓</button>
|
||
<span class="vv-find-x" onclick="vvFindClose()" title="Close (Esc)">×</span>
|
||
</div>
|
||
<div class="vv-replace-row" id="vv-replace-row">
|
||
<span style="width:14px;flex-shrink:0"></span>
|
||
<input type="text" id="vv-replace-input" placeholder="Replace with…"
|
||
onkeydown="vvReplaceKeydown(event)">
|
||
<button class="vv-btn-sm vv-repl-btn" onclick="vvReplaceOne()" title="Replace (Enter)">Replace</button>
|
||
<button class="vv-btn-sm vv-repl-btn" onclick="vvReplaceAll()" title="Replace All">All</button>
|
||
</div>
|
||
</div>
|
||
<div id="vv-goto-bar">
|
||
<span style="color:#666;font-size:12px;flex-shrink:0">Go to line</span>
|
||
<input type="number" id="vv-goto-input" min="1" placeholder="#"
|
||
oninput="vvGotoApply()" onkeydown="vvGotoKeydown(event)">
|
||
<span class="vv-goto-info" id="vv-goto-info"></span>
|
||
<span class="vv-find-x" onclick="vvGotoClose()" title="Close (Esc)">×</span>
|
||
</div>
|
||
<!-- ── Keyboard Shortcuts — expanded by default, state remembered ── -->
|
||
<div class="vv-sug-block" data-save-key="editor-shortcuts" style="margin-bottom:6px;border:1px solid #1e1e1e;border-radius:4px;">
|
||
<div class="vv-sug-header" onclick="vvToggleSug(this)" style="padding:5px 8px;">
|
||
<span class="vv-sug-chevron">▾</span>
|
||
<span style="font-size:11px;font-weight:bold;color:#555;letter-spacing:.06em;text-transform:uppercase;">Keyboard Shortcuts</span>
|
||
</div>
|
||
<div class="vv-sug-body" style="padding:4px 10px 8px;">
|
||
<ul class="vv-info-cols" style="font-size:11px;">
|
||
<li><strong>Ctrl+S</strong> — save</li>
|
||
<li><strong>Ctrl+Z / Ctrl+Y</strong> — undo / redo (200 steps)</li>
|
||
<li><strong>Ctrl+/</strong> — toggle line comment</li>
|
||
<li><strong>Tab / Shift+Tab</strong> — indent / dedent</li>
|
||
<li><strong>Ctrl+F</strong> — find</li>
|
||
<li><strong>Ctrl+H</strong> — find & replace</li>
|
||
<li><strong>Ctrl+G</strong> — go to line</li>
|
||
<li><strong>Ctrl+D</strong> — select next occurrence</li>
|
||
<li><strong>Ctrl+L</strong> — select line</li>
|
||
<li><strong>Ctrl+Shift+K</strong> — delete line</li>
|
||
<li><strong>Alt+↑/↓</strong> — move line</li>
|
||
<li><strong>Alt+Shift+↓</strong> — duplicate line below</li>
|
||
<li><strong>Home</strong> — smart (first non-space → col 0)</li>
|
||
<li><strong>( { [ " `</strong> — auto-close pair; wraps selection; Backspace deletes both</li>
|
||
<li><strong>A− / A+</strong> in status bar — font size (9–22px, persists)</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="vv-editor-wrap">
|
||
<pre id="vv-ln-gutter" aria-hidden="true"></pre>
|
||
<div id="vv-editor-inner">
|
||
<div id="vv-cur-line"></div>
|
||
<pre id="vv-hl-overlay" aria-hidden="true"></pre>
|
||
<textarea id="vv-editor-body" class="vv-editor-body" spellcheck="false"
|
||
oninput="vvSyncHlOverlay(); vvUndoCapture()" onscroll="vvSyncHlScroll()"
|
||
onkeydown="vvEditorKeydown(event)"
|
||
onkeyup="vvEditorCursorMoved()" onmouseup="vvEditorCursorMoved()"
|
||
onclick="vvEditorCursorMoved()"></textarea>
|
||
</div>
|
||
</div>
|
||
<div id="vv-editor-status">
|
||
<span class="vv-es-pos" id="vv-es-pos">Ln 1, Col 1</span>
|
||
<span class="vv-es-sel" id="vv-es-sel"></span>
|
||
<button class="vv-es-btn" onclick="vvFontSize(-1)" title="Decrease font size">A−</button>
|
||
<span id="vv-es-fontsize">12px</span>
|
||
<button class="vv-es-btn" onclick="vvFontSize(1)" title="Increase font size">A+</button>
|
||
<button class="vv-es-btn" id="vv-es-wrap-btn" onclick="vvToggleWordWrap()" title="Word wrap OFF — click to enable">Wrap</button>
|
||
<span class="vv-es-lang" id="vv-es-lang"></span>
|
||
</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 vv-snap-footer" id="vv-sched-info-footer">
|
||
<span class="vv-snap-item">
|
||
<span class="vv-snap-label">CPU</span>
|
||
<span class="vv-snap-bar"><span id="vv-snap-cpu-bar"></span></span>
|
||
<span id="vv-snap-cpu-val" class="vv-snap-val">—</span>
|
||
</span>
|
||
<span class="vv-snap-item">
|
||
<span class="vv-snap-label">RAM</span>
|
||
<span class="vv-snap-bar"><span id="vv-snap-ram-bar"></span></span>
|
||
<span id="vv-snap-ram-val" class="vv-snap-val">—</span>
|
||
</span>
|
||
<span class="vv-snap-div">·</span>
|
||
<span id="vv-snap-fallback" class="vv-snap-state">—</span>
|
||
<span class="vv-snap-div">·</span>
|
||
<span id="vv-snap-partner" class="vv-snap-partner">—</span>
|
||
<span class="vv-snap-div">·</span>
|
||
<span id="vv-snap-streams" class="vv-snap-media">—</span>
|
||
<span class="vv-snap-div">·</span>
|
||
<span id="vv-snap-transcodes" class="vv-snap-media">—</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;
|
||
// Editor state
|
||
let vvWordMatch = null; // word under cursor — highlighted everywhere in overlay
|
||
let vvFindTerm = ''; // active find-bar search string
|
||
let vvFindMatches = []; // [{start,end}] in raw text
|
||
let vvFindIdx = 0; // current match index
|
||
// Undo / redo
|
||
let vvUndoStack = []; // [{v,ss,se}] snapshots — v=value, ss=selStart, se=selEnd
|
||
let vvRedoStack = [];
|
||
let vvUndoTimer = null;
|
||
const VV_UNDO_MAX = 200; // max history depth
|
||
// Auto-close pairs
|
||
const VV_PAIRS = {'(':')', '[':']', '{':'}', '"':'"', "'":"'", '`':'`'};
|
||
const VV_CLOSE = new Set([')', ']', '}', '"', "'", '`']);
|
||
// Editor display prefs
|
||
let vvFontSizePx = parseInt(localStorage.getItem('vv-editor-fontsize') || '12') || 12;
|
||
let vvWordWrap = localStorage.getItem('vv-editor-wordwrap') === '1';
|
||
// Line height helper — used everywhere instead of hardcoded 18
|
||
function vvLH() {
|
||
const ta = document.getElementById('vv-editor-body');
|
||
if (ta) { const lh = parseFloat(window.getComputedStyle(ta).lineHeight); if (lh > 0) return lh; }
|
||
return vvFontSizePx * 1.5;
|
||
}
|
||
|
||
// Setup mode — injected by PHP when navigating from the first-run wizard
|
||
let vvSetupConf = <?= json_encode($vv_setup_conf) ?>;
|
||
const vvLocalHostConf = <?= json_encode($_vv_local_host_conf) ?>;
|
||
|
||
// Where Custom Scripts (Create + Import) actually live — shown in the Import Script dialog
|
||
window.__vvCustomScriptsDir = <?= json_encode(CUSTOM_SCRIPTS_DIR) ?>;
|
||
|
||
if (vvSetupConf) {
|
||
// Auto-open the setup conf file once the page is ready
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
setTimeout(() => vvLoadRawConf(vvSetupConf), 150);
|
||
});
|
||
}
|
||
|
||
// Deep-link from Monitor: open a specific script's panel once the tree is rendered.
|
||
const vvOpenScriptId = <?= json_encode($vv_open_script) ?>;
|
||
if (vvOpenScriptId) {
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
setTimeout(() => {
|
||
if (document.querySelector('[data-id="' + CSS.escape(vvOpenScriptId) + '"]')) {
|
||
vvOpenRight(vvOpenScriptId);
|
||
const row = document.querySelector('[data-id="' + CSS.escape(vvOpenScriptId) + '"]');
|
||
if (row) row.scrollIntoView({behavior: 'smooth', block: 'center'});
|
||
}
|
||
}, 200);
|
||
});
|
||
}
|
||
|
||
function vvEscHtml(s) {
|
||
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||
}
|
||
|
||
function vvPost(url, data) {
|
||
const params = new URLSearchParams({csrf_token, ...data});
|
||
return fetch(url, {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||
body: params
|
||
}).then(r => r.json());
|
||
}
|
||
|
||
function vvFitRight() {
|
||
const right = document.getElementById('vv-sched-right');
|
||
if (!right.classList.contains('vv-panel-visible')) return;
|
||
const left = document.getElementById('vv-sched-left');
|
||
|
||
const lf = left.querySelector('.vv-sched-footer');
|
||
const rf = right.querySelector('.vv-sched-info');
|
||
const toolbar = right.querySelector('.vv-log-toolbar');
|
||
const pre = document.getElementById('vv-log-pre');
|
||
const sug = document.getElementById('vv-suggestions');
|
||
|
||
const leftRect = left.getBoundingClientRect();
|
||
const rightRect = right.getBoundingClientRect();
|
||
|
||
// 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') {
|
||
const _edH = Math.max(60, contentH - 38) + 'px';
|
||
const _ta = document.getElementById('vv-editor-body');
|
||
_ta.style.height = _edH;
|
||
document.getElementById('vv-ln-gutter').style.height = _edH;
|
||
// Re-pin the overlay: this just changed the box it has to match, and vvSyncHlOverlay()
|
||
// does not run on resize. Without this the two drift apart again the moment the window
|
||
// changes size — see the comment there for what that costs.
|
||
const _ov = document.getElementById('vv-hl-overlay');
|
||
if (_ov) _ov.style.height = _ta.clientHeight + '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-log-search').style.display = '';
|
||
document.getElementById('vv-clear-btn').style.display = '';
|
||
document.getElementById('vv-cancel-edit-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';
|
||
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);
|
||
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() {
|
||
vvEditorReset();
|
||
document.getElementById('vv-editor-status').classList.remove('vv-ed-active');
|
||
document.getElementById('vv-undo-btn').style.display = 'none';
|
||
document.getElementById('vv-redo-btn').style.display = 'none';
|
||
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-log-search').style.display = 'none';
|
||
document.getElementById('vv-log-search').value = '';
|
||
document.getElementById('vv-clear-btn').style.display = 'none';
|
||
document.getElementById('vv-cancel-edit-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;
|
||
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);
|
||
const display = content.trim() ? (invert ? content.split('\n').reverse().join('\n') : content) : '(no log yet)';
|
||
pre._raw = display;
|
||
const term = document.getElementById('vv-log-search')?.value.trim() ?? '';
|
||
if (term) vvFilterLog(term); else pre.textContent = display;
|
||
if (autoScroll) {
|
||
pre.scrollTop = invert ? 0 : pre.scrollHeight;
|
||
} else {
|
||
requestAnimationFrame(() => { pre.scrollTop = savedScroll; });
|
||
}
|
||
const _d = d.ts ? new Date(d.ts * 1000) : null;
|
||
ts.innerHTML = _d ? '<span>Last run:</span><span>' + _d.toLocaleDateString([],{month:'numeric',day:'numeric',year:'numeric'}) + '</span><span>' + _d.toLocaleTimeString([],{hour:'2-digit',minute:'2-digit',second:'2-digit'}) + '</span>' : '';
|
||
})
|
||
.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);
|
||
}
|
||
|
||
// Run a script by ID directly — no DOM card needed.
|
||
function vvRunById(id) {
|
||
vvOpenRight(id);
|
||
vvSetDot(id);
|
||
vvRunningSet.add(id);
|
||
vvPost('/plugins/varaverk/api/run.php', {id, manual: '1'})
|
||
.then(d => {
|
||
if (!d.ok) {
|
||
document.getElementById('vv-log-pre').textContent = '✗ ' + (d.error ?? 'Failed to start');
|
||
vvClearDot(id);
|
||
vvRunningSet.delete(id);
|
||
}
|
||
});
|
||
}
|
||
|
||
function vvGitPull(btn) {
|
||
const id = 'git_pull_execute.sh';
|
||
btn.disabled = true;
|
||
btn.textContent = '⟳ Pulling…';
|
||
vvRunById(id);
|
||
// Re-enable once the log poll confirms it's running or done
|
||
setTimeout(() => { btn.disabled = false; btn.textContent = '↻ Git Pull'; }, 4000);
|
||
}
|
||
|
||
function vvRunJob(btn) {
|
||
const job = btn.closest('[data-id]');
|
||
const id = job.dataset.id;
|
||
const location = job.querySelector('.vv-rsync-location')?.value.trim() || '';
|
||
const extra_args = job.querySelector('.vv-script-args')?.value.trim() || '';
|
||
vvOpenRight(id);
|
||
vvSetDot(id);
|
||
vvRunningSet.add(id);
|
||
const data = {id};
|
||
if (location) data.location = location;
|
||
if (extra_args) data.extra_args = extra_args;
|
||
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() || '';
|
||
const extra_args = job.querySelector('.vv-script-args')?.value.trim() || '';
|
||
vvOpenRight(id);
|
||
vvSetDot(id);
|
||
vvRunningSet.add(id);
|
||
const data = {id};
|
||
if (location) data.location = location;
|
||
if (extra_args) data.extra_args = extra_args;
|
||
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);
|
||
}
|
||
|
||
// Cron blur-to-save — fires when focus leaves any cron input.
|
||
// Skips rsync standalone (conf_flag) cron fields — those are saved together with location via their own Save button.
|
||
function vvSaveCronBlur(input) {
|
||
const job = input.closest('[data-id]');
|
||
if (!job || job.dataset.type === 'conf_flag') return;
|
||
vvSaveJob(input);
|
||
}
|
||
|
||
// 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';
|
||
|
||
const argsInput = child.querySelector('.vv-script-args');
|
||
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'; }
|
||
if (argsInput) { argsInput.style.display = 'none'; }
|
||
} else {
|
||
// Orch off: all children switch off; cron and args become active
|
||
toggle.checked = false;
|
||
toggle.disabled = false;
|
||
if (cronInput) { cronInput.disabled = false; cronInput.style.opacity = ''; }
|
||
if (argsInput) { argsInput.style.display = ''; }
|
||
// 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';
|
||
const badge = child.querySelector('.vv-flag-badge');
|
||
if (badge) badge.style.display = enabled ? '' : 'none';
|
||
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); });
|
||
}
|
||
}
|
||
|
||
|
||
// Named Steps, not Advanced. The page already carries a global "Advanced" mode button that
|
||
// unlocks script source and raw conf editing — an unrelated control with a much sharper edge.
|
||
// Two buttons sharing a name on one page meant "go to Scheduler and click Advanced" could land
|
||
// someone in raw conf editing when all they wanted was to see what an orchestrator runs.
|
||
function vvToggleSteps(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 ? 'Steps ▸' : 'Steps ▾';
|
||
}
|
||
|
||
function vvToggleTools(btn) {
|
||
const card = btn.closest('.vv-tools-card');
|
||
const children = card.querySelector('.vv-children');
|
||
const visible = children.style.display !== 'none';
|
||
children.style.display = visible ? 'none' : 'block';
|
||
btn.textContent = visible ? 'Tools ▸' : 'Tools ▾';
|
||
}
|
||
|
||
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');
|
||
vvSyncHlOverlay();
|
||
vvUndoCaptureInitial();
|
||
vvEditorCursorMoved();
|
||
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';
|
||
vvSyncHlOverlay();
|
||
vvUndoCaptureInitial();
|
||
vvEditorCursorMoved();
|
||
})
|
||
.catch(() => {
|
||
document.getElementById('vv-editor-body').value = '# Error loading script';
|
||
vvSyncHlOverlay();
|
||
});
|
||
|
||
vvShowEditorMode(name);
|
||
}
|
||
|
||
function vvShowEditorMode(title) {
|
||
vvEditorReset();
|
||
document.getElementById('vv-editor').classList.add('vv-editor-hl');
|
||
document.getElementById('vv-editor-status').classList.add('vv-ed-active');
|
||
document.getElementById('vv-es-lang').textContent = 'Bash';
|
||
document.getElementById('vv-undo-btn').style.display = '';
|
||
document.getElementById('vv-redo-btn').style.display = '';
|
||
vvRestoreEditorPrefs();
|
||
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-log-search').style.display = 'none';
|
||
document.getElementById('vv-log-search').value = '';
|
||
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-cancel-edit-btn').style.display = '';
|
||
document.getElementById('vv-save-conf-btn').style.display = 'none';
|
||
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'; });
|
||
}
|
||
|
||
// ── Import Script — browse the whole server, move a chosen .sh into Custom Scripts ──
|
||
|
||
let _vvImpSelected = null; // full path of the currently-selected file, or null
|
||
|
||
function vvImportScriptOpen() {
|
||
let modal = document.getElementById('vv-import-modal');
|
||
if (!modal) modal = _vvImpBuildModal();
|
||
modal.style.display = 'flex';
|
||
_vvImpSelected = null;
|
||
_vvImpUpdateFooter();
|
||
_vvImpBrowse('/');
|
||
}
|
||
|
||
function vvImportScriptClose() {
|
||
const modal = document.getElementById('vv-import-modal');
|
||
if (modal) modal.style.display = 'none';
|
||
_vvImpSelected = null;
|
||
}
|
||
|
||
function _vvImpBuildModal() {
|
||
const modal = document.createElement('div');
|
||
modal.id = 'vv-import-modal';
|
||
modal.style.cssText = 'display:none;position:fixed;inset:0;z-index:9000;background:rgba(0,0,0,.6);'
|
||
+ 'align-items:center;justify-content:center;';
|
||
modal.addEventListener('mousedown', e => { if (e.target === modal) vvImportScriptClose(); });
|
||
|
||
const box = document.createElement('div');
|
||
box.style.cssText = 'background:#111;border:1px solid #222;border-radius:6px;width:560px;max-width:92vw;'
|
||
+ 'max-height:80vh;display:flex;flex-direction:column;overflow:hidden;';
|
||
|
||
box.innerHTML = `
|
||
<div style="padding:10px 14px;border-bottom:1px solid #1e1e1e;display:flex;align-items:center;justify-content:space-between;">
|
||
<span style="font-size:13px;font-weight:bold;color:#ccc;">Import Script</span>
|
||
<button onclick="vvImportScriptClose()" style="background:none;border:none;color:#666;cursor:pointer;font-size:14px;">✕</button>
|
||
</div>
|
||
<div style="padding:10px 14px;border-bottom:1px solid #1e1e1e;">
|
||
<div style="font-size:10px;color:#555;margin-bottom:6px;">
|
||
Moves the selected script into Custom Scripts (<code>${_vvImpEsc(window.__vvCustomScriptsDir || '')}</code>).
|
||
The original is removed once the copy is verified.
|
||
</div>
|
||
<div style="display:flex;gap:6px;align-items:center;">
|
||
<input id="vv-imp-path" type="text" value="/" style="flex:1;min-width:0;background:#0a0a0a;border:1px solid #222;
|
||
color:#aaa;border-radius:3px;padding:4px 8px;font-size:11px;font-family:monospace;"
|
||
onkeydown="if(event.key==='Enter')_vvImpBrowse(document.getElementById('vv-imp-path').value.trim()||'/')">
|
||
<button onclick="_vvImpNavUp()" title="Parent" style="background:#111;border:1px solid #222;color:#555;
|
||
border-radius:3px;padding:4px 8px;cursor:pointer;font-size:12px;flex-shrink:0;">↑</button>
|
||
<button onclick="_vvImpBrowse(document.getElementById('vv-imp-path').value.trim()||'/')" style="background:#111;
|
||
border:1px solid #222;color:#4a9eff;border-radius:3px;padding:4px 9px;cursor:pointer;font-size:11px;
|
||
white-space:nowrap;flex-shrink:0;">↻ Browse</button>
|
||
</div>
|
||
<span id="vv-imp-err" style="font-size:9px;color:#ef5350;display:none;margin-top:4px;"></span>
|
||
</div>
|
||
<div id="vv-imp-list" style="flex:1;overflow-y:auto;background:#080808;min-height:220px;"></div>
|
||
<div style="padding:10px 14px;border-top:1px solid #1e1e1e;display:flex;align-items:center;justify-content:space-between;gap:10px;">
|
||
<span id="vv-imp-selected" style="font-size:10px;color:#555;font-family:monospace;flex:1;min-width:0;
|
||
overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"></span>
|
||
<div style="display:flex;gap:6px;flex-shrink:0;">
|
||
<button onclick="vvImportScriptClose()" class="vv-btn-sm">Cancel</button>
|
||
<button id="vv-imp-do-btn" class="vv-btn-sm vv-save-script-btn-style" disabled onclick="_vvImpDoImport()">Import</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
modal.appendChild(box);
|
||
document.body.appendChild(modal);
|
||
return modal;
|
||
}
|
||
|
||
function _vvImpEsc(s) {
|
||
const d = document.createElement('div');
|
||
d.textContent = s;
|
||
return d.innerHTML;
|
||
}
|
||
|
||
function _vvImpBrowse(path) {
|
||
const listEl = document.getElementById('vv-imp-list');
|
||
const errEl = document.getElementById('vv-imp-err');
|
||
const pathEl = document.getElementById('vv-imp-path');
|
||
errEl.style.display = 'none';
|
||
listEl.innerHTML = '<div style="padding:10px;color:#444;font-size:11px;">Loading…</div>';
|
||
|
||
fetch(`/plugins/varaverk/api/import_script.php?action=browse&path=${encodeURIComponent(path)}&_=${Date.now()}`)
|
||
.then(r => r.json())
|
||
.then(d => {
|
||
if (!d.ok) {
|
||
listEl.innerHTML = '';
|
||
errEl.textContent = d.error || 'Browse failed';
|
||
errEl.style.display = '';
|
||
return;
|
||
}
|
||
pathEl.value = d.path;
|
||
_vvImpRender(d);
|
||
})
|
||
.catch(e => {
|
||
listEl.innerHTML = '';
|
||
errEl.textContent = 'Request failed: ' + e;
|
||
errEl.style.display = '';
|
||
});
|
||
}
|
||
|
||
function _vvImpNavUp() {
|
||
const cur = (document.getElementById('vv-imp-path').value || '/').replace(/\/$/, '') || '/';
|
||
const up = cur === '/' ? '/' : (cur.substring(0, cur.lastIndexOf('/')) || '/');
|
||
_vvImpBrowse(up);
|
||
}
|
||
|
||
function _vvImpRender(d) {
|
||
const listEl = document.getElementById('vv-imp-list');
|
||
let html = '';
|
||
|
||
if (d.parent !== null) {
|
||
const pname = d.parent === '/' ? '/' : (d.parent.replace(/^.*\//, '') || d.parent) + '/';
|
||
html += `<div class="vv-imp-row" onclick="_vvImpBrowse(${JSON.stringify(d.parent)})"
|
||
style="padding:5px 12px;font-size:11px;color:#444;font-style:italic;cursor:pointer;font-family:monospace;">↑ ${pname}</div>`;
|
||
}
|
||
|
||
for (const dir of d.dirs) {
|
||
const name = dir.replace(/^.*\//, '') || dir;
|
||
html += `<div class="vv-imp-row" onclick="_vvImpBrowse(${JSON.stringify(dir)})" title="${_vvImpEsc(dir)}"
|
||
style="padding:5px 12px;font-size:11px;color:#666;cursor:pointer;font-family:monospace;">▶ ${_vvImpEsc(name)}</div>`;
|
||
}
|
||
|
||
for (const file of d.files) {
|
||
const name = file.replace(/^.*\//, '') || file;
|
||
const sel = file === _vvImpSelected;
|
||
html += `<div class="vv-imp-row vv-imp-file${sel ? ' vv-imp-file-sel' : ''}" data-path="${_vvImpEsc(file)}"
|
||
onclick="_vvImpSelectFile(${JSON.stringify(file)})" title="${_vvImpEsc(file)}"
|
||
style="padding:5px 12px;font-size:11px;cursor:pointer;font-family:monospace;
|
||
color:${sel ? '#4caf50' : '#4a9eff'};background:${sel ? '#0f1f0f' : 'transparent'};">📄 ${_vvImpEsc(name)}</div>`;
|
||
}
|
||
|
||
if (!d.dirs.length && !d.files.length) {
|
||
html += '<div style="padding:10px 12px;color:#333;font-size:11px;">— empty —</div>';
|
||
}
|
||
|
||
listEl.innerHTML = html;
|
||
}
|
||
|
||
function _vvImpSelectFile(path) {
|
||
_vvImpSelected = path;
|
||
_vvImpUpdateFooter();
|
||
// Re-render just the highlight without a re-fetch.
|
||
document.querySelectorAll('#vv-imp-list .vv-imp-file').forEach(el => {
|
||
const isSel = el.dataset.path === path;
|
||
el.classList.toggle('vv-imp-file-sel', isSel);
|
||
el.style.color = isSel ? '#4caf50' : '#4a9eff';
|
||
el.style.background = isSel ? '#0f1f0f' : 'transparent';
|
||
});
|
||
}
|
||
|
||
function _vvImpUpdateFooter() {
|
||
const sel = document.getElementById('vv-imp-selected');
|
||
const btn = document.getElementById('vv-imp-do-btn');
|
||
if (!sel || !btn) return;
|
||
sel.textContent = _vvImpSelected || '';
|
||
btn.disabled = !_vvImpSelected;
|
||
}
|
||
|
||
function _vvImpDoImport() {
|
||
if (!_vvImpSelected) return;
|
||
const dest = (window.__vvCustomScriptsDir || 'Custom Scripts') + '/' + _vvImpSelected.replace(/^.*\//, '');
|
||
if (!confirm('Move\n ' + _vvImpSelected + '\n→ ' + dest + '\n\nThe original will be deleted once the copy is verified. Continue?')) return;
|
||
|
||
const btn = document.getElementById('vv-imp-do-btn');
|
||
btn.disabled = true;
|
||
btn.textContent = 'Importing…';
|
||
|
||
vvPost('/plugins/varaverk/api/import_script.php', {action: 'import', path: _vvImpSelected})
|
||
.then(d => {
|
||
if (!d.ok) {
|
||
alert('Import failed: ' + (d.error || 'Unknown error'));
|
||
btn.disabled = false;
|
||
btn.textContent = 'Import';
|
||
return;
|
||
}
|
||
if (d.warning) alert(d.warning);
|
||
window.location.reload();
|
||
})
|
||
.catch(e => {
|
||
alert('Import failed: ' + e);
|
||
btn.disabled = false;
|
||
btn.textContent = 'Import';
|
||
});
|
||
}
|
||
|
||
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-cancel-edit-btn').style.display = '';
|
||
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-log-search').style.display = 'none';
|
||
document.getElementById('vv-log-search').value = '';
|
||
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 = '';
|
||
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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||
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, #vv-si-view .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 click: navigate to the matching row in the Orch tree and open its info.
|
||
// Falls back to cog behaviour for scripts not in the tree (e.g. custom scripts).
|
||
function vvClickLabel(el) {
|
||
const job = el.closest('[data-id]');
|
||
const id = job.dataset.id;
|
||
const treeRow = document.querySelector('#vv-sb-tree [data-id="' + CSS.escape(id) + '"]');
|
||
if (!treeRow) { vvClickCog(el); return; }
|
||
// Expand parent sb-children if collapsed so the row is visible on back-nav
|
||
const parent = treeRow.parentElement;
|
||
if (parent?.classList.contains('vv-sb-children') && parent.style.display === 'none') {
|
||
parent.style.display = '';
|
||
const expand = parent.previousElementSibling?.querySelector('.vv-sb-expand');
|
||
if (expand) expand.textContent = '▾';
|
||
}
|
||
vvSelectScript(treeRow);
|
||
}
|
||
|
||
// Cog click: open settings / enriched info for this script (moved from label click).
|
||
function vvClickCog(el) {
|
||
const job = el.closest('[data-id]');
|
||
const id = job.dataset.id;
|
||
if (id.startsWith('Custom/')) {
|
||
vvAdvancedMode ? vvEditScript(id) : vvEditConf(id);
|
||
return;
|
||
}
|
||
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);
|
||
}
|
||
|
||
// ── 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 || expr === 'array_start' || expr === 'array_stop') 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 === 'array_start' || cron === 'array_stop') 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 });
|
||
});
|
||
// Tools and custom scripts with their own cron
|
||
document.querySelectorAll('.vv-tools-card .vv-script, .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>';
|
||
}
|
||
|
||
// Auto-expand a suggestions block body if it's currently collapsed.
|
||
function vvAutoExpand(bodyId) {
|
||
const body = document.getElementById(bodyId);
|
||
if (!body || body.style.display !== 'none') return;
|
||
body.style.display = '';
|
||
const chevron = body.previousElementSibling?.querySelector('.vv-sug-chevron');
|
||
if (chevron) chevron.textContent = '▾';
|
||
}
|
||
|
||
function vvErrIsAcked(script, ts) {
|
||
return parseInt(localStorage.getItem('vv-ack-' + script) || '0') >= ts;
|
||
}
|
||
|
||
function vvAckError(script, ts, btn) {
|
||
localStorage.setItem('vv-ack-' + script, ts);
|
||
const row = btn.closest('.vv-err-row');
|
||
const list = row?.closest('.vv-errors-list');
|
||
row?.remove();
|
||
const badge = document.getElementById('vv-errors-badge');
|
||
const body = document.getElementById('vv-errors-body');
|
||
const remaining = list?.querySelectorAll('.vv-err-row').length ?? 0;
|
||
if (!remaining && body)
|
||
body.innerHTML = '<div class="vv-board-placeholder">All errors acknowledged.</div>';
|
||
const cur = Math.max(0, parseInt(badge?.textContent || '0') - 1);
|
||
if (badge) { badge.textContent = cur || ''; badge.style.display = cur ? '' : 'none'; }
|
||
}
|
||
|
||
function vvUpdateErrors(errors) {
|
||
const body = document.getElementById('vv-errors-body');
|
||
const badge = document.getElementById('vv-errors-badge');
|
||
if (!body) return;
|
||
if (!errors.length) {
|
||
badge.style.display = 'none'; badge.textContent = '';
|
||
body.innerHTML = '<div class="vv-board-placeholder">No recent errors.</div>';
|
||
return;
|
||
}
|
||
// Filter out acknowledged entries
|
||
const unacked = errors.filter(e => !vvErrIsAcked(e.script, e.ts));
|
||
badge.style.display = unacked.length ? '' : 'none';
|
||
badge.textContent = unacked.length || '';
|
||
if (!unacked.length) {
|
||
body.innerHTML = '<div class="vv-board-placeholder">All errors acknowledged.</div>';
|
||
return;
|
||
}
|
||
const now = Math.floor(Date.now() / 1000);
|
||
let html = '<div class="vv-errors-list">';
|
||
for (const e of unacked) {
|
||
const jobId = "'" + (e.script + '.sh').replace(/\\/g,"\\\\").replace(/'/g,"\\'") + "'";
|
||
const label = e.script.split('/').pop().replace(/_/g, ' ');
|
||
html += '<div class="vv-err-row">'
|
||
+ '<div class="vv-err-top">'
|
||
+ '<span class="vv-err-script" onclick="vvOpenRight(' + jobId + ')" style="cursor:pointer" title="' + vvEscHtml(e.script) + '">' + vvEscHtml(label) + '</span>'
|
||
+ '<span class="vv-err-age">' + vvFmtAge(now - e.ts) + '</span>'
|
||
+ '<button class="vv-btn-sm vv-ack-btn" onclick="vvAckError(\'' + e.script.replace(/\\/g,"\\\\").replace(/'/g,"\\'") + "'," + e.ts + ',this)">Ack</button>'
|
||
+ '</div>'
|
||
+ '<div class="vv-err-line">' + vvEscHtml(e.line) + '</div>'
|
||
+ '</div>';
|
||
}
|
||
body.innerHTML = html + '</div>';
|
||
vvAutoExpand('vv-errors-body');
|
||
}
|
||
|
||
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 (expr === 'array_start' || expr === 'array_stop') 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;
|
||
});
|
||
}
|
||
|
||
// ── Log search ───────────────────────────────────────────────────────────────
|
||
|
||
function vvFilterLog(term) {
|
||
const pre = document.getElementById('vv-log-pre');
|
||
const raw = pre._raw || '';
|
||
if (!term.trim()) { pre.textContent = raw; return; }
|
||
const esc = s => s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||
const escRe = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||
const re = new RegExp('(' + escRe + ')', 'gi');
|
||
pre.innerHTML = raw.split('\n').map(line => {
|
||
const e = esc(line);
|
||
if (!line) return '';
|
||
if (re.test(line)) { re.lastIndex = 0; return e.replace(re, '<mark>$1</mark>'); }
|
||
re.lastIndex = 0;
|
||
return '<span class="vv-log-dim">' + e + '</span>';
|
||
}).join('\n');
|
||
}
|
||
|
||
// ── Cron humanizer ───────────────────────────────────────────────────────────
|
||
|
||
function vvHumanCron(expr) {
|
||
expr = (expr || '').trim();
|
||
if (!expr) return '';
|
||
if (expr === 'array_start') return 'on array start';
|
||
if (expr === 'array_stop') return 'on array stop';
|
||
const p = expr.split(/\s+/);
|
||
if (p.length !== 5) return '';
|
||
const [min, hour, dom, month, dow] = p;
|
||
const DAYS = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
|
||
const hhmm = (h, m) => {
|
||
const H = parseInt(h), M = parseInt(m);
|
||
if (H === 0 && M === 0) return 'midnight';
|
||
if (H === 12 && M === 0) return 'noon';
|
||
const ampm = H < 12 ? 'am' : 'pm';
|
||
const h12 = H === 0 ? 12 : H > 12 ? H - 12 : H;
|
||
return M === 0 ? `${h12}${ampm}` : `${h12}:${String(M).padStart(2,'0')}${ampm}`;
|
||
};
|
||
// Every minute / every N minutes
|
||
if (min === '*' && [hour,dom,month,dow].every(f => f === '*')) return 'every minute';
|
||
if (/^\*\/(\d+)$/.test(min) && [hour,dom,month,dow].every(f => f === '*'))
|
||
return parseInt(min.slice(2)) === 1 ? 'every minute' : 'every ' + min.slice(2) + ' min';
|
||
// Every hour / every N hours
|
||
if (min === '0' && hour === '*' && [dom,month,dow].every(f => f === '*')) return 'every hour';
|
||
if (min === '0' && /^\*\/(\d+)$/.test(hour) && [dom,month,dow].every(f => f === '*'))
|
||
return parseInt(hour.slice(2)) === 1 ? 'every hour' : 'every ' + hour.slice(2) + ' hours';
|
||
if (!/^\d+$/.test(min) || !/^\d+$/.test(hour)) return '';
|
||
const T = hhmm(hour, min);
|
||
// Weekdays / weekends
|
||
if (dom === '*' && month === '*' && dow === '1-5') return 'weekdays at ' + T;
|
||
if (dom === '*' && month === '*' && /^(6,0|0,6)$/.test(dow)) return 'weekends at ' + T;
|
||
// Nth weekday of month (1-7 = 1st, 8-14 = 2nd, 15-21 = 3rd, 22-28 = 4th)
|
||
const NTH = [[1,7,'1st'],[8,14,'2nd'],[15,21,'3rd'],[22,28,'4th']];
|
||
for (const [lo,hi,lbl] of NTH) {
|
||
if (dom === `${lo}-${hi}` && month === '*' && /^\d+$/.test(dow))
|
||
return `${lbl} ${DAYS[parseInt(dow)] ?? 'day'} of month at ${T}`;
|
||
}
|
||
// Monthly on any specific day-of-month
|
||
if (/^\d+$/.test(dom) && month === '*' && dow === '*') {
|
||
const d = parseInt(dom);
|
||
const sfx = [11,12,13].includes(d) ? 'th'
|
||
: d % 10 === 1 ? 'st' : d % 10 === 2 ? 'nd' : d % 10 === 3 ? 'rd' : 'th';
|
||
return (d === 1 ? 'monthly' : `${d}${sfx} of every month`) + ' at ' + T;
|
||
}
|
||
// Specific day(s) of week
|
||
if (dom === '*' && month === '*' && /^\d+$/.test(dow))
|
||
return (DAYS[parseInt(dow)] ?? 'day ' + dow) + 's at ' + T;
|
||
if (dom === '*' && month === '*' && /^\d+(,\d+)+$/.test(dow))
|
||
return dow.split(',').map(d => DAYS[parseInt(d)] ?? 'd'+d).join('/') + ' at ' + T;
|
||
// Daily
|
||
if (dom === '*' && month === '*' && dow === '*') return 'daily at ' + T;
|
||
return '';
|
||
}
|
||
|
||
// ── Natural language → cron expression ──────────────────────────────────────
|
||
|
||
function vvNlToCron(text) {
|
||
const t = text.toLowerCase().replace(/\s+/g, ' ').trim();
|
||
let m;
|
||
// Pure interval patterns (no time needed)
|
||
if (/\bevery\s+minute\b/.test(t)) return '* * * * *';
|
||
if (/\bhourly\b/.test(t)) return '0 * * * *';
|
||
if (/\bevery\s+hour\b/.test(t) && !/\d+\s*h/.test(t)) return '0 * * * *';
|
||
if ((m = t.match(/\bevery\s+(\d+)\s*(?:minutes?|mins?)\b/))) return `*/${m[1]} * * * *`;
|
||
if ((m = t.match(/\bevery\s+(\d+)\s*hours?\b/))) return `0 */${m[1]} * * *`;
|
||
|
||
// Parse time — optional, defaults to midnight when not given
|
||
const parseTime = s => {
|
||
let h = -1, min = 0, tm;
|
||
if (/\bmidnight\b/.test(s)) { h = 0; min = 0; }
|
||
else if (/\bnoon\b/.test(s)) { h = 12; min = 0; }
|
||
else if ((tm = s.match(/\b(\d{1,2}):(\d{2})\s*(am|pm)?\b/))) { h = parseInt(tm[1]); min = parseInt(tm[2]); if (tm[3]==='pm'&&h<12) h+=12; if (tm[3]==='am'&&h===12) h=0; }
|
||
else if ((tm = s.match(/\b(\d{1,2})\s*(am|pm)\b/))) { h = parseInt(tm[1]); if (tm[2]==='pm'&&h<12) h+=12; if (tm[2]==='am'&&h===12) h=0; }
|
||
else if ((tm = s.match(/\bat\s+(\d{1,2}):(\d{2})\b/))) { h = parseInt(tm[1]); min = parseInt(tm[2]); }
|
||
return h >= 0 && h < 24 ? {h, min} : null;
|
||
};
|
||
const time = parseTime(t);
|
||
const H = time?.h ?? 0; // default midnight
|
||
const M = time?.min ?? 0;
|
||
|
||
// Ordinals: 1st/2nd/3rd/4th and first/second/third/fourth
|
||
const ORD = {first:1,'1st':1, second:2,'2nd':2, third:3,'3rd':3, fourth:4,'4th':4};
|
||
const ordM = t.match(/\b(first|1st|second|2nd|third|3rd|fourth|4th)\b/);
|
||
const ordN = ordM ? ORD[ordM[1]] : null;
|
||
|
||
const DOW = {sun:0,sunday:0,mon:1,monday:1,tue:2,tuesday:2,wed:3,wednesday:3,
|
||
thu:4,thursday:4,fri:5,friday:5,sat:6,saturday:6};
|
||
const dayHits = [...t.matchAll(/\b(sun(?:day)?|mon(?:day)?|tue(?:s(?:day)?)?|wed(?:nesday)?|thu(?:rs(?:day)?)?|fri(?:day)?|sat(?:urday)?)\b/g)];
|
||
|
||
// Nth weekday of month: "every 2nd sunday", "first monday of the month"
|
||
if (ordN && dayHits.length) {
|
||
const key = dayHits[0][1].slice(0, 3);
|
||
const dayNum = DOW[key] ?? DOW[dayHits[0][1]];
|
||
if (dayNum !== undefined) {
|
||
const lo = (ordN - 1) * 7 + 1, hi = ordN * 7;
|
||
return `${M} ${H} ${lo}-${hi} * ${dayNum}`;
|
||
}
|
||
}
|
||
|
||
// Monthly (no time required — defaults to midnight)
|
||
if (/\bmonthly\b|\bevery\s+month\b|\b(?:once\s+a\s+month)\b/.test(t)) return `${M} ${H} 1 * *`;
|
||
|
||
// Day-of-week patterns
|
||
if (/\bweekdays?\b/.test(t) && !/weekend/.test(t)) return `${M} ${H} * * 1-5`;
|
||
if (/\bweekends?\b/.test(t)) return `${M} ${H} * * 6,0`;
|
||
|
||
if (dayHits.length) {
|
||
const nums = [...new Set(dayHits.map(d => { const k=d[1].slice(0,3); return DOW[k]??DOW[d[1]]; }).filter(n=>n!==undefined))];
|
||
if (nums.length) return `${M} ${H} * * ${nums.join(',')}`;
|
||
}
|
||
|
||
// Require explicit time for a plain "daily" default
|
||
if (!time) return null;
|
||
return `${M} ${H} * * *`;
|
||
}
|
||
|
||
// ── Cron calculator state ────────────────────────────────────────────────────
|
||
|
||
let vvCalcTarget = null;
|
||
let vvCalcExpr = '';
|
||
|
||
document.addEventListener('focusin', e => {
|
||
if (!e.target.classList.contains('vv-cron')) return;
|
||
if (e.target.classList.contains('vv-rsync-standalone')) return;
|
||
if (!e.target.closest('#vv-sched-left')) return;
|
||
vvCalcTarget = e.target;
|
||
const label = e.target.closest('[data-id]')?.querySelector('.vv-job-label')?.textContent.trim() ?? '';
|
||
const lbl = document.getElementById('vv-calc-target-label');
|
||
if (lbl) lbl.textContent = label || 'selected';
|
||
const applyBtn = document.getElementById('vv-calc-apply');
|
||
if (applyBtn && vvCalcExpr) applyBtn.style.display = '';
|
||
});
|
||
|
||
function vvCalcNextFires(expr, n) {
|
||
const parts = expr.split(/\s+/);
|
||
const [mF, hF, dF, moF, wF] = parts;
|
||
const match = (f, v) => {
|
||
if (f==='*') return true;
|
||
if (/^\*\/\d+$/.test(f)) return v % parseInt(f.slice(2)) === 0;
|
||
if (/^\d+$/.test(f)) return parseInt(f) === v;
|
||
if (/^\d+-\d+$/.test(f)) { const [a,b]=f.split('-').map(Number); return v>=a&&v<=b; }
|
||
if (f.includes(',')) return f.split(',').some(x=>parseInt(x)===v);
|
||
return false;
|
||
};
|
||
const fires = [], t = new Date();
|
||
t.setSeconds(0,0); t.setMinutes(t.getMinutes()+1);
|
||
for (let i = 0; fires.length < n && i < 10080; i++, t.setMinutes(t.getMinutes()+1)) {
|
||
if (match(moF,t.getMonth()+1) && match(dF,t.getDate()) && match(wF,t.getDay()) &&
|
||
match(hF,t.getHours()) && match(mF,t.getMinutes())) fires.push(new Date(t));
|
||
}
|
||
return fires;
|
||
}
|
||
|
||
function vvCalcUpdate(val) {
|
||
const result = document.getElementById('vv-calc-result');
|
||
const applyBtn = document.getElementById('vv-calc-apply');
|
||
val = val.trim();
|
||
if (!val) { result.innerHTML = ''; applyBtn.style.display = 'none'; vvCalcExpr = ''; return; }
|
||
|
||
let expr = '', desc = '', hint = '';
|
||
|
||
if (val === 'array_start' || val === 'array_stop') {
|
||
expr = val; desc = vvHumanCron(val) || val;
|
||
} else if (vvValidateCronExpr(val) && val.split(/\s+/).length === 5) {
|
||
expr = val; desc = vvHumanCron(val);
|
||
} else {
|
||
const nl = vvNlToCron(val);
|
||
if (nl) { expr = nl; desc = vvHumanCron(nl) || nl; }
|
||
else hint = 'Try: "every 15 min", "daily at 3am", "sundays at 2:30am", "weekdays at 9am"';
|
||
}
|
||
|
||
vvCalcExpr = expr;
|
||
|
||
if (hint) {
|
||
result.innerHTML = '<div class="vv-calc-hint">' + vvEscHtml(hint) + '</div>';
|
||
applyBtn.style.display = 'none';
|
||
return;
|
||
}
|
||
|
||
const esc = vvEscHtml;
|
||
let html = '<div class="vv-calc-expr">' + esc(expr) + '</div>';
|
||
if (desc) html += '<div class="vv-calc-desc">' + esc(desc) + '</div>';
|
||
|
||
if (expr !== 'array_start' && expr !== 'array_stop') {
|
||
const fires = vvCalcNextFires(expr, 5);
|
||
if (fires.length) {
|
||
const now = new Date();
|
||
html += '<div class="vv-calc-runs-lbl">Next ' + fires.length + ' fires</div><div class="vv-calc-runs">';
|
||
for (const f of fires) {
|
||
const dur = vvFmtDuration(f - now);
|
||
const at = f.toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'});
|
||
const day = f.toLocaleDateString([], {weekday:'short', month:'short', day:'numeric'});
|
||
html += '<div class="vv-calc-run-row"><span class="vv-calc-run-in">in ' + dur + '</span>'
|
||
+ '<span class="vv-calc-run-at">' + day + ' · ' + at + '</span></div>';
|
||
}
|
||
html += '</div>';
|
||
}
|
||
}
|
||
|
||
result.innerHTML = html;
|
||
applyBtn.style.display = vvCalcTarget ? '' : 'none';
|
||
}
|
||
|
||
function vvCalcApply() {
|
||
if (!vvCalcExpr || !vvCalcTarget) return;
|
||
vvCalcTarget.value = vvCalcExpr;
|
||
vvUpdateCronHint(vvCalcTarget);
|
||
vvSaveCronBlur(vvCalcTarget);
|
||
const btn = document.getElementById('vv-calc-apply');
|
||
const lbl = document.getElementById('vv-calc-target-label');
|
||
const saved = btn.innerHTML;
|
||
btn.innerHTML = '✓ Applied to ' + (lbl?.textContent ?? 'job');
|
||
btn.style.color = '#4caf50';
|
||
setTimeout(() => { btn.innerHTML = saved; btn.style.color = ''; }, 1800);
|
||
}
|
||
|
||
function vvUpdateCronHint(input) {
|
||
if (!input || input.classList.contains('vv-rsync-standalone')) return;
|
||
// No hint when the row already shows an ⚡ array-event badge
|
||
const row = input.closest('.vv-job-row');
|
||
if (row?.querySelector('.vv-event-badge')) {
|
||
const stale = input.previousElementSibling;
|
||
if (stale?.classList.contains('vv-cron-hint')) stale.style.display = 'none';
|
||
return;
|
||
}
|
||
const hint = vvHumanCron(input.value);
|
||
let el = input.previousElementSibling;
|
||
if (!el || !el.classList.contains('vv-cron-hint')) {
|
||
el = document.createElement('span');
|
||
el.className = 'vv-cron-hint';
|
||
input.before(el);
|
||
}
|
||
el.textContent = hint;
|
||
el.style.display = hint ? '' : 'none';
|
||
}
|
||
|
||
// ── Recent Activity ──────────────────────────────────────────────────────────
|
||
|
||
function vvTimeAgo(s) {
|
||
if (s < 60) return s + 's ago';
|
||
if (s < 3600) return Math.floor(s/60) + 'm ago';
|
||
if (s < 86400) return Math.floor(s/3600) + 'h ago';
|
||
return Math.floor(s/86400) + 'd ago';
|
||
}
|
||
function vvFmtDur(s) {
|
||
if (s <= 0) return '—';
|
||
if (s < 60) return s + 's';
|
||
if (s < 3600) return Math.floor(s/60) + 'm ' + (s%60) + 's';
|
||
return Math.floor(s/3600) + 'h ' + Math.floor((s%3600)/60) + 'm';
|
||
}
|
||
|
||
function vvLoadRecentActivity() {
|
||
const body = document.getElementById('vv-activity-body');
|
||
const badge = document.getElementById('vv-activity-badge');
|
||
if (!body) return;
|
||
fetch('/plugins/varaverk/api/recent.php')
|
||
.then(r => r.json())
|
||
.then(d => {
|
||
if (!d.ok || !d.runs?.length) {
|
||
body.innerHTML = '<div class="vv-board-placeholder">No completed runs yet.</div>';
|
||
if (badge) badge.style.display = 'none';
|
||
return;
|
||
}
|
||
const now = Math.floor(Date.now() / 1000);
|
||
let errors = 0;
|
||
let html = '<div class="vv-activity-list">';
|
||
for (const r of d.runs) {
|
||
const cls = r.status === 'ok' ? 'vv-stat-ok' : r.status === 'warn' ? 'vv-stat-warn' : 'vv-stat-error';
|
||
if (r.status !== 'ok' && r.status !== 'skipped') errors++;
|
||
html += '<div class="vv-activity-row" onclick="vvOpenRight(' + JSON.stringify(r.id) + ')">'
|
||
+ '<span class="vv-activity-dot ' + cls + '">●</span>'
|
||
+ '<span class="vv-activity-label">' + vvEscHtml(r.label) + '</span>'
|
||
+ '<span class="vv-activity-ago">' + vvTimeAgo(now - r.start) + '</span>'
|
||
+ '<span class="vv-activity-dur">' + vvFmtDur(r.dur) + '</span>'
|
||
+ '</div>';
|
||
}
|
||
html += '</div>';
|
||
body.innerHTML = html;
|
||
if (badge) { badge.textContent = errors || ''; badge.style.display = errors ? '' : 'none'; }
|
||
if (errors) vvAutoExpand('vv-activity-body');
|
||
})
|
||
.catch(() => {});
|
||
}
|
||
|
||
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();
|
||
vvLoadRecentActivity();
|
||
if (!vvBoardTimer) {
|
||
vvBoardTimer = setInterval(() => { vvBuildNextRuns(); vvBoardPoll(); vvLoadRecentActivity(); }, 30000);
|
||
}
|
||
// Live cron validation + humanizer hint on input
|
||
document.getElementById('vv-sched-left').addEventListener('input', e => {
|
||
if (!e.target.classList.contains('vv-cron')) return;
|
||
const ok = vvValidateCronExpr(e.target.value.trim());
|
||
e.target.style.borderColor = e.target.value.trim() && !ok ? '#f44336' : '';
|
||
vvUpdateCronHint(e.target);
|
||
});
|
||
// Seed hints for all existing cron values on load
|
||
document.querySelectorAll('#vv-sched-left input.vv-cron').forEach(vvUpdateCronHint);
|
||
}
|
||
|
||
// 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,'&').replace(/</g,'<').replace(/>/g,'>');
|
||
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();
|
||
vvUndoCaptureInitial();
|
||
vvEditorCursorMoved();
|
||
requestAnimationFrame(vvFitRight);
|
||
})
|
||
.catch(() => { document.getElementById('vv-editor-body').value = '# Load failed'; });
|
||
}
|
||
|
||
function vvShowRawConfMode(title) {
|
||
vvEditorReset();
|
||
vvRestoreEditorPrefs();
|
||
document.getElementById('vv-editor-status').classList.add('vv-ed-active');
|
||
document.getElementById('vv-es-lang').textContent = 'Bash / Config';
|
||
document.getElementById('vv-undo-btn').style.display = '';
|
||
document.getElementById('vv-redo-btn').style.display = '';
|
||
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-cancel-edit-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 = '';
|
||
document.getElementById('vv-advanced-mode-btn').style.display = 'none';
|
||
document.getElementById('vv-delete-script-btn').style.display = 'none';
|
||
document.getElementById('vv-log-search').style.display = 'none';
|
||
document.getElementById('vv-log-search').value = '';
|
||
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 = '';
|
||
if (vvPollTimer) { clearInterval(vvPollTimer); vvPollTimer = null; }
|
||
requestAnimationFrame(vvFitRight);
|
||
}
|
||
|
||
function vvSaveRawConf() {
|
||
if (!vvRawConfFile) return;
|
||
if (!vvSetupConf && !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 = 'Save Conf';
|
||
alert('Save failed: ' + (d.error ?? 'Unknown error'));
|
||
return;
|
||
}
|
||
|
||
// Setup mode: forced editing sequence
|
||
if (vvSetupConf) {
|
||
if (vvRawConfFile === 'master.conf') {
|
||
// master.conf saved → open host conf next
|
||
btn.textContent = '✓ Saved — opening ' + vvLocalHostConf + '…';
|
||
vvSetupConf = vvLocalHostConf;
|
||
history.replaceState(null, '', location.pathname + '?tab=scheduler&vv_setup=' + encodeURIComponent(vvLocalHostConf));
|
||
setTimeout(() => vvLoadRawConf(vvLocalHostConf), 400);
|
||
} else {
|
||
// Host conf saved → go to Partnership so SSH keys get set up
|
||
vvSetupConf = '';
|
||
btn.textContent = '✓ Done — opening Partnership…';
|
||
setTimeout(() => { window.location.href = location.pathname + '?tab=partnership&vv_onboard=1'; }, 800);
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Normal save: show push status
|
||
// ready:false = partner not set up yet (pre-onboard) — treat as silent, not an error
|
||
const push = d.push ?? [];
|
||
const synced = push.filter(p => p.ok);
|
||
const realFailed = push.filter(p => !p.ok && p.ready !== false);
|
||
if (realFailed.length > 0) {
|
||
btn.textContent = '✓ Saved · push failed: ' + realFailed.map(p => p.host).join(', ');
|
||
} else if (synced.length > 0) {
|
||
btn.textContent = '✓ Saved · synced to ' + synced.map(p => p.host).join(', ');
|
||
} else {
|
||
btn.textContent = '✓ Saved';
|
||
}
|
||
setTimeout(() => { btn.textContent = 'Save Conf'; }, 3500);
|
||
})
|
||
.catch(() => { btn.disabled = false; btn.textContent = 'Save Conf'; });
|
||
}
|
||
|
||
// ── Highlighted conf editor overlay ──────────────────────────────────────────
|
||
|
||
// ── Overlay helpers ────────────────────────────────────────────────────────────
|
||
|
||
// Annotate text occurrences in already-rendered HTML (text between > and < only).
|
||
// Returns modified HTML with <span class=cls> wrapping each whole-word match.
|
||
function vvMarkInHtml(html, word, cls, caseInsensitive) {
|
||
if (!word || word.length < 1) return html;
|
||
const hw = word.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||
const esc = hw.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||
const fl = caseInsensitive ? 'gi' : 'g';
|
||
const re = new RegExp('(?<![\\w$])' + esc + '(?![\\w])', fl);
|
||
return html.replace(/>((?:[^<])*)</g, (_, txt) =>
|
||
'>' + txt.replace(re, `<span class="${cls}">$&</span>`) + '<'
|
||
);
|
||
}
|
||
|
||
// Like vvMarkInHtml but marks the curIdx-th match differently.
|
||
function vvMarkSearchInHtml(html, term, curIdx) {
|
||
if (!term) return html;
|
||
const hw = term.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||
const esc = hw.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||
const re = new RegExp(esc, 'gi');
|
||
let idx = 0;
|
||
return html.replace(/>((?:[^<])*)</g, (_, txt) => {
|
||
const repl = txt.replace(re, m => {
|
||
const cls = idx++ === curIdx ? 'vv-search-mark-current' : 'vv-search-mark';
|
||
return `<span class="${cls}">${m}</span>`;
|
||
});
|
||
return '>' + repl + '<';
|
||
});
|
||
}
|
||
|
||
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');
|
||
|
||
// Pin the overlay to the textarea's client box instead of letting it stretch to
|
||
// #vv-editor-inner. In highlight mode the textarea's text is transparent, so the overlay IS
|
||
// the visible document, and it is scrolled only by copying ta.scrollTop. That copy is exact
|
||
// only while both boxes are the same height — and nothing enforced that: vvFitRight() sizes
|
||
// the textarea to an explicit pixel height while the overlay inherits `top:0; bottom:0` from
|
||
// a flex:1 parent. When the parent came out taller, the overlay's scrollable range was the
|
||
// shorter of the two, so it reached its own end while the textarea kept going and the visible
|
||
// text froze a few lines short of the file. Reported against master.conf: stuck at 1683 of
|
||
// 1689, and unchanged by expanding or collapsing the job tree, which is what proved it a fixed
|
||
// offset rather than anything to do with contentH.
|
||
//
|
||
// clientHeight, not offsetHeight: it already excludes the horizontal scrollbar the textarea
|
||
// grows on long conf lines and the overlay never has, which was part of the same mismatch.
|
||
// With top and bottom both set, an explicit height wins and bottom is ignored.
|
||
ov.style.height = ta.clientHeight + 'px';
|
||
|
||
let html = vvHl(ta.value, false) + '\n';
|
||
if (vvFindTerm) {
|
||
html = vvMarkSearchInHtml(html, vvFindTerm, vvFindIdx);
|
||
} else if (vvWordMatch) {
|
||
html = vvMarkInHtml(html, vvWordMatch, 'vv-word-mark', false);
|
||
}
|
||
ov.innerHTML = html;
|
||
ov.scrollTop = ta.scrollTop;
|
||
ov.scrollLeft = ta.scrollLeft;
|
||
vvUpdateLineNums();
|
||
vvUpdateCurLine();
|
||
}
|
||
|
||
function vvSyncHlScroll() {
|
||
const ta = document.getElementById('vv-editor-body');
|
||
const ov = document.getElementById('vv-hl-overlay');
|
||
if (!ov || !document.getElementById('vv-editor').classList.contains('vv-editor-hl')) return;
|
||
ov.scrollTop = ta.scrollTop;
|
||
ov.scrollLeft = ta.scrollLeft;
|
||
const gutter = document.getElementById('vv-ln-gutter');
|
||
if (gutter) gutter.scrollTop = ta.scrollTop;
|
||
vvUpdateCurLine();
|
||
}
|
||
|
||
function vvUpdateLineNums() {
|
||
const gutter = document.getElementById('vv-ln-gutter');
|
||
const ta = document.getElementById('vv-editor-body');
|
||
if (!gutter || !ta || vvWordWrap) return;
|
||
gutter.style.lineHeight = vvLH() + 'px';
|
||
const lines = ta.value.split('\n').length;
|
||
const curLn = ta.value.slice(0, ta.selectionStart).split('\n').length;
|
||
let html = '';
|
||
for (let i = 1; i <= lines; i++) {
|
||
html += i === curLn ? `<span class="vv-gln-cur">${i}</span>\n` : `${i}\n`;
|
||
}
|
||
gutter.innerHTML = html;
|
||
gutter.scrollTop = ta.scrollTop;
|
||
}
|
||
|
||
// Position the current-line highlight strip behind the overlay
|
||
function vvUpdateCurLine() {
|
||
const ta = document.getElementById('vv-editor-body');
|
||
const cl = document.getElementById('vv-cur-line');
|
||
if (!cl || !ta || vvWordWrap) return;
|
||
const lineH = vvLH();
|
||
const padTop = parseFloat(window.getComputedStyle(ta).paddingTop);
|
||
const lineN = ta.value.slice(0, ta.selectionStart).split('\n').length - 1; // 0-indexed
|
||
cl.style.top = (ta.offsetTop + padTop + lineN * lineH - ta.scrollTop) + 'px';
|
||
cl.style.height = lineH + 'px';
|
||
cl.style.display = '';
|
||
}
|
||
|
||
// Called on cursor move (keyup, mouseup, click) — update word match, status bar, cur-line
|
||
function vvEditorCursorMoved() {
|
||
vvUpdateCurLine();
|
||
vvUpdateEditorStatus();
|
||
|
||
if (vvFindTerm) { vvSyncHlOverlay(); return; } // find mode owns the overlay
|
||
|
||
const ta = document.getElementById('vv-editor-body');
|
||
const start = ta.selectionStart;
|
||
const end = ta.selectionEnd;
|
||
const val = ta.value;
|
||
|
||
// If user selected a word (not whitespace, at least 2 chars) → highlight all occurrences
|
||
if (end > start) {
|
||
const sel = val.slice(start, end);
|
||
const word = /^[\w$]{2,}$/.test(sel) ? sel : null;
|
||
if (word !== vvWordMatch) { vvWordMatch = word; vvSyncHlOverlay(); }
|
||
return;
|
||
}
|
||
|
||
// Find the identifier word at cursor position
|
||
const before = val.slice(0, start);
|
||
const after = val.slice(start);
|
||
const wBefore = before.match(/[$A-Za-z_][\w]*$/) ?? [''];
|
||
const wAfter = after.match(/^[\w]+/) ?? [''];
|
||
const word = wBefore[0].replace(/^\$/, '') + wAfter[0]; // strip leading $ for matching
|
||
const newWord = word.length >= 2 ? word : null;
|
||
if (newWord !== vvWordMatch) { vvWordMatch = newWord; vvSyncHlOverlay(); }
|
||
}
|
||
|
||
function vvUpdateEditorStatus() {
|
||
const ta = document.getElementById('vv-editor-body');
|
||
const pos = document.getElementById('vv-es-pos');
|
||
const sel = document.getElementById('vv-es-sel');
|
||
if (!ta || !pos) return;
|
||
const start = ta.selectionStart;
|
||
const end = ta.selectionEnd;
|
||
const val = ta.value;
|
||
const before = val.slice(0, start);
|
||
const lines = before.split('\n');
|
||
pos.textContent = `Ln ${lines.length}, Col ${lines[lines.length - 1].length + 1}`;
|
||
if (end > start) {
|
||
const chars = end - start;
|
||
const lns = val.slice(start, end).split('\n').length;
|
||
sel.textContent = lns > 1 ? `(${chars} chars · ${lns} lines)` : `(${chars} chars)`;
|
||
} else {
|
||
sel.textContent = '';
|
||
}
|
||
}
|
||
|
||
// ── Find bar ───────────────────────────────────────────────────────────────────
|
||
|
||
function vvFindOpen() {
|
||
const bar = document.getElementById('vv-editor-find');
|
||
bar.classList.add('vv-find-open');
|
||
const inp = document.getElementById('vv-find-input');
|
||
// Pre-fill with selected word if any
|
||
const ta = document.getElementById('vv-editor-body');
|
||
const sel = ta.value.slice(ta.selectionStart, ta.selectionEnd);
|
||
if (sel && /^\w+$/.test(sel)) { inp.value = sel; }
|
||
inp.focus(); inp.select();
|
||
vvFindSearch();
|
||
}
|
||
|
||
function vvFindClose() {
|
||
document.getElementById('vv-editor-find').classList.remove('vv-find-open');
|
||
document.getElementById('vv-find-input').value = '';
|
||
document.getElementById('vv-find-input').classList.remove('vv-find-no-match');
|
||
document.getElementById('vv-find-count').textContent = '';
|
||
vvFindTerm = ''; vvFindMatches = []; vvFindIdx = 0;
|
||
document.getElementById('vv-editor-body').focus();
|
||
vvSyncHlOverlay();
|
||
}
|
||
|
||
function vvFindSearch() {
|
||
const inp = document.getElementById('vv-find-input');
|
||
const cnt = document.getElementById('vv-find-count');
|
||
vvFindTerm = inp.value;
|
||
if (!vvFindTerm) {
|
||
vvFindMatches = []; vvFindIdx = 0;
|
||
cnt.textContent = '';
|
||
inp.classList.remove('vv-find-no-match');
|
||
vvSyncHlOverlay(); return;
|
||
}
|
||
const ta = document.getElementById('vv-editor-body');
|
||
const re = new RegExp(vvFindTerm.replace(/[.*+?^${}()|[\]\\]/g,'\\$&'), 'gi');
|
||
vvFindMatches = [];
|
||
let m;
|
||
while ((m = re.exec(ta.value)) !== null) vvFindMatches.push({start: m.index, end: m.index + m[0].length});
|
||
if (!vvFindMatches.length) {
|
||
vvFindIdx = 0;
|
||
cnt.textContent = 'no matches';
|
||
inp.classList.add('vv-find-no-match');
|
||
} else {
|
||
vvFindIdx = Math.min(vvFindIdx, vvFindMatches.length - 1);
|
||
cnt.textContent = `${vvFindIdx + 1} / ${vvFindMatches.length}`;
|
||
inp.classList.remove('vv-find-no-match');
|
||
const cur = vvFindMatches[vvFindIdx];
|
||
ta.selectionStart = cur.start; ta.selectionEnd = cur.end;
|
||
}
|
||
vvSyncHlOverlay();
|
||
}
|
||
|
||
function vvFindNav(dir) {
|
||
if (!vvFindMatches.length) return;
|
||
vvFindIdx = (vvFindIdx + dir + vvFindMatches.length) % vvFindMatches.length;
|
||
document.getElementById('vv-find-count').textContent = `${vvFindIdx + 1} / ${vvFindMatches.length}`;
|
||
const ta = document.getElementById('vv-editor-body');
|
||
const cur = vvFindMatches[vvFindIdx];
|
||
ta.selectionStart = cur.start; ta.selectionEnd = cur.end;
|
||
ta.focus();
|
||
const lineN = ta.value.slice(0, cur.start).split('\n').length - 1;
|
||
ta.scrollTop = Math.max(0, lineN * vvLH() - 80);
|
||
vvSyncHlOverlay();
|
||
}
|
||
|
||
function vvFindKeydown(e) {
|
||
if (e.key === 'Enter') { e.preventDefault(); vvFindNav(e.shiftKey ? -1 : 1); }
|
||
if (e.key === 'Escape') { e.preventDefault(); vvFindClose(); }
|
||
}
|
||
|
||
// ── Find & Replace ────────────────────────────────────────────────────────────
|
||
|
||
function vvFindToggleReplace(forceOpen) {
|
||
const row = document.getElementById('vv-replace-row');
|
||
const btn = document.getElementById('vv-find-expand');
|
||
const show = forceOpen !== undefined ? forceOpen : !row.classList.contains('vv-repl-open');
|
||
row.classList.toggle('vv-repl-open', show);
|
||
btn.classList.toggle('open', show);
|
||
if (show) document.getElementById('vv-replace-input').focus();
|
||
}
|
||
|
||
function vvReplaceOne() {
|
||
if (!vvFindMatches.length) return;
|
||
const ta = document.getElementById('vv-editor-body');
|
||
const rep = document.getElementById('vv-replace-input').value;
|
||
const m = vvFindMatches[vvFindIdx];
|
||
vvUndoCapture(true);
|
||
ta.value = ta.value.slice(0, m.start) + rep + ta.value.slice(m.end);
|
||
const prevIdx = vvFindIdx;
|
||
vvFindSearch();
|
||
if (vvFindMatches.length) {
|
||
vvFindIdx = Math.min(prevIdx, vvFindMatches.length - 1);
|
||
document.getElementById('vv-find-count').textContent = `${vvFindIdx + 1} / ${vvFindMatches.length}`;
|
||
const cur = vvFindMatches[vvFindIdx];
|
||
ta.selectionStart = cur.start; ta.selectionEnd = cur.end;
|
||
}
|
||
vvSyncHlOverlay(); vvUndoCapture(true);
|
||
}
|
||
|
||
function vvReplaceAll() {
|
||
if (!vvFindTerm) return;
|
||
const ta = document.getElementById('vv-editor-body');
|
||
const rep = document.getElementById('vv-replace-input').value;
|
||
const re = new RegExp(vvFindTerm.replace(/[.*+?^${}()|[\]\\]/g,'\\$&'), 'gi');
|
||
let n = 0;
|
||
vvUndoCapture(true);
|
||
ta.value = ta.value.replace(re, () => { n++; return rep; });
|
||
ta.selectionStart = ta.selectionEnd = 0;
|
||
vvFindSearch();
|
||
document.getElementById('vv-find-count').textContent = n ? `replaced ${n}` : 'no matches';
|
||
vvUndoCapture(true);
|
||
}
|
||
|
||
function vvReplaceKeydown(e) {
|
||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); vvReplaceOne(); }
|
||
if (e.key === 'Enter' && e.shiftKey) { e.preventDefault(); vvFindNav(-1); }
|
||
if (e.key === 'Escape') { e.preventDefault(); vvFindClose(); }
|
||
}
|
||
|
||
// ── Font size ─────────────────────────────────────────────────────────────────
|
||
|
||
function vvFontSize(delta) {
|
||
vvFontSizePx = Math.max(9, Math.min(22, vvFontSizePx + delta));
|
||
localStorage.setItem('vv-editor-fontsize', vvFontSizePx);
|
||
const fs = vvFontSizePx + 'px';
|
||
const lh = (vvFontSizePx * 1.5) + 'px';
|
||
const els = ['vv-editor-body', 'vv-hl-overlay', 'vv-ln-gutter'];
|
||
els.forEach(id => {
|
||
const el = document.getElementById(id);
|
||
if (el) { el.style.fontSize = fs; el.style.lineHeight = lh; el.style.fontFamily = 'monospace'; }
|
||
});
|
||
const cl = document.getElementById('vv-cur-line');
|
||
if (cl) cl.style.height = lh;
|
||
const fs_el = document.getElementById('vv-es-fontsize');
|
||
if (fs_el) fs_el.textContent = fs;
|
||
vvSyncHlOverlay(); vvUpdateCurLine(); vvUpdateEditorStatus();
|
||
}
|
||
|
||
function vvRestoreEditorPrefs() {
|
||
const saved = parseInt(localStorage.getItem('vv-editor-fontsize') || '12') || 12;
|
||
vvFontSizePx = 12;
|
||
vvFontSize(saved - 12); // always force inline fontSize+lineHeight — Unraid SPA CSS overrides line-height: 1.5 to normal
|
||
vvWordWrap = localStorage.getItem('vv-editor-wordwrap') === '1';
|
||
vvApplyWordWrap(false);
|
||
}
|
||
|
||
// ── Word wrap toggle ──────────────────────────────────────────────────────────
|
||
|
||
function vvApplyWordWrap(save) {
|
||
const wrap = document.getElementById('vv-editor-wrap');
|
||
const btn = document.getElementById('vv-es-wrap-btn');
|
||
if (!wrap) return;
|
||
if (vvWordWrap) {
|
||
wrap.classList.add('vv-ed-wrap');
|
||
if (btn) { btn.classList.add('active'); btn.title = 'Word wrap ON — click to disable'; }
|
||
} else {
|
||
wrap.classList.remove('vv-ed-wrap');
|
||
if (btn) { btn.classList.remove('active'); btn.title = 'Word wrap OFF — click to enable'; }
|
||
}
|
||
if (save) localStorage.setItem('vv-editor-wordwrap', vvWordWrap ? '1' : '0');
|
||
vvSyncHlOverlay();
|
||
}
|
||
|
||
function vvToggleWordWrap() {
|
||
vvWordWrap = !vvWordWrap;
|
||
vvApplyWordWrap(true);
|
||
}
|
||
|
||
// ── Go to line ────────────────────────────────────────────────────────────────
|
||
|
||
function vvGotoOpen() {
|
||
const bar = document.getElementById('vv-goto-bar');
|
||
if (!bar) return;
|
||
bar.classList.add('vv-goto-open');
|
||
const ta = document.getElementById('vv-editor-body');
|
||
const inp = document.getElementById('vv-goto-input');
|
||
inp.value = ta.value.slice(0, ta.selectionStart).split('\n').length;
|
||
document.getElementById('vv-goto-info').textContent = 'of ' + ta.value.split('\n').length;
|
||
inp.focus(); inp.select();
|
||
}
|
||
|
||
function vvGotoClose() {
|
||
document.getElementById('vv-goto-bar').classList.remove('vv-goto-open');
|
||
document.getElementById('vv-editor-body').focus();
|
||
}
|
||
|
||
function vvGotoApply() {
|
||
const ta = document.getElementById('vv-editor-body');
|
||
const lines = ta.value.split('\n');
|
||
const n = Math.max(1, Math.min(parseInt(document.getElementById('vv-goto-input').value) || 1, lines.length));
|
||
let pos = 0;
|
||
for (let i = 0; i < n - 1; i++) pos += lines[i].length + 1;
|
||
ta.selectionStart = ta.selectionEnd = pos;
|
||
ta.scrollTop = Math.max(0, (n - 5) * vvLH());
|
||
vvSyncHlOverlay();
|
||
vvEditorCursorMoved();
|
||
}
|
||
|
||
function vvGotoKeydown(e) {
|
||
if (e.key === 'Enter') { e.preventDefault(); vvGotoApply(); vvGotoClose(); }
|
||
if (e.key === 'Escape') { e.preventDefault(); vvGotoClose(); }
|
||
}
|
||
|
||
// ── Line operations ───────────────────────────────────────────────────────────
|
||
|
||
function vvMoveLine(dir) {
|
||
const ta = document.getElementById('vv-editor-body');
|
||
const v = ta.value, s = ta.selectionStart;
|
||
vvUndoCapture(true);
|
||
const ls = v.lastIndexOf('\n', s - 1) + 1;
|
||
const le = v.indexOf('\n', s);
|
||
if (dir === -1) {
|
||
if (ls === 0) return;
|
||
const pls = v.lastIndexOf('\n', ls - 2) + 1;
|
||
const prev = v.slice(pls, ls - 1), cur = v.slice(ls, le === -1 ? v.length : le);
|
||
ta.value = v.slice(0, pls) + cur + '\n' + prev + v.slice(le === -1 ? v.length : le);
|
||
ta.selectionStart = ta.selectionEnd = pls + (s - ls);
|
||
} else {
|
||
if (le === -1) return;
|
||
const nle = v.indexOf('\n', le + 1);
|
||
const next = v.slice(le + 1, nle === -1 ? v.length : nle), cur = v.slice(ls, le);
|
||
ta.value = v.slice(0, ls) + next + '\n' + cur + v.slice(nle === -1 ? v.length : nle);
|
||
ta.selectionStart = ta.selectionEnd = ls + next.length + 1 + (s - ls);
|
||
}
|
||
vvSyncHlOverlay(); vvUndoCapture(true); vvEditorCursorMoved();
|
||
}
|
||
|
||
function vvDuplicateLine(above) {
|
||
const ta = document.getElementById('vv-editor-body');
|
||
const v = ta.value, s = ta.selectionStart;
|
||
vvUndoCapture(true);
|
||
const ls = v.lastIndexOf('\n', s - 1) + 1;
|
||
const le = v.indexOf('\n', s);
|
||
const cur = v.slice(ls, le === -1 ? v.length : le);
|
||
if (above) {
|
||
ta.value = v.slice(0, ls) + cur + '\n' + v.slice(ls);
|
||
ta.selectionStart = ta.selectionEnd = ls + cur.length + 1 + (s - ls);
|
||
} else {
|
||
const ins = le === -1 ? v.length : le;
|
||
ta.value = v.slice(0, ins) + '\n' + cur + v.slice(ins);
|
||
ta.selectionStart = ta.selectionEnd = ins + 1 + (s - ls);
|
||
}
|
||
vvSyncHlOverlay(); vvUndoCapture(true); vvEditorCursorMoved();
|
||
}
|
||
|
||
// ── Select next occurrence (Ctrl+D) ──────────────────────────────────────────
|
||
|
||
function vvSelectNextOccurrence() {
|
||
const ta = document.getElementById('vv-editor-body');
|
||
const v = ta.value, s = ta.selectionStart, e = ta.selectionEnd;
|
||
let word;
|
||
if (e > s) {
|
||
word = v.slice(s, e);
|
||
} else {
|
||
const wb = v.slice(0, s).match(/[\w$]+$/) ?? [''];
|
||
const wa = v.slice(s).match(/^[\w]+/) ?? [''];
|
||
word = wb[0] + wa[0];
|
||
if (word.length < 1) return;
|
||
// First call: just select the word at cursor
|
||
const ws = s - wb[0].length;
|
||
ta.selectionStart = ws; ta.selectionEnd = ws + word.length;
|
||
vvWordMatch = word.replace(/^\$/, '');
|
||
vvSyncHlOverlay(); vvEditorCursorMoved(); return;
|
||
}
|
||
if (!word) return;
|
||
const re = new RegExp(word.replace(/[.*+?^${}()|[\]\\]/g,'\\$&'), 'g');
|
||
re.lastIndex = e;
|
||
let m = re.exec(v) ?? (() => { re.lastIndex = 0; return re.exec(v); })();
|
||
if (!m || m.index === s) return;
|
||
ta.selectionStart = m.index; ta.selectionEnd = m.index + word.length;
|
||
ta.scrollTop = Math.max(0, (v.slice(0, m.index).split('\n').length - 5) * vvLH());
|
||
vvWordMatch = word.replace(/^\$/, '');
|
||
vvSyncHlOverlay(); vvEditorCursorMoved();
|
||
}
|
||
|
||
function vvEditorReset() {
|
||
vvWordMatch = null; vvFindTerm = ''; vvFindMatches = []; vvFindIdx = 0;
|
||
document.getElementById('vv-editor-find')?.classList.remove('vv-find-open');
|
||
document.getElementById('vv-replace-row')?.classList.remove('vv-repl-open');
|
||
document.getElementById('vv-find-expand')?.classList.remove('open');
|
||
const inp = document.getElementById('vv-find-input');
|
||
if (inp) { inp.value = ''; inp.classList.remove('vv-find-no-match'); }
|
||
const rep = document.getElementById('vv-replace-input');
|
||
if (rep) rep.value = '';
|
||
const cnt = document.getElementById('vv-find-count');
|
||
if (cnt) cnt.textContent = '';
|
||
document.getElementById('vv-goto-bar')?.classList.remove('vv-goto-open');
|
||
vvUndoReset();
|
||
}
|
||
|
||
// ── Undo / Redo ────────────────────────────────────────────────────────────────
|
||
|
||
function vvUndoSnapshot() {
|
||
const ta = document.getElementById('vv-editor-body');
|
||
return ta ? { v: ta.value, ss: ta.selectionStart, se: ta.selectionEnd } : null;
|
||
}
|
||
|
||
function _vvUndoCommit(snap) {
|
||
if (!snap) return;
|
||
const top = vvUndoStack[vvUndoStack.length - 1];
|
||
if (top && top.v === snap.v) return; // identical — skip
|
||
vvUndoStack.push(snap);
|
||
if (vvUndoStack.length > VV_UNDO_MAX) vvUndoStack.shift();
|
||
vvUndoUpdateBtns();
|
||
}
|
||
|
||
// immediate=true: flush debounce and commit now (use before/after programmatic edits)
|
||
// immediate=false (default): debounce — groups rapid typing into one undo step
|
||
function vvUndoCapture(immediate = false) {
|
||
const snap = vvUndoSnapshot();
|
||
if (!snap) return;
|
||
if (immediate) {
|
||
if (vvUndoTimer) { clearTimeout(vvUndoTimer); vvUndoTimer = null; }
|
||
_vvUndoCommit(snap);
|
||
} else {
|
||
if (vvUndoTimer) clearTimeout(vvUndoTimer);
|
||
vvUndoTimer = setTimeout(() => { vvUndoTimer = null; _vvUndoCommit(vvUndoSnapshot()); }, 600);
|
||
}
|
||
}
|
||
|
||
function vvUndoCaptureInitial() {
|
||
// Called after editor content loads — seeds the stack with the initial state
|
||
if (vvUndoTimer) { clearTimeout(vvUndoTimer); vvUndoTimer = null; }
|
||
vvUndoStack = [];
|
||
vvRedoStack = [];
|
||
_vvUndoCommit(vvUndoSnapshot());
|
||
}
|
||
|
||
function vvUndo() {
|
||
if (vvUndoTimer) { clearTimeout(vvUndoTimer); vvUndoTimer = null; }
|
||
if (vvUndoStack.length < 2) return;
|
||
const ta = document.getElementById('vv-editor-body');
|
||
// Push current state to redo before going back
|
||
vvRedoStack.push({ v: ta.value, ss: ta.selectionStart, se: ta.selectionEnd });
|
||
vvUndoStack.pop(); // discard current top (that's the state we're leaving)
|
||
const prev = vvUndoStack[vvUndoStack.length - 1];
|
||
ta.value = prev.v;
|
||
ta.selectionStart = prev.ss;
|
||
ta.selectionEnd = prev.se;
|
||
vvSyncHlOverlay();
|
||
vvEditorCursorMoved();
|
||
vvUndoUpdateBtns();
|
||
}
|
||
|
||
function vvRedo() {
|
||
if (!vvRedoStack.length) return;
|
||
const ta = document.getElementById('vv-editor-body');
|
||
const next = vvRedoStack.pop();
|
||
_vvUndoCommit({ v: ta.value, ss: ta.selectionStart, se: ta.selectionEnd });
|
||
ta.value = next.v;
|
||
ta.selectionStart = next.ss;
|
||
ta.selectionEnd = next.se;
|
||
vvSyncHlOverlay();
|
||
vvEditorCursorMoved();
|
||
vvUndoUpdateBtns();
|
||
}
|
||
|
||
function vvUndoUpdateBtns() {
|
||
const ub = document.getElementById('vv-undo-btn');
|
||
const rb = document.getElementById('vv-redo-btn');
|
||
const uc = document.getElementById('vv-undo-count');
|
||
const depth = vvUndoStack.length - 1; // steps available
|
||
if (ub) ub.disabled = depth < 1;
|
||
if (rb) rb.disabled = vvRedoStack.length === 0;
|
||
if (uc) uc.textContent = depth > 0 ? depth : '';
|
||
}
|
||
|
||
function vvUndoReset() {
|
||
if (vvUndoTimer) { clearTimeout(vvUndoTimer); vvUndoTimer = null; }
|
||
vvUndoStack = []; vvRedoStack = [];
|
||
vvUndoUpdateBtns();
|
||
}
|
||
|
||
function vvEditorKeydown(e) {
|
||
const ta = e.target;
|
||
const start = ta.selectionStart;
|
||
const end = ta.selectionEnd;
|
||
const val = ta.value;
|
||
|
||
// Ctrl/Cmd+S — save
|
||
if (e.key === 's' && (e.ctrlKey || e.metaKey)) {
|
||
e.preventDefault();
|
||
if (vvRawConfFile) vvSaveRawConf(); else vvSaveScript();
|
||
return;
|
||
}
|
||
|
||
// ── Ctrl/Cmd shortcuts ────────────────────────────────────────────────────
|
||
if (e.ctrlKey || e.metaKey) {
|
||
if (e.key === 'z' && !e.shiftKey) { e.preventDefault(); vvUndo(); return; }
|
||
if (e.key === 'y' || (e.key === 'z' && e.shiftKey)) { e.preventDefault(); vvRedo(); return; }
|
||
if (e.key === 's') { e.preventDefault(); if (vvRawConfFile) vvSaveRawConf(); else vvSaveScript(); return; }
|
||
if (e.key === 'f') { e.preventDefault(); vvFindOpen(); return; }
|
||
if (e.key === 'h') { e.preventDefault(); vvFindOpen(); vvFindToggleReplace(true); return; }
|
||
if (e.key === 'g') { e.preventDefault(); vvGotoOpen(); return; }
|
||
if (e.key === 'l' && !e.shiftKey) {
|
||
// Select current line
|
||
e.preventDefault();
|
||
const ls = val.lastIndexOf('\n', start - 1) + 1;
|
||
const le = val.indexOf('\n', start);
|
||
ta.selectionStart = ls;
|
||
ta.selectionEnd = le === -1 ? val.length : le + 1;
|
||
vvEditorCursorMoved(); return;
|
||
}
|
||
if (e.key === 'd' && !e.shiftKey) { e.preventDefault(); vvSelectNextOccurrence(); return; }
|
||
if (e.key === 'k' && e.shiftKey) {
|
||
// Delete current line
|
||
e.preventDefault(); vvUndoCapture(true);
|
||
const ls = val.lastIndexOf('\n', start - 1) + 1;
|
||
const le = val.indexOf('\n', start);
|
||
if (le === -1) {
|
||
ta.value = ls > 0 ? val.slice(0, ls - 1) : '';
|
||
ta.selectionStart = ta.selectionEnd = Math.max(0, ls - 1);
|
||
} else {
|
||
ta.value = val.slice(0, ls) + val.slice(le + 1);
|
||
ta.selectionStart = ta.selectionEnd = ls;
|
||
}
|
||
vvSyncHlOverlay(); vvUndoCapture(true); return;
|
||
}
|
||
if (e.key === '/') {
|
||
// Toggle line comment — single line or entire multi-line selection
|
||
e.preventDefault(); vvUndoCapture(true);
|
||
const bs = val.lastIndexOf('\n', start - 1) + 1;
|
||
const be = end > start ? val.indexOf('\n', end - 1) : val.indexOf('\n', start);
|
||
const block = val.slice(bs, be === -1 ? val.length : be);
|
||
const lines = block.split('\n');
|
||
const allCommented = lines.every(l => !l.trim() || /^\s*#/.test(l));
|
||
const newBlock = lines.map(l =>
|
||
!l.trim() ? l
|
||
: allCommented ? l.replace(/^(\s*)#\s?/, '$1')
|
||
: l.replace(/^(\s*)/, '$1# ')
|
||
).join('\n');
|
||
ta.value = val.slice(0, bs) + newBlock + val.slice(be === -1 ? val.length : be);
|
||
if (end > start) {
|
||
ta.selectionStart = bs; ta.selectionEnd = bs + newBlock.length; // keep block selected
|
||
} else {
|
||
const curLineDelta = newBlock.split('\n')[0].length - lines[0].length;
|
||
ta.selectionStart = ta.selectionEnd = start + curLineDelta;
|
||
}
|
||
vvSyncHlOverlay(); vvUndoCapture(true); return;
|
||
}
|
||
return; // let browser handle other Ctrl combos
|
||
}
|
||
|
||
// ── Escape ─────────────────────────────────────────────────────────────────
|
||
if (e.key === 'Escape') {
|
||
const fb = document.getElementById('vv-editor-find');
|
||
if (fb?.classList.contains('vv-find-open')) { e.preventDefault(); vvFindClose(); return; }
|
||
const gb = document.getElementById('vv-goto-bar');
|
||
if (gb?.classList.contains('vv-goto-open')) { e.preventDefault(); vvGotoClose(); return; }
|
||
}
|
||
|
||
// ── Alt shortcuts (line moves) ─────────────────────────────────────────────
|
||
if (e.altKey) {
|
||
if (e.key === 'ArrowUp' && !e.shiftKey) { e.preventDefault(); vvMoveLine(-1); return; }
|
||
if (e.key === 'ArrowDown' && !e.shiftKey) { e.preventDefault(); vvMoveLine(1); return; }
|
||
if (e.key === 'ArrowDown' && e.shiftKey) { e.preventDefault(); vvDuplicateLine(false); return; }
|
||
if (e.key === 'ArrowUp' && e.shiftKey) { e.preventDefault(); vvDuplicateLine(true); return; }
|
||
}
|
||
|
||
// ── Smart Home ─────────────────────────────────────────────────────────────
|
||
if (e.key === 'Home' && !e.altKey && !e.shiftKey) {
|
||
e.preventDefault();
|
||
const ls = val.lastIndexOf('\n', start - 1) + 1;
|
||
const fns = ls + val.slice(ls).match(/^(\s*)/)[1].length;
|
||
ta.selectionStart = ta.selectionEnd = start === fns ? ls : fns;
|
||
vvEditorCursorMoved(); return;
|
||
}
|
||
|
||
// ── Tab / Shift+Tab ────────────────────────────────────────────────────────
|
||
if (e.key === 'Tab') {
|
||
e.preventDefault(); vvUndoCapture(true);
|
||
if (end > start && val.slice(start, end).includes('\n')) {
|
||
const bs = val.lastIndexOf('\n', start - 1) + 1;
|
||
const be = val.indexOf('\n', end - 1);
|
||
const blk = val.slice(bs, be === -1 ? val.length : be);
|
||
const nb = e.shiftKey ? blk.replace(/^ /gm, '') : blk.replace(/^/gm, ' ');
|
||
ta.value = val.slice(0, bs) + nb + val.slice(be === -1 ? val.length : be);
|
||
ta.selectionStart = bs; ta.selectionEnd = bs + nb.length;
|
||
} else if (e.shiftKey) {
|
||
const ls = val.lastIndexOf('\n', start - 1) + 1;
|
||
if (val.slice(ls, ls + 2) === ' ') {
|
||
ta.value = val.slice(0, ls) + val.slice(ls + 2);
|
||
ta.selectionStart = ta.selectionEnd = Math.max(ls, start - 2);
|
||
}
|
||
} else {
|
||
ta.value = val.slice(0, start) + ' ' + val.slice(end);
|
||
ta.selectionStart = ta.selectionEnd = start + 2;
|
||
}
|
||
vvSyncHlOverlay(); vvUndoCapture(true); return;
|
||
}
|
||
|
||
// ── Enter auto-indent ──────────────────────────────────────────────────────
|
||
if (e.key === 'Enter') {
|
||
e.preventDefault(); vvUndoCapture(true);
|
||
const ls = val.lastIndexOf('\n', start - 1) + 1;
|
||
const curL = val.slice(ls, start);
|
||
const ind = curL.match(/^(\s*)/)[1];
|
||
const xtra = /\{\s*$/.test(curL) ? ' ' : '';
|
||
const ins = '\n' + ind + xtra;
|
||
ta.value = val.slice(0, start) + ins + val.slice(end);
|
||
ta.selectionStart = ta.selectionEnd = start + ins.length;
|
||
vvSyncHlOverlay(); vvUndoCapture(true); return;
|
||
}
|
||
|
||
// ── Auto-close brackets and quotes (no modifier keys) ─────────────────────
|
||
if (!e.altKey) {
|
||
// Skip over closing char if already there
|
||
if (VV_CLOSE.has(e.key) && start === end && val[start] === e.key) {
|
||
e.preventDefault();
|
||
ta.selectionStart = ta.selectionEnd = start + 1;
|
||
vvEditorCursorMoved(); return;
|
||
}
|
||
// Smart backspace: delete matched pair
|
||
if (e.key === 'Backspace' && start === end && start > 0 && VV_PAIRS[val[start-1]] === val[start]) {
|
||
e.preventDefault(); vvUndoCapture(true);
|
||
ta.value = val.slice(0, start - 1) + val.slice(start + 1);
|
||
ta.selectionStart = ta.selectionEnd = start - 1;
|
||
vvSyncHlOverlay(); vvUndoCapture(true); return;
|
||
}
|
||
// Auto-close opening bracket/quote
|
||
if (e.key in VV_PAIRS) {
|
||
if (e.key === "'" && /\w/.test(val[start - 1] ?? '')) return; // skip contractions
|
||
if (end === start && /\w/.test(val[start] ?? '')) return; // skip if next is word char
|
||
e.preventDefault(); vvUndoCapture(true);
|
||
const cl = VV_PAIRS[e.key];
|
||
if (end > start) {
|
||
// Wrap selection in pair
|
||
ta.value = val.slice(0, start) + e.key + val.slice(start, end) + cl + val.slice(end);
|
||
ta.selectionStart = start + 1; ta.selectionEnd = end + 1;
|
||
} else {
|
||
ta.value = val.slice(0, start) + e.key + cl + val.slice(end);
|
||
ta.selectionStart = ta.selectionEnd = start + 1;
|
||
}
|
||
vvSyncHlOverlay(); vvUndoCapture(true); return;
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── 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-cancel-edit-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-log-search').style.display = 'none';
|
||
document.getElementById('vv-log-search').value = '';
|
||
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 = '';
|
||
if (vvPollTimer) { clearInterval(vvPollTimer); vvPollTimer = null; }
|
||
|
||
const si = document.getElementById('vv-si-view');
|
||
const _isMd = /\.md$/i.test(id);
|
||
|
||
if (_isMd) {
|
||
// Markdown files always show rendered content
|
||
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 ? vvEscHtml(d.content) : '# Error loading') + '</pre>';
|
||
requestAnimationFrame(vvFitRight);
|
||
})
|
||
.catch(() => { si.innerHTML = '<pre class="vv-si-src"># Load failed</pre>'; });
|
||
|
||
} else if (vvAdvancedMode) {
|
||
// Advanced mode: header + README/Manual sections + inline config settings
|
||
si.innerHTML = '<p class="vv-cf-empty">Loading…</p>';
|
||
Promise.all([
|
||
fetch('/plugins/varaverk/api/scriptinfo.php?id=' + encodeURIComponent(id)).then(r => r.json()),
|
||
fetch('/plugins/varaverk/api/confform.php?id=' + encodeURIComponent(id)).then(r => r.json()),
|
||
]).then(([info, conf]) => {
|
||
let html = '';
|
||
const dispHdr = (info.ok && info.header) ? info.header : hdr;
|
||
|
||
if (dispHdr) {
|
||
html += '<div class="vv-sinfo-block">'
|
||
+ '<pre class="vv-si-hdr">' + vvHl(dispHdr, true) + '</pre>'
|
||
+ '</div>';
|
||
}
|
||
|
||
if (info.ok && info.sections?.length) {
|
||
for (const sec of info.sections) {
|
||
html += '<div class="vv-sinfo-block">'
|
||
+ '<div class="vv-sinfo-lbl">' + vvEscHtml(sec.source) + '</div>'
|
||
+ '<pre class="vv-readme-body">' + vvEscHtml(sec.body) + '</pre>'
|
||
+ '</div>';
|
||
}
|
||
}
|
||
|
||
if (conf.ok && conf.groups?.length) {
|
||
html += '<div class="vv-sinfo-block">'
|
||
+ '<div class="vv-sinfo-lbl">Config</div>'
|
||
+ vvRenderConfForm(conf.groups)
|
||
+ '</div>';
|
||
vvConfId = id;
|
||
document.getElementById('vv-cancel-edit-btn').style.display = '';
|
||
document.getElementById('vv-save-conf-btn').style.display = '';
|
||
}
|
||
|
||
si.innerHTML = html || '<p class="vv-cf-empty">No additional information found.</p>';
|
||
requestAnimationFrame(vvFitRight);
|
||
}).catch(() => { si.innerHTML = '<p class="vv-cf-empty">Failed to load info.</p>'; });
|
||
|
||
} else {
|
||
// Basic mode: header only
|
||
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);
|
||
document.body.classList.add('vv-is-dragging');
|
||
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);
|
||
document.body.classList.add('vv-is-dragging');
|
||
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);
|
||
document.body.classList.add('vv-is-dragging');
|
||
setTimeout(() => el.classList.add('vv-drag-ghost'), 0);
|
||
}
|
||
|
||
function vvDragEnd(e) {
|
||
if (vvDragEl) vvDragEl.classList.remove('vv-drag-ghost');
|
||
document.body.classList.remove('vv-is-dragging');
|
||
vvDragEl = null;
|
||
}
|
||
// Failsafe: clear dragging class if drop lands outside the window
|
||
document.addEventListener('dragend', () => document.body.classList.remove('vv-is-dragging'));
|
||
|
||
// 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 ? '▾' : '▸';
|
||
}
|
||
|
||
// ── Snapshot footer poll ──────────────────────────────────────────────────────
|
||
let vvSnapTimer = null;
|
||
function vvPollSnapshot() {
|
||
fetch('/plugins/varaverk/api/snapshot.php')
|
||
.then(r => r.json())
|
||
.then(d => {
|
||
const cpuBar = document.getElementById('vv-snap-cpu-bar');
|
||
const cpuVal = document.getElementById('vv-snap-cpu-val');
|
||
if (cpuBar && cpuVal) {
|
||
const p = d.cpu_pct ?? 0;
|
||
cpuBar.style.width = p + '%';
|
||
cpuBar.style.backgroundColor = p > 85 ? '#f44336' : p > 60 ? '#ff9800' : '#4caf50';
|
||
cpuVal.textContent = p + '%';
|
||
}
|
||
const ramBar = document.getElementById('vv-snap-ram-bar');
|
||
const ramVal = document.getElementById('vv-snap-ram-val');
|
||
if (ramBar && ramVal) {
|
||
const p = d.ram_pct ?? 0;
|
||
ramBar.style.width = p + '%';
|
||
ramBar.style.backgroundColor = p > 85 ? '#f44336' : p > 70 ? '#ff9800' : '#4caf50';
|
||
ramVal.textContent = p + '%';
|
||
}
|
||
const fb = document.getElementById('vv-snap-fallback');
|
||
if (fb) {
|
||
const s = (d.fallback ?? 'UNKNOWN').toUpperCase();
|
||
fb.textContent = s;
|
||
fb.style.color = s === 'NORMAL' ? '#4caf50'
|
||
: s === 'FAILOVER' ? '#f44336'
|
||
: s === 'NO_INTERNET' ? '#ff9800' : '#666';
|
||
}
|
||
const pt = document.getElementById('vv-snap-partner');
|
||
if (pt) {
|
||
if (!d.partner_enabled) {
|
||
pt.textContent = 'No partnership'; pt.style.color = '#444';
|
||
} else {
|
||
const peers = d.peers ?? [];
|
||
const on = peers.filter(p => p.online === true);
|
||
const off = peers.filter(p => p.online !== true);
|
||
if (off.length > 0 && peers.length === 1) {
|
||
pt.textContent = off[0].hostname + ' ✕'; pt.style.color = '#f44336';
|
||
} else if (off.length > 0) {
|
||
pt.textContent = off.length + ' peer' + (off.length > 1 ? 's' : '') + ' down'; pt.style.color = '#f44336';
|
||
} else if (on.length === 1) {
|
||
pt.textContent = on[0].hostname + ' ●'; pt.style.color = '#4caf50';
|
||
} else if (on.length > 1) {
|
||
pt.textContent = on.length + ' peers ●'; pt.style.color = '#4caf50';
|
||
} else {
|
||
pt.textContent = '—'; pt.style.color = '#555';
|
||
}
|
||
}
|
||
}
|
||
const sm = document.getElementById('vv-snap-streams');
|
||
if (sm) {
|
||
const sc = d.stream_count ?? 0;
|
||
sm.textContent = sc + (sc === 1 ? ' stream' : ' streams');
|
||
sm.style.color = sc > 0 ? '#aaa' : '#444';
|
||
}
|
||
const tc = document.getElementById('vv-snap-transcodes');
|
||
if (tc) {
|
||
const n = d.transcode_count ?? 0;
|
||
tc.textContent = n + (n === 1 ? ' transcode' : ' transcodes');
|
||
tc.style.color = n > 0 ? '#ff9800' : '#444';
|
||
}
|
||
})
|
||
.catch(() => {});
|
||
}
|
||
function vvStartSnapPoll() {
|
||
if (vvSnapTimer) return;
|
||
vvPollSnapshot();
|
||
vvSnapTimer = setInterval(vvPollSnapshot, 10000);
|
||
}
|
||
|
||
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();
|
||
vvStartSnapPoll();
|
||
});
|
||
|
||
// ── Keyboard navigation ───────────────────────────────────────────────────────
|
||
// ↑/↓ — navigate top-level orchestrator cards
|
||
// Home/End — jump to first/last card
|
||
// Escape — close right panel
|
||
// / — focus log search when visible
|
||
document.addEventListener('keydown', function(e) {
|
||
// Don't intercept when typing in any input/textarea/select
|
||
const t = document.activeElement;
|
||
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' ||
|
||
t.tagName === 'SELECT' || t.isContentEditable)) return;
|
||
|
||
// Escape — back to suggestions
|
||
if (e.key === 'Escape' && vvActiveId) {
|
||
e.preventDefault();
|
||
vvBackToSuggestions();
|
||
return;
|
||
}
|
||
|
||
// / — focus log search when the log panel is open
|
||
if (e.key === '/' && document.getElementById('vv-log-search').style.display !== 'none') {
|
||
e.preventDefault();
|
||
document.getElementById('vv-log-search').focus();
|
||
return;
|
||
}
|
||
|
||
// ↑/↓/Home/End — navigate top-level orchestrator cards
|
||
if (!['ArrowUp','ArrowDown','Home','End'].includes(e.key)) return;
|
||
const cards = [...document.querySelectorAll('#vv-sched-cards > .vv-sched-card')];
|
||
if (!cards.length) return;
|
||
const cur = vvActiveId ? cards.findIndex(c => c.dataset.id === vvActiveId) : -1;
|
||
let next;
|
||
if (e.key === 'ArrowDown') next = cur < cards.length - 1 ? cur + 1 : cur;
|
||
else if (e.key === 'ArrowUp') next = cur > 0 ? cur - 1 : 0;
|
||
else if (e.key === 'Home') next = 0;
|
||
else next = cards.length - 1;
|
||
if (next === cur && cur !== -1) return;
|
||
if (next < 0) next = 0;
|
||
e.preventDefault();
|
||
cards[next].scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||
vvOpenRight(cards[next].dataset.id);
|
||
});
|
||
</script>
|