5118 lines
242 KiB
PHP
5118 lines
242 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.
|
||
// vvConfirmRun() guards all three entry points — the six Run buttons, the direct
|
||
// by-id run, and Git Pull. This line described intent rather than behaviour until
|
||
// 2026-08-07: Run was the only mutating control on the page that did not ask, while
|
||
// being the one that executes shell as root.
|
||
//
|
||
// 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';
|
||
require_once dirname(__DIR__) . '/include/ai_profiles.php';
|
||
// The chat component itself now, not only the store: the assistant panel in the right-hand pane
|
||
// is an instance of it rather than a second implementation.
|
||
require_once dirname(__DIR__) . '/include/ai_chat.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,
|
||
]);
|
||
|
||
// The assistant dock renders only where the AI tab itself would: HOST1, with AI_ENABLED true.
|
||
// The same gate function, not a second copy of the condition, so the page cannot offer a chat
|
||
// the endpoint will refuse — api/ai.php rejects every action on both counts regardless of what
|
||
// this page draws. With it false the dock markup is never emitted, and every caller into the
|
||
// dock is guarded by vvAiDockOn(), which reads the element's absence.
|
||
$_vv_ai_on = vv_ai_ui_on();
|
||
|
||
// 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.
|
||
//
|
||
// Counted against orchestrators, not against every script. A schedule here is normally expressed
|
||
// as one cron on an orchestrator that then calls its steps in order, so the steps are deliberately
|
||
// cronless — counting them as unscheduled reported a fully-scheduled system as almost entirely
|
||
// unscheduled, "10 / 82", which is alarming and wrong.
|
||
//
|
||
// Steps that do carry their own cron are counted separately. That happens when an orchestrator is
|
||
// off and its steps have been given schedules of their own, which is the exception — and the
|
||
// number of exceptions is the figure actually worth knowing.
|
||
$orchTotal = 0; $orchScheduled = 0; $soloScheduled = 0;
|
||
foreach ($tree as $orch) {
|
||
$orchTotal++;
|
||
if (!empty($orch['cron']) && $orch['enabled']) $orchScheduled++;
|
||
foreach (($orch['children'] ?? []) as $child) {
|
||
if (!empty($child['cron']) && $child['enabled']) $soloScheduled++;
|
||
}
|
||
}
|
||
$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-cog-btn" onclick="vvClickCog(this)" title="Settings">⚙</span><span class="vv-job-label" onclick="vvClickLabel(this)" style="cursor:pointer"><?= 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"><?= $orchScheduled ?> / <?= $orchTotal ?> orchestrators</span>
|
||
<?php if ($soloScheduled): ?>
|
||
<span class="vv-nb-sep">·</span>
|
||
<span class="vv-nb-stat"><?= $soloScheduled ?> standalone<?= $soloScheduled === 1 ? '' : 's' ?></span>
|
||
<?php endif; ?>
|
||
<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>
|
||
<!-- stopPropagation: the whole header is the collapse target, so without it the
|
||
button would switch view and immediately shut the block it just changed. -->
|
||
<button class="vv-more-btn" id="vv-howto-more" type="button"
|
||
onclick="event.stopPropagation(); vvToggleHowToMore(this)">More info</button>
|
||
</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 views of one file, both inside the collapsible body so the chevron still
|
||
means what it means everywhere else on this page. Open lands on the terse
|
||
one-line-per-control list — short enough that Next Runs and the error blocks
|
||
stay on screen. "More info" swaps in the full document for when you actually
|
||
need the walkthroughs. -->
|
||
<div class="vv-sug-body vv-info-body">
|
||
<ul class="vv-info-cols" id="vv-howto-brief">
|
||
<?= vv_docs_brief('Plugin/unraid/pages/readme/scheduler-readme.md', $_vv_doc_vars,
|
||
fn($h) => is_string($h) && str_starts_with($h, 'Reference —')) ?>
|
||
</ul>
|
||
<div class="vv-doc" id="vv-howto-full" style="display:none">
|
||
<?= vv_docs_render('Plugin/unraid/pages/readme/scheduler-readme.md', $_vv_doc_vars) ?>
|
||
</div>
|
||
</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>
|
||
<?php if ($_vv_ai_on): ?>
|
||
<!-- Assistant dock. Sits below every view in the right panel rather than inside any one of
|
||
them: the Scheduler Info card is only on screen in the suggestions view, so a chat box
|
||
living there would vanish exactly when the context — a conf, a log, a script — is most
|
||
worth asking about.
|
||
It takes a share of the panel and the views above shrink to suit, which is why
|
||
vvFitRight() subtracts its height. It pushes rather than overlays, so the thing being
|
||
asked about stays on screen; ⤢ trades more of the panel for the conversation and back. -->
|
||
<?php
|
||
// The registry, the store, and now the component itself. vv_ai_chat_assets() is what
|
||
// defines VvAiChat and carries the transcript and composer styling — without it the markup
|
||
// below renders as inert boxes with no factory to bring them to life. It was absent while
|
||
// this panel drew its own bar.
|
||
vv_ai_profiles_script();
|
||
vv_ai_chat_store_script();
|
||
vv_ai_chat_assets();
|
||
?>
|
||
<!-- The same component the AI tab and the Monitor card render, at this panel's size. It
|
||
used to be a second implementation with its own bar, send loop and poll — which is how
|
||
a thread here died on reload while the other two were stored, and how this bar drifted
|
||
into a different shape from the same control everywhere else.
|
||
|
||
The wrapper keeps the #vv-ai-dock id: vvAiDockOn() reads its presence to decide whether
|
||
to draw the "why?" buttons on the activity rows, and vvFitRight() measures it.
|
||
|
||
Heights are set at runtime, not here — see vvFitRight(). This panel's share of the page
|
||
is not a number that exists until layout has run. -->
|
||
<div id="vv-ai-dock">
|
||
<?php vv_ai_chat_markup('vv-sched-ai', [
|
||
'profile' => 'varaverk',
|
||
'compact' => true,
|
||
'scopeLabel' => 'Scheduler',
|
||
'empty' => 'Ask about what is on screen.',
|
||
'placeholder' => 'Ask about what is on screen…',
|
||
]); ?>
|
||
</div>
|
||
<?php endif; ?>
|
||
<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);
|
||
});
|
||
}
|
||
|
||
// vvEscHtml() and vvEscAttr() moved to Varaverk.page, which every tab loads — they were needed on
|
||
// pages that never include this one. The attribute variant exists because a " inside a
|
||
// double-quoted attribute ends it early and silently destroys the handler after it: that shipped
|
||
// once, truncating an onclick to "vvErrOpenAtLine(" so it did nothing at all when clicked.
|
||
|
||
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');
|
||
|
||
// The pinned help block sticks directly under the notification board, so it needs the board's
|
||
// real height — which changes when Advanced mode adds the conf buttons and the board wraps.
|
||
// Set before the stacked-layout return below, because the two rows stay pinned there too.
|
||
const nb = right.querySelector('.vv-nb-board');
|
||
if (nb && sug) sug.style.setProperty('--vv-nb-h', nb.offsetHeight + 'px');
|
||
|
||
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';
|
||
|
||
// The assistant dock sits below every view, so its height comes off the space the views get.
|
||
// Cap the conversation at roughly 40% of the panel and let it scroll internally, so a long
|
||
// answer shrinks the view above by a bounded amount instead of swallowing it.
|
||
// The cap comes off the space available before the dock is measured, not after. Deriving it
|
||
// from a figure the dock's own height had already been subtracted from fed the dock back into
|
||
// its own limit: one long answer drove the subtraction to its floor, which drove the cap to
|
||
// its floor, and nothing ever raised it again.
|
||
const availH = Math.max(80, lf.getBoundingClientRect().top - 32
|
||
- toolbar.getBoundingClientRect().bottom);
|
||
// Three shares of the panel: 10% collapsed, 20% expanded at Medium, 40% at Large. Collapsed is
|
||
// deliberately small — enough to see the last exchange and type, while the log, conf or script
|
||
// being discussed keeps the panel, which is what you are usually asking about. Medium is for
|
||
// following an answer without giving up the view; Large is for sitting and reading one, and
|
||
// still leaves the majority above it so a follow-up does not need the panel handed back first.
|
||
//
|
||
// It grows upward: the panel is pinned at the bottom and everything above it is sized from what
|
||
// is left, a few lines down. So the top edge rises and the thing being read stays where it is.
|
||
//
|
||
// Both heights handed to the chat rather than one cap applied to it, because the component owns
|
||
// which of the two it is currently at. Recomputed on every fit so a resize while expanded stays
|
||
// expanded at the new panel size instead of snapping back to the resting share. The floors keep
|
||
// the same 1:2 relationship, or on a very short viewport the two states would clamp to the same
|
||
// number and the control would appear to do nothing.
|
||
const dock = document.getElementById('vv-ai-dock');
|
||
if (vvSchedChat && dock) {
|
||
// The share is of the transcript, but what the panel has to find room for is the whole
|
||
// assistant — composer, control row, padding. Measured rather than assumed, because it is a
|
||
// wrapped row of buttons whose height depends on how narrow the panel is.
|
||
const chatBox = document.getElementById('vv-sched-ai-chat');
|
||
const chrome = chatBox ? Math.max(0, dock.offsetHeight - chatBox.offsetHeight) : 0;
|
||
// Hard ceiling, not a preference. This panel is a fixed height with overflow:hidden, so a
|
||
// transcript that asks for more than is left is not scrolled into place — it pushes its own
|
||
// composer out of the panel and the panel cuts it off. VIEW_MIN keeps the thing being
|
||
// discussed on screen too; a chat that covers it entirely is not worth the room it took.
|
||
const VIEW_MIN = 90;
|
||
const room = Math.max(60, availH - VIEW_MIN - chrome);
|
||
vvSchedChat.setHeights(Math.min(room, Math.max(52, Math.round(availH * 0.10))) + 'px',
|
||
Math.min(room, Math.max(100, Math.round(availH * 0.20))) + 'px',
|
||
Math.min(room, Math.max(140, Math.round(availH * 0.40))) + 'px');
|
||
}
|
||
// Measured after the heights are applied, so this reads the clamped height, not the natural one.
|
||
const dockH = dock ? dock.offsetHeight : 0;
|
||
const contentH = Math.max(80, availH - dockH);
|
||
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') {
|
||
// Everything inside the editor pane that is not the text box: the status bar below it, and
|
||
// the shortcut help above when open. Measured, not assumed — this subtracted a hardcoded
|
||
// 38px, which under-counted the real chrome by ~100px. The textarea was therefore sized
|
||
// taller than #vv-editor-wrap, which is overflow:hidden, so its last ~6 lines rendered
|
||
// below the clip with the textarea's own scroll already at its end: nothing to scroll, and
|
||
// content you can see is missing. Reported against master.conf as stopping mid-line 1684
|
||
// of 1689, unchanged by expanding or collapsing the job tree — a fixed offset, which is
|
||
// what a constant this wrong looks like.
|
||
const _wrap = document.getElementById('vv-editor-wrap');
|
||
const _chrome = Math.max(0, ed.scrollHeight - _wrap.offsetHeight);
|
||
const _edH = Math.max(60, contentH - _chrome) + '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) {
|
||
vvAiDockScope('troubleshoot',
|
||
String(id).replace(/\.sh$/, '').split('/').pop() + ' log',
|
||
String(id).replace(/\.sh$/, ''));
|
||
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() {
|
||
vvAiDockScope('varaverk', 'Scheduler');
|
||
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);
|
||
}
|
||
|
||
// Text of the log line to keep marked, and whether the next render should scroll to it. Cleared
|
||
// by anything that changes what is on screen — a different job, a search — so a mark can never
|
||
// outlive the thing it was pointing at.
|
||
let vvLogMark = null;
|
||
let vvLogMarkScroll = false;
|
||
|
||
function vvOpenRight(id) {
|
||
vvLogMark = null; vvLogMarkScroll = false;
|
||
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() ?? '';
|
||
// Captured before the call, which consumes the flag: this render is the one that centres.
|
||
const willCentre = !!vvLogMark && vvLogMarkScroll;
|
||
let marked = false;
|
||
if (term) vvFilterLog(term);
|
||
else if (vvLogMark) { marked = vvMarkLogLine(pre, display, invert); if (!marked) pre.textContent = display; }
|
||
else pre.textContent = display;
|
||
// A jump that has just placed the line mid-view must not be immediately undone by the
|
||
// auto-scroll-to-bottom every other render wants; later polls keep wherever the operator
|
||
// has scrolled to while reading it.
|
||
if (marked) {
|
||
if (!willCentre) requestAnimationFrame(() => { pre.scrollTop = savedScroll; });
|
||
} else 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);
|
||
}
|
||
|
||
// Every other mutating control on this page already confirms — save, delete, move, conf save,
|
||
// lock clear. Run did not, and it is the one that executes shell as root: the library holds
|
||
// cleaners that delete files and the git pull itself, and the six Run buttons sit one row apart
|
||
// in a dense tree where the row under the cursor is easy to misjudge. The page header has
|
||
// promised this confirmation since it was written; the code never did it.
|
||
//
|
||
// Dry Run is named in the prompt on purpose. For anything destructive it is the actual answer to
|
||
// "are you sure", and it is the button immediately beside the one that raised the question.
|
||
async function vvConfirmRun(id) {
|
||
return vvConfirm('Run ' + id + ' now?\n\n'
|
||
+ 'It starts immediately and as root, with the same effect as a scheduled run.\n'
|
||
+ 'Use Dry Run first if you want to see what it would change.');
|
||
}
|
||
|
||
// The confirm lives in the callers rather than here, so a caller that has already asked — or has
|
||
// button state to manage on cancel, as the pull does — is not forced to ask twice.
|
||
function vvRunStart(id, data) {
|
||
vvOpenRight(id);
|
||
vvSetDot(id);
|
||
vvRunningSet.add(id);
|
||
return 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);
|
||
}
|
||
});
|
||
}
|
||
|
||
// Run a script by ID directly — no DOM card needed.
|
||
async function vvRunById(id) {
|
||
if (!await vvConfirmRun(id)) return;
|
||
vvRunStart(id, {id, manual: '1'});
|
||
}
|
||
|
||
async function vvGitPull(btn) {
|
||
const id = 'git_pull_execute.sh';
|
||
// Asked before the button is disabled, so cancelling does not leave it stuck reading "Pulling…"
|
||
// for the next four seconds while nothing is pulling.
|
||
if (!await vvConfirmRun(id)) return;
|
||
btn.disabled = true;
|
||
btn.textContent = '⟳ Pulling…';
|
||
vvRunStart(id, {id, manual: '1'});
|
||
// Re-enable once the log poll confirms it's running or done
|
||
setTimeout(() => { btn.disabled = false; btn.textContent = '↻ Git Pull'; }, 4000);
|
||
}
|
||
|
||
async function vvRunJob(btn) {
|
||
const job = btn.closest('[data-id]');
|
||
const id = job.dataset.id;
|
||
if (!await vvConfirmRun(id)) return;
|
||
const location = job.querySelector('.vv-rsync-location')?.value.trim() || '';
|
||
const extra_args = job.querySelector('.vv-script-args')?.value.trim() || '';
|
||
const data = {id};
|
||
if (location) data.location = location;
|
||
if (extra_args) data.extra_args = extra_args;
|
||
vvRunStart(id, data);
|
||
}
|
||
|
||
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) {
|
||
vvAiDockScope('code', title || 'a custom script');
|
||
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);
|
||
}
|
||
|
||
async 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)) {
|
||
vvAlert('Name must be letters, numbers, _ or - only (no spaces, no .sh)');
|
||
return;
|
||
}
|
||
if (!await vvConfirm('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) { vvAlert('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'; });
|
||
}
|
||
|
||
async function vvDeleteScript() {
|
||
if (!vvEditorId) return;
|
||
const name = vvEditorId.replace(/^Custom\//, '').replace(/\.sh$/, '');
|
||
if (!await vvConfirm('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) { vvAlert('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(${vvEscAttr(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(${vvEscAttr(JSON.stringify(dir))})" title="${vvEscAttr(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="${vvEscAttr(file)}"
|
||
onclick="_vvImpSelectFile(${vvEscAttr(JSON.stringify(file))})" title="${vvEscAttr(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;
|
||
}
|
||
|
||
async function _vvImpDoImport() {
|
||
if (!_vvImpSelected) return;
|
||
const dest = (window.__vvCustomScriptsDir || 'Custom Scripts') + '/' + _vvImpSelected.replace(/^.*\//, '');
|
||
if (!await vvConfirm('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) {
|
||
vvAlert('Import failed: ' + (d.error || 'Unknown error'));
|
||
btn.disabled = false;
|
||
btn.textContent = 'Import';
|
||
return;
|
||
}
|
||
if (d.warning) vvAlert(d.warning);
|
||
window.location.reload();
|
||
})
|
||
.catch(e => {
|
||
vvAlert('Import failed: ' + e);
|
||
btn.disabled = false;
|
||
btn.textContent = 'Import';
|
||
});
|
||
}
|
||
|
||
function vvShowConfMode(title) {
|
||
vvAiDockScope('varaverk', (title || 'a script') + ' settings');
|
||
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);
|
||
vvCfInitArrays(cf);
|
||
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 = '';
|
||
// Continuous numbering across the whole panel, not restarting per group. It mirrors the
|
||
// editor's line gutter, and it gives every setting one unambiguous handle — "number 12"
|
||
// beats "the third one under Docker Watchdog" when someone is reading it back to you.
|
||
// The stripe is driven by this counter rather than :nth-child, because the group header is
|
||
// also a child and would throw the parity off inside every section.
|
||
let n = 0;
|
||
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) {
|
||
n++;
|
||
html += '<div class="vv-cf-field' + (n % 2 === 0 ? ' vv-cf-alt' : '') + '">';
|
||
html += '<span class="vv-cf-num">' + n + '</span>';
|
||
html += '<div class="vv-cf-body">';
|
||
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 {
|
||
// Arrays get a numbered gutter and striped rows. DAILY_MAINTENANCE_SCRIPTS is 25 lines
|
||
// of script paths and interleaved comment blocks; as a bare textarea it reads as one
|
||
// block of text and you cannot tell where an entry ends. The stripes do the separating
|
||
// and the numbers give each line something to be referred to by.
|
||
const rows = Math.min(20, (f.value.match(/\n/g) || []).length + 3);
|
||
html += '<div class="vv-cf-arraywrap">'
|
||
+ '<pre class="vv-cf-lines" aria-hidden="true"></pre>'
|
||
+ '<textarea class="vv-cf-input vv-cf-array"'
|
||
+ ' data-key="' + esc(f.key) + '" data-file="' + esc(f.file) + '" data-type="' + esc(f.type) + '"'
|
||
+ ' oninput="vvCfLines(this)" onscroll="vvCfLineScroll(this)"'
|
||
+ ' rows="' + rows + '">' + esc(f.value) + '</textarea>'
|
||
+ '</div>';
|
||
}
|
||
html += '</div></div>';
|
||
}
|
||
html += '</div>';
|
||
}
|
||
return html;
|
||
}
|
||
|
||
// Line numbers for an array field. The gutter is a plain <pre> scrolled in step with the
|
||
// textarea — same shape as the main editor's gutter, minus the highlight overlay, because these
|
||
// stay ordinary editable textareas and nothing here needs syntax colouring.
|
||
function vvCfLines(ta) {
|
||
const g = ta.parentElement.querySelector('.vv-cf-lines');
|
||
if (!g) return;
|
||
const n = ta.value.split('\n').length;
|
||
let s = '';
|
||
for (let i = 1; i <= n; i++) s += i + '\n';
|
||
g.textContent = s;
|
||
g.scrollTop = ta.scrollTop;
|
||
}
|
||
function vvCfLineScroll(ta) {
|
||
const g = ta.parentElement.querySelector('.vv-cf-lines');
|
||
if (g) g.scrollTop = ta.scrollTop;
|
||
}
|
||
// Numbers cannot be produced server-side: the count follows the textarea's value, which the user
|
||
// is about to change. Called once after any render that can contain conf fields.
|
||
function vvCfInitArrays(root) {
|
||
(root || document).querySelectorAll('.vv-cf-array').forEach(vvCfLines);
|
||
}
|
||
|
||
async function vvSaveConf() {
|
||
if (!vvConfId) return;
|
||
const _cfName = vvConfId.replace(/\.sh$/, '').split('/').pop();
|
||
if (!await vvConfirm('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';
|
||
vvAlert('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');
|
||
}
|
||
|
||
// Condensed ↔ full for the help block. Both views live inside the collapsible body, so this is
|
||
// only about which one is on screen — the chevron still collapses the block outright, the same
|
||
// as every other block on the page. Persisted, because which depth you want is a preference and
|
||
// not a per-visit decision.
|
||
function vvToggleHowToMore(btn) {
|
||
const brief = document.getElementById('vv-howto-brief');
|
||
const full = document.getElementById('vv-howto-full');
|
||
if (!brief || !full) return;
|
||
const showFull = full.style.display === 'none';
|
||
full.style.display = showFull ? '' : 'none';
|
||
brief.style.display = showFull ? 'none' : '';
|
||
btn.textContent = showFull ? 'Less' : 'More info';
|
||
btn.classList.toggle('vv-more-on', showFull);
|
||
localStorage.setItem('vv-howto-more', showFull ? '1' : '0');
|
||
}
|
||
|
||
function vvRestoreHowToMore() {
|
||
if (localStorage.getItem('vv-howto-more') !== '1') return;
|
||
const btn = document.getElementById('vv-howto-more');
|
||
if (btn) vvToggleHowToMore(btn);
|
||
}
|
||
|
||
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 ? '▾' : '▸';
|
||
});
|
||
vvRestoreHowToMore();
|
||
}
|
||
|
||
// 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);
|
||
}
|
||
|
||
// ── Assistant dock ────────────────────────────────────────────────────────────
|
||
// One component, one instance. Every view calls vvAiDockScope() to say where the user now is;
|
||
// nothing else about it is per-view. Building a chat per card would mean N implementations of
|
||
// the same thing, which is how the multipart POST bug became 21 call sites across 7 pages.
|
||
|
||
let vvAiProfile = 'varaverk';
|
||
let vvAiScope = 'Scheduler';
|
||
let vvAiScopeLabel = 'Scheduler'; // what the chip reads; the target is the id behind it
|
||
let vvSchedChat = null; // the shared chat instance, once the row has rendered
|
||
|
||
// Presence of the instance, not of an element. Everything that guards on this — the "why?" buttons
|
||
// on the activity rows, the panel fit — needs a working chat, and on a host without the AI row
|
||
// there is neither.
|
||
function vvAiDockOn() { return !!vvSchedChat; }
|
||
|
||
// The profile picker, the send loop, the poll, the transcript and the store are all the component's
|
||
// now. What stays here is the part that is genuinely this tab's: which script, log or conf the
|
||
// question is about, and how that follows the view the operator has open.
|
||
if (document.getElementById('vv-sched-ai-chat')) {
|
||
vvSchedChat = VvAiChat({
|
||
prefix: 'vv-sched-ai',
|
||
profile: vvAiProfile,
|
||
scopeLabel: vvAiScopeLabel,
|
||
// No reopening the last thread here, unlike the other two surfaces. Restoring a conversation
|
||
// restores the profile it was held under, so the tab would come up as Troubleshoot pointed at
|
||
// a log nobody has opened — the chip saying one thing and vvAiScope saying "Scheduler". This
|
||
// panel's subject follows the view, and at load the view is the tab itself, so it starts where
|
||
// that puts it: the assistant, scoped to the Scheduler. Saved threads are a click away under
|
||
// Saved.
|
||
resume: false,
|
||
// Re-read at send time rather than captured, because the operator moves around this tab
|
||
// between asking and sending.
|
||
scope: () => vvAiScope,
|
||
// Reasoning for diagnosis only — see the note in the component's ask payload.
|
||
think: p => p === 'troubleshoot',
|
||
// The picker is the component's, so this is how the page learns the contract changed.
|
||
onProfile: p => { vvAiProfile = p; },
|
||
// Claims what was typed rather than asking it, once, after a fix offer is accepted. Also the
|
||
// one place that reliably sees every question, so it is where the symptom is remembered.
|
||
beforeSend: q => {
|
||
if (vvAiFixPending) { vvAiFixSave(q); return true; }
|
||
vvAiLastQ = q;
|
||
return false;
|
||
},
|
||
onTurn: () => { vvAiFixArm(); requestAnimationFrame(vvFitRight); },
|
||
// Expanding changes how much of the panel is left for the views above it. Without this the
|
||
// chat grows downward past the end of the panel instead of upward into it.
|
||
onResize: () => requestAnimationFrame(vvFitRight),
|
||
onOffer: (kind, yes) => { if (kind === 'fix') vvAiFixAnswer(yes); },
|
||
// Per-block Insert buttons render only where a page can receive them.
|
||
onInsertCode: vvAiInsertCode,
|
||
onOpenSource: vvAiOpenSource,
|
||
// Diff and per-hunk apply against whatever the editor holds at the moment of the click.
|
||
getCompareText: vvAiCompareText,
|
||
onReplaceCode: vvAiReplaceCode,
|
||
});
|
||
}
|
||
|
||
// Called by every view switch. Profile and scope are derived from what is open and shown on the
|
||
// chip — never chosen, never hidden. If the operator can see what it thinks it is looking at, a
|
||
// wrong inference costs a glance instead of a confidently wrong answer.
|
||
// label is what the chip shows; target is what the worker resolves. They differ for logs, where
|
||
// the chip wants "daily_sync_maintenance log" and the worker needs the script id it can turn
|
||
// into a path under LOG_DIR.
|
||
function vvAiDockScope(profile, label, target) {
|
||
if (!vvAiDockOn()) return;
|
||
target = target || label;
|
||
if (profile === vvAiProfile && target === vvAiScope) return;
|
||
|
||
vvAiProfile = profile;
|
||
vvAiScope = target;
|
||
vvAiScopeLabel = label;
|
||
// One call for both, so changing profile and subject together leaves one line in the transcript
|
||
// rather than two saying nearly the same thing. The component keeps the transcript and moves
|
||
// only the floor the model is told about.
|
||
vvSchedChat.retarget(profile, label, 'now looking at ' + label);
|
||
// A fix offer belongs to the thing it was diagnosed against; moving the subject abandons it.
|
||
vvAiFixWatch = null;
|
||
|
||
// The scope moved under text already typed. Not blocked — just never silent.
|
||
const input = document.getElementById('vv-sched-ai-input');
|
||
if (input && input.value.trim() !== '') {
|
||
// The button, not the label span inside it. The flash animates a background, and the
|
||
// background lives on the button that triggers the picker.
|
||
const chip = document.getElementById('vv-sched-ai-chip');
|
||
if (chip) {
|
||
chip.classList.remove('vv-ai-chip-flash');
|
||
void chip.offsetWidth;
|
||
chip.classList.add('vv-ai-chip-flash');
|
||
}
|
||
}
|
||
}
|
||
|
||
|
||
// ── Recording a fix ───────────────────────────────────────────────────────────
|
||
// The symptom is taken from the question that was being asked; the fix is typed by the operator.
|
||
// The model's diagnosis is deliberately not saved — it is a reading of evidence, and writing a
|
||
// hypothesis into institutional memory as settled fact is how a wrong answer outlives the
|
||
// incident it came from. What gets remembered is what actually worked.
|
||
//
|
||
// Offered, not buttoned. This used to be a "Save fix" button that appeared after any answer that
|
||
// was not an error, which meant it was on screen after nearly every question — so it read as
|
||
// furniture and was mostly ignored. The offer instead waits for the one fact that says the
|
||
// trouble is actually over: the script that was being diagnosed runs again and exits clean. That
|
||
// arrives once per episode, at the moment it is true, and it comes from the run record rather
|
||
// than from a guess about the conversation.
|
||
let vvAiFixWatch = null; // { scope, symptom, since } while a diagnosis is waiting on a good run
|
||
let vvAiFixAsk = null; // { scope, symptom } while the offer is open and awaiting an answer
|
||
|
||
// Armed when a troubleshoot turn finishes. The question is the symptom; the scope is whatever was
|
||
// being diagnosed. `since` guards against a run that had already completed before the diagnosis
|
||
// started being read as proof that the diagnosis worked.
|
||
// ── AI → editor handoff ──────────────────────────────────────────────────────────────────────
|
||
// Asking the assistant for a script and then hand-selecting it out of the transcript is the one
|
||
// step in this tab that is pure friction — and a transcript selection loses leading whitespace
|
||
// the moment a line wraps, which for bash is not cosmetic. When the editor is open and an answer
|
||
// carries a real code block, offer to place it instead.
|
||
//
|
||
// It is an offer, never automatic: the editor usually holds work in progress, and a reply that
|
||
// silently rewrote it would be far worse than copy/paste.
|
||
|
||
// Clicking a retrieved source here opens it where it can be changed, not just read. The component
|
||
// falls back to its read-only viewer for anything this declines — which is everything that is not
|
||
// a script, since api/script.php serves scripts and the index also carries READMEs and manuals.
|
||
function vvAiOpenSource(path) {
|
||
if (!path) return;
|
||
if (/\.sh$/.test(path)) {
|
||
vvEditScript(path);
|
||
if (vvSchedChat) vvSchedChat.note('Opened ' + path + ' in the editor.');
|
||
return;
|
||
}
|
||
vvAiOpen(path);
|
||
}
|
||
|
||
// display:'' vs 'none' is how this panel switches views — see vvShowEditor and friends.
|
||
function vvAiEditorOpen() {
|
||
const ed = document.getElementById('vv-editor');
|
||
return !!ed && ed.style.display !== 'none';
|
||
}
|
||
|
||
// What a proposed block is diffed against: the editor exactly as it stands right now, not as it
|
||
// was when the answer arrived. Read fresh on every draw, so applying one hunk and redrawing shows
|
||
// the remaining differences rather than a stale picture.
|
||
function vvAiCompareText() {
|
||
const ta = document.getElementById('vv-editor-body');
|
||
return (ta && vvAiEditorOpen()) ? ta.value : '';
|
||
}
|
||
|
||
// Takes the whole rewritten file back from a hunk apply. Whole-file rather than a splice because
|
||
// the component computed the result against this exact text — handing back a range would make
|
||
// both sides responsible for the arithmetic, and only one of them can be right.
|
||
function vvAiReplaceCode(text) {
|
||
const ta = document.getElementById('vv-editor-body');
|
||
if (!ta || !vvAiEditorOpen()) {
|
||
if (vvSchedChat) vvSchedChat.note('The editor is not open — nothing was applied.');
|
||
return;
|
||
}
|
||
|
||
// Bracketed immediately, so one Ctrl+Z steps back over the whole hunk rather than unpicking it
|
||
// a keystroke at a time. Cursor position is kept where it was where that still makes sense.
|
||
vvUndoCapture(true);
|
||
const caret = Math.min(ta.selectionStart, text.length);
|
||
ta.value = text;
|
||
ta.selectionStart = ta.selectionEnd = caret;
|
||
vvUndoCapture(true);
|
||
|
||
vvSyncHlOverlay();
|
||
vvEditorCursorMoved();
|
||
}
|
||
|
||
// Called by the chat component's per-block Insert button, with that block's exact text. This
|
||
// replaced a whole-answer offer: the offer had to guess which block was meant when an answer
|
||
// carried several, and it asked after every code answer whether or not anything was wanted.
|
||
// A button on each block needs no guess and no question.
|
||
function vvAiInsertCode(code) {
|
||
if (!code) return;
|
||
|
||
const ta = document.getElementById('vv-editor-body');
|
||
// They may have switched views between the answer and the click. Inserting into a hidden
|
||
// textarea would look like nothing happened and be discovered much later.
|
||
if (!ta || !vvAiEditorOpen()) {
|
||
vvSchedChat.note('The editor is not open — nothing was inserted.');
|
||
return;
|
||
}
|
||
|
||
// Commit the pre-insert state immediately. Without the flag, the 600ms coalescing window can
|
||
// merge this with whatever was typed just before it, and one Ctrl+Z would take back both.
|
||
vvUndoCapture(true);
|
||
|
||
const s = ta.selectionStart, e = ta.selectionEnd;
|
||
ta.value = ta.value.slice(0, s) + code + ta.value.slice(e);
|
||
ta.selectionStart = ta.selectionEnd = s + code.length;
|
||
|
||
// A programmatic value change fires no input event, so the highlight overlay, the line gutter
|
||
// and the current-line marker all keep rendering the old text until these are called by hand.
|
||
vvUndoCapture(true);
|
||
vvSyncHlOverlay();
|
||
vvEditorCursorMoved();
|
||
ta.focus();
|
||
|
||
vvSchedChat.note('Inserted at the cursor. Ctrl+Z in the editor takes it back.');
|
||
}
|
||
|
||
function vvAiFixArm() {
|
||
if (vvAiProfile !== 'troubleshoot' || !vvAiScope) return;
|
||
vvAiFixWatch = {
|
||
scope: vvAiScope,
|
||
symptom: vvAiLastQ || 'diagnosed from the log',
|
||
since: Math.floor(Date.now() / 1000),
|
||
};
|
||
}
|
||
|
||
// Called from the activity poll with each completed run. One offer per episode: the watch is
|
||
// cleared as it fires.
|
||
function vvAiFixRunOk(id, startedAt) {
|
||
const w = vvAiFixWatch;
|
||
if (!w || !vvAiDockOn()) return;
|
||
if (id !== w.scope || startedAt < w.since) return;
|
||
vvAiFixWatch = null;
|
||
vvAiFixAsk = { scope: w.scope, symptom: w.symptom };
|
||
// Raw, not escaped — offer() renders through the component's fmt(), which escapes first.
|
||
vvSchedChat.offer('fix', id + ' just ran clean. Want me to record what fixed it?');
|
||
}
|
||
|
||
function vvAiFixAnswer(yes) {
|
||
const ask = vvAiFixAsk;
|
||
vvAiFixAsk = null;
|
||
if (!yes || !ask) return;
|
||
// The fix itself is still typed, because only the operator knows what they actually changed.
|
||
// The next thing sent is captured as the fix rather than asked as a question.
|
||
vvAiFixPending = ask;
|
||
const input = document.getElementById('vv-sched-ai-input');
|
||
if (input) {
|
||
input.placeholder = 'What actually fixed it? (saved against ' + ask.scope + ')';
|
||
input.focus();
|
||
}
|
||
}
|
||
|
||
let vvAiFixPending = null;
|
||
|
||
function vvAiFixSave(text) {
|
||
const ask = vvAiFixPending;
|
||
vvAiFixPending = null;
|
||
const input = document.getElementById('vv-sched-ai-input');
|
||
if (input) input.placeholder = 'Ask about what is on screen…';
|
||
if (!ask) return;
|
||
fetch('/plugins/varaverk/api/ai.php', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
|
||
body: new URLSearchParams({ action: 'incident_add', scope: ask.scope,
|
||
symptom: ask.symptom, fix: text }),
|
||
}).then(r => r.json()).then(d => {
|
||
vvSchedChat.note(d.ok
|
||
? 'saved against ' + ask.scope + ' — it will be shown next time this is diagnosed'
|
||
: 'could not save: ' + (d.error || 'unknown'));
|
||
}).catch(e => vvSchedChat.note('save failed: ' + e));
|
||
}
|
||
|
||
// The last question asked, kept so a fix filed later has a symptom to go with it. Written by the
|
||
// component's beforeSend hook, which is the one place every question passes through.
|
||
let vvAiLastQ = '';
|
||
|
||
// Asks about one run from the Recent Activity list, in a single click.
|
||
//
|
||
// It opens the log first rather than asking from wherever the operator happens to be standing.
|
||
// That is not decoration: vvOpenRight() runs vvShowLogMode(), which scopes the dock to
|
||
// troubleshoot against this script's log id — the profile that gets the log tail and may file a
|
||
// bug. Asking without it would send the question up under the assistant's contract with no log
|
||
// attached, which is the failure this whole day was spent removing.
|
||
//
|
||
// The question is posted as text the operator can see in the transcript, not hidden in the
|
||
// request, so what was asked on their behalf is never a mystery.
|
||
function vvAiAskRun(id, failed, ev) {
|
||
if (ev) ev.stopPropagation();
|
||
if (!vvAiDockOn() || vvSchedChat.busy()) return;
|
||
vvOpenRight(id);
|
||
const input = document.getElementById('vv-sched-ai-input');
|
||
input.value = failed ? 'Why did this run fail?' : 'How did this run go?';
|
||
vvSchedChat.send();
|
||
}
|
||
|
||
// ── 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('
|
||
+ vvEscAttr(JSON.stringify(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;
|
||
}
|
||
|
||
// Opens the offending line in the log, and acknowledges it in the same click.
|
||
//
|
||
// Acking here is safe in a way a bare Ack button is not: the operator cannot dismiss this without
|
||
// the line being put in front of them, so "acknowledged" means it was seen rather than cleared.
|
||
// That is the only reason the two actions belong on one click.
|
||
//
|
||
// The line is located by its text rather than by a line number. board.php scans the last 200
|
||
// lines and log.php serves the last 200, so an index would agree only while the log sat still —
|
||
// and these are logs of things that are still running. Matching the text survives the window
|
||
// moving underneath it, and when the line has genuinely scrolled out of the tail the log still
|
||
// opens, just without the mark.
|
||
function vvErrOpenAtLine(script, ts, lineText, el) {
|
||
const search = document.getElementById('vv-log-search');
|
||
// A filter would hide the surrounding lines, which are the reason for opening the log at all.
|
||
if (search && search.value.trim()) search.value = '';
|
||
vvOpenRight(script + '.sh');
|
||
vvLogMark = lineText;
|
||
vvLogMarkScroll = true;
|
||
vvAckError(script, ts, el);
|
||
}
|
||
|
||
// Marks one line in the rendered log and, the first time, scrolls it to the middle. Re-applied on
|
||
// every poll so a live log does not blink the mark away while it is being read.
|
||
function vvMarkLogLine(pre, display, invert) {
|
||
const want = (vvLogMark || '').trim();
|
||
if (!want) return false;
|
||
const lines = display.split('\n');
|
||
|
||
// Scanned in file order from the end, because that is the occurrence board.php reported: it
|
||
// walks the log backwards and stops at the first hit. Taking the first match instead would mark
|
||
// an earlier copy of a line that repeats — "Findings: 1", "✗ failed" — and quietly point at a
|
||
// previous run in the same file. Inverting the view reverses file order, so the direction has
|
||
// to follow it.
|
||
const n = lines.length;
|
||
const seek = test => {
|
||
for (let k = 0; k < n; k++) {
|
||
const i = invert ? k : n - 1 - k;
|
||
if (test(lines[i])) return i;
|
||
}
|
||
return -1;
|
||
};
|
||
|
||
let idx = seek(l => l.trim() === want);
|
||
// board.php truncates the line at 220 characters, so an exact match is not guaranteed.
|
||
if (idx < 0) idx = seek(l => l.includes(want));
|
||
if (idx < 0 && want.length > 40) idx = seek(l => l.includes(want.slice(0, 40)));
|
||
if (idx < 0) return false;
|
||
|
||
const esc = s => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||
pre.innerHTML = lines.map((l, i) => i === idx
|
||
? '<mark class="vv-log-jump" id="vv-log-jump-hit">' + esc(l) + '</mark>'
|
||
: esc(l)).join('\n');
|
||
|
||
if (vvLogMarkScroll) {
|
||
vvLogMarkScroll = false;
|
||
const hit = document.getElementById('vv-log-jump-hit');
|
||
if (hit) requestAnimationFrame(() => {
|
||
// Measured against the pre rather than scrollIntoView(), which would also scroll the page.
|
||
const rel = hit.getBoundingClientRect().top - pre.getBoundingClientRect().top + pre.scrollTop;
|
||
pre.scrollTop = Math.max(0, rel - pre.clientHeight / 2);
|
||
});
|
||
}
|
||
return true;
|
||
}
|
||
|
||
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) {
|
||
// JSON.stringify for correct JS literals, then attribute-escaped: the error text is arbitrary
|
||
// log output and absolutely will contain quotes eventually.
|
||
const scriptJs = vvEscAttr(JSON.stringify(e.script));
|
||
const lineJs = vvEscAttr(JSON.stringify(e.line));
|
||
const label = e.script.split('/').pop().replace(/_/g, ' ');
|
||
html += '<div class="vv-err-row">'
|
||
+ '<div class="vv-err-top">'
|
||
// The name goes to the same place as the text below it. Two clickable halves of one row
|
||
// behaving differently — one acking, one not — is how an error looks acknowledged when
|
||
// it is not, which is worse than either behaviour on its own.
|
||
+ '<span class="vv-err-script" style="cursor:pointer"'
|
||
+ ' title="' + vvEscAttr(e.script) + ' — opens the log at this line and acknowledges it"'
|
||
+ ' onclick="vvErrOpenAtLine(' + scriptJs + ',' + e.ts + ',' + lineJs + ',this)">'
|
||
+ vvEscHtml(label) + '</span>'
|
||
+ '<span class="vv-err-age">' + vvFmtAge(now - e.ts) + '</span>'
|
||
+ '<button class="vv-btn-sm vv-ack-btn" onclick="vvAckError(' + scriptJs + ',' + e.ts + ',this)">Ack</button>'
|
||
+ '</div>'
|
||
+ '<div class="vv-err-line vv-err-line-open"'
|
||
+ ' title="Open the log at this line — also acknowledges it"'
|
||
+ ' onclick="vvErrOpenAtLine(' + scriptJs + ',' + e.ts + ',' + lineJs + ',this)">'
|
||
+ 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>';
|
||
}
|
||
|
||
async function vvClearLock(file, btn) {
|
||
if (!await vvConfirm('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 || '';
|
||
// Searching replaces the jumped-to line as the thing being looked for; leaving the mark set
|
||
// would put it back on the next poll and fight the filter.
|
||
if (term.trim()) { vvLogMark = null; vvLogMarkScroll = false; }
|
||
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);
|
||
const withAi = vvAiDockOn();
|
||
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';
|
||
const bad = r.status !== 'ok' && r.status !== 'skipped';
|
||
if (bad) errors++;
|
||
// The one fact that says a diagnosis worked: the script it was about ran again and exited
|
||
// clean. This poll is already watching for it, so the offer costs a comparison rather than
|
||
// a second source of truth.
|
||
if (r.status === 'ok') vvAiFixRunOk(r.id, r.start);
|
||
// The question is on the row because that is where the operator already is when they want
|
||
// it. Reaching the same answer otherwise means noticing the red dot, clicking through to
|
||
// the log, finding the dock, and typing out what the row already knows.
|
||
const ask = withAi
|
||
? '<button class="vv-activity-ask' + (bad ? ' vv-ask-bad' : '') + '"'
|
||
+ ' title="' + (bad ? 'Ask the assistant why this run failed'
|
||
: 'Ask the assistant how this run went') + '"'
|
||
+ ' onclick="vvAiAskRun(' + vvEscAttr(JSON.stringify(r.id)) + ',' + bad + ',event)">'
|
||
+ (bad ? 'why?' : 'recap') + '</button>'
|
||
: '';
|
||
html += '<div class="vv-activity-row" onclick="vvOpenRight(' + vvEscAttr(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>'
|
||
+ ask
|
||
+ '</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) {
|
||
vvAiDockScope('varaverk', title || 'a conf file');
|
||
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);
|
||
}
|
||
|
||
async function vvSaveRawConf() {
|
||
if (!vvRawConfFile) return;
|
||
if (!vvSetupConf && !await vvConfirm('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';
|
||
vvAlert('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);
|
||
}
|
||
|
||
// Retitle the banner for this view: "Daily Sync Maintenance" → "Daily Sync Maintenance Info &
|
||
// Settings", re-centred so the = rules still line up. Done at display time and never to the file
|
||
// — that banner is the script's own header, it is parsed by include/scheduler.php for the
|
||
// library listing, and it belongs to the script rather than to this panel.
|
||
//
|
||
// Matches only the titled line: the plain rules above and below have no text between their =
|
||
// runs, so the pattern cannot hit them.
|
||
function vvSiRetitle(hdr, suffix) {
|
||
const lines = String(hdr || '').split('\n');
|
||
for (let i = 0; i < lines.length; i++) {
|
||
const m = lines[i].match(/^(\s*)(=+)\s+(\S.*?)\s+(=+)\s*$/);
|
||
if (!m) continue;
|
||
const indent = m[1];
|
||
const width = lines[i].trimEnd().length - indent.length;
|
||
const title = ' ' + m[3].trim() + ' ' + suffix + ' ';
|
||
const pad = Math.max(2, width - title.length);
|
||
const left = Math.floor(pad / 2);
|
||
lines[i] = indent + '='.repeat(left) + title + '='.repeat(pad - left);
|
||
break;
|
||
}
|
||
return lines.join('\n');
|
||
}
|
||
|
||
function vvShowScriptInfoMode(name, hdr, id) {
|
||
vvAiDockScope('varaverk', name || 'a script');
|
||
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(vvSiRetitle(dispHdr, 'Info & Settings'), 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 = '';
|
||
} else {
|
||
// Say so rather than showing nothing. An absent Config block is ambiguous — it reads the
|
||
// same whether the script genuinely has no settings or nobody ever mapped it, and that
|
||
// ambiguity hid 15 scripts' settings until the conf was audited against the map.
|
||
html += '<div class="vv-sinfo-block">'
|
||
+ '<div class="vv-sinfo-lbl">Config</div>'
|
||
+ '<p class="vv-cf-none">No user adjustable settings — this script is driven by its '
|
||
+ 'own logic and the values it inherits.</p>'
|
||
+ '</div>';
|
||
}
|
||
|
||
si.innerHTML = html || '<p class="vv-cf-empty">No additional information found.</p>';
|
||
vvCfInitArrays(si);
|
||
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(vvSiRetitle(hdr, 'Info & Settings'), 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);
|
||
vvAlert('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) { vvAlert(`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) { vvAlert('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>
|