Compare commits

...
13 Commits
Author SHA1 Message Date
Gmer4Lfe 6b30768853 webgui_watchdog: mark executable 2026-05-24 23:33:39 -04:00
Gmer4Lfe 8e0d68665c Move dangling image prune from docker_update.sh to docker_daily_restart.sh
Restarts create the orphans — prune belongs in the same script immediately
after the restart loop, not in the update script that runs before it.
2026-05-24 23:32:00 -04:00
Gmer4Lfe 83ff9fca5e docker_update: prune dangling images after pull (both normal and remainder modes)
Daily restarts create orphaned images that were never cleaned up.
Added docker image prune -f section matching docker_update_remaining.sh pattern.
Prune runs each cycle so orphans from the previous day's restart are removed daily
rather than waiting for the weekly docker_update_remaining.sh sweep.
2026-05-24 23:14:31 -04:00
Gmer4Lfe 83195ebb9e monthly_maintenance: reschedule to 0 0 15 * * (15th at midnight)
Previously ran daily at 3am with internal self-gating. Now scheduled directly
on the 15th — uptime and interval gates still apply; a skip means next month's 15th.
2026-05-24 23:10:31 -04:00
Gmer4Lfe dfb462a18e Varaverk: suggested cron hints left of every cron input
- vv_script_children() now calls vv_script_suggested_cron() per child,
  adding suggested_cron and suggested_label to each child entry
- Orch rows (non-event) and regular script child rows render a faint
  .vv-cron-hint span to the left of the cron input showing the suggested
  cron from the script's # Schedule: header
- Clicking the hint fills in the cron input (data-cron + onclick)
- Tooltip shows full "suggested_cron — label" string on hover
- .vv-cron-hint: color #444, brightens to #888 on hover
2026-05-24 22:59:33 -04:00
Gmer4Lfe d6cfcaef46 Varaverk: always open Scheduler Information on page load; restore button for last log 2026-05-24 22:51:35 -04:00
Gmer4Lfe eade6072b0 Varaverk: Scheduler Information panel with structured how-it-works sections
- Right panel title: "Suggested Schedules" → "Scheduler Information"
- Removed top hint paragraph; info now lives in the panel itself
- Added three collapsible info sections (open by default) at top of panel:
  Controls — toggle, cron, run, dry run, log, stop, verbose, config
  Orchestrators — enabled/disabled behavior, child cron rules, event triggers
  Children & Advanced — toggle semantics, independent cron, rsync flag badge
- Bullet lists use CSS columns:2 with column-fill:balance for even distribution
- "Suggested Schedules" divider separates info from parsed schedule blocks
- Added vvToggleSug() for DOM-traversal-based accordion (no ID dependency)
2026-05-24 22:47:34 -04:00
Gmer4Lfe cdd2dfc990 Varaverk: rsync flag toggle + stop button
- Rsync/rsync.sh children in Advanced now show as conf_flag type when the
  parent orch controls a *_RSYNC_ENABLED tier flag; toggle writes true/false
  to master.conf instead of comment/uncommenting a SCRIPTS array entry
- Added vv_conf_flag_value() and vv_conf_flag_set() helpers in scheduler.php
- Added api/flag_toggle.php endpoint (validates *_RSYNC_ENABLED pattern)
- Added api/stop.php: kills process group, sweeps stuck locks, updates stat
- vv-flag-badge CSS (amber, monospace) to distinguish from event/script rows
- vvSaveChild() routes conf_flag children to flag_toggle.php unconditionally
- vvApplyOrchState() keeps conf_flag toggle at real flag value; disables when orch off
- vvSaveAll() skips conf_flag children (immediate-save only)
2026-05-24 22:38:33 -04:00
Gmer4Lfe d0d56ed8b9 scheduler: orch-god toggle model — children managed via master.conf
When orch is ON (god mode):
- Children's toggle state is read from master.conf comment status
- Toggling a child comments/uncomments its line in the *_SCRIPTS array
- Child crons are suppressed in vv_cron_rebuild — orch is the sole trigger
- Children not found in any *_SCRIPTS array are shown disabled (read-only)

When orch is OFF:
- All children flip to off; schedule.json updated immediately
- A child with a cron value + enabled toggle gets its own independent cron entry
- A child with no cron does nothing when enabled

New: vv_conf_script_map() — cached per-request scan of master.conf arrays
New: vv_parse_conf_array_full() — includes commented entries (disabled scripts)
New: vv_conf_toggle_script() — comments/uncomments a script line in master.conf
New: api/conf_toggle.php — endpoint for child toggle → master.conf write
2026-05-24 22:24:14 -04:00
Gmer4Lfe 3186097941 scheduler: revert legend grid — restore original hint paragraph 2026-05-24 22:06:48 -04:00
Gmer4Lfe 4832dd8d4e scheduler: replace hint paragraph with compact 3-column legend grid 2026-05-24 21:56:27 -04:00
Gmer4Lfe 655c520ae8 scheduler: add Array Starting / Array Stopping event triggers
Two hardcoded event-triggered entries appear at the top of the scheduler
(array_started.sh / array_stopping.sh). They show an  Array Start /
 Array Stop badge instead of a cron field and are enabled/disabled via
the normal toggle.

Static event scripts fire them via Unraid's event system:
- event/disks_mounted/array_start_jobs  → runs in background (non-blocking)
- event/disks_unmounting/array_stop_jobs → runs foreground (blocks until done)

vv_cron_rebuild() skips @array_* entries so they never land in the cron file.
Scripts executed on each event are managed in master.conf via
ARRAY_START_SCRIPTS and ARRAY_STOP_SCRIPTS arrays.
2026-05-24 21:54:13 -04:00
Gmer4Lfe 1540c62d3a scheduler: smart auto-scroll replaces separate scroll lock checkbox; lidarr: allow single-seed candidates
Auto Scroll now pauses automatically when user scrolls up and resumes
when they reach the bottom — no separate Scroll Lock needed.

Lidarr discovery no longer hard-skips single-seed candidates; they
score low on breadth but can still reach threshold on merit.
2026-05-24 21:40:03 -04:00
15 changed files with 683 additions and 110 deletions
+17
View File
@@ -344,6 +344,22 @@ done
END=$(date +%s) END=$(date +%s)
# ==============================================================================================
# ━━━ Prune Old Images ━━━
# ==============================================================================================
# Restarts above swap containers onto new images — old images are now dangling. Prune immediately.
echo ""
echo "━━━ $ICON_SYNC Pruning Dangling Images — $(date '+%Y-%m-%d %H:%M:%S') ━━━"
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would prune dangling images"
PRUNED_SUMMARY="(dry run)"
else
PRUNED_OUTPUT=$(docker image prune -f 2>&1)
[[ "$ENABLE_LOGGING" == "true" ]] && echo "$PRUNED_OUTPUT" | sed 's/^/ /'
PRUNED_SUMMARY=$(echo "$PRUNED_OUTPUT" | grep -E "^Total reclaimed" || echo "nothing reclaimed")
fi
# ============================================================================================== # ==============================================================================================
# ━━━ Summary ━━━ # ━━━ Summary ━━━
# ============================================================================================== # ==============================================================================================
@@ -354,6 +370,7 @@ echo "$ICON_CONTAINERS Scope: ${#RESTARTED[@]} restarted, ${#SKIPPED[@]} skipp
[[ ${#RESTARTED[@]} -gt 0 ]] && log "$ICON_STARTED Restarted: ${RESTARTED[*]}" [[ ${#RESTARTED[@]} -gt 0 ]] && log "$ICON_STARTED Restarted: ${RESTARTED[*]}"
[[ ${#SKIPPED[@]} -gt 0 ]] && log "$ICON_NOT_RUNNING Skipped: ${SKIPPED[*]}" [[ ${#SKIPPED[@]} -gt 0 ]] && log "$ICON_NOT_RUNNING Skipped: ${SKIPPED[*]}"
[[ ${#FAILED[@]} -gt 0 ]] && echo "$ICON_ERROR Failed: ${FAILED[*]}" [[ ${#FAILED[@]} -gt 0 ]] && echo "$ICON_ERROR Failed: ${FAILED[*]}"
echo "$ICON_SYNC Pruned: ${PRUNED_SUMMARY:-none}"
if [[ "$DRY_RUN" == true ]]; then if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — no changes made" warn "DRY RUN — no changes made"
+1 -1
View File
@@ -30,7 +30,7 @@
# DAILY_RESTART_CONTAINERS — already updated daily # DAILY_RESTART_CONTAINERS — already updated daily
# emby + critical-data profiles — updated inline by the weekly sync window # emby + critical-data profiles — updated inline by the weekly sync window
# FALLBACK_*_TIER* — owned by the remote server's update cycle # FALLBACK_*_TIER* — owned by the remote server's update cycle
# Pull → compare → restart if updated → prune dangling images. # Pull → compare → prune dangling images.
# #
# ============================================================================================== # ==============================================================================================
# DESIGN PRINCIPLES # DESIGN PRINCIPLES
-6
View File
@@ -571,12 +571,6 @@ for candidate in "${!CANDIDATE_SCORE[@]}"; do
days_ago=0 days_ago=0
fi fi
if (( seed_count < 2 )); then
log " $ICON_SKIP Single-seed candidate skipped: $candidate"
REJECT_LIST+=("0|${candidate}|${seed_count}|0|single-seed skip")
continue
fi
affinity_s=$(_affinity_score "$raw_score") affinity_s=$(_affinity_score "$raw_score")
breadth_s=$(_breadth_score "$seed_count") breadth_s=$(_breadth_score "$seed_count")
+4 -5
View File
@@ -7,15 +7,14 @@
# 1. Server uptime >= MONTHLY_UPTIME_THRESHOLD_DAYS days # 1. Server uptime >= MONTHLY_UPTIME_THRESHOLD_DAYS days
# 2. Last run was >= MONTHLY_RUN_INTERVAL_DAYS days ago (or never run) # 2. Last run was >= MONTHLY_RUN_INTERVAL_DAYS days ago (or never run)
# #
# ── WHY UPTIME-TRIGGERED, NOT CRON ─────────────────────────────────────────────────────────── # ── WHY UPTIME-GATED ─────────────────────────────────────────────────────────────────────────
# A scheduled reboot resets uptime. Monthly tasks (ZFS scrub, SMART long test) need a # A scheduled reboot resets uptime. Monthly tasks (ZFS scrub, SMART long test) need a
# stable, settled system — not one that just rebooted. Uptime-gating ensures maintenance # stable, settled system — not one that just rebooted. Uptime-gating ensures maintenance
# only runs after the server has been healthy for a full month, never immediately post-boot. # only runs after the server has been healthy for a full month, never immediately post-boot.
# The daily cron is just the trigger mechanism. The uptime and interval checks inside # If uptime or interval gate is not met on the 15th, the run is skipped until next month.
# the script are what enforce the monthly cadence.
# #
# ── HOW TO CALL ────────────────────────────────────────────────────────────────────────────── # ── HOW TO CALL ──────────────────────────────────────────────────────────────────────────────
# Cron: 0 3 * * * — daily 3am check. Script self-gates — calling it daily is safe. # Schedule: 0 0 15 * * (15th of each month at midnight)
# Silent exit 0 when either gate is not met. Only outputs when maintenance actually fires. # Silent exit 0 when either gate is not met. Only outputs when maintenance actually fires.
# #
# ── STATE FILE ──────────────────────────────────────────────────────────────────────────────── # ── STATE FILE ────────────────────────────────────────────────────────────────────────────────
@@ -153,7 +152,7 @@ if [[ "$SHOW_STATUS" == true ]]; then
fi fi
echo "" echo ""
echo " Schedule: 0 3 * * * (daily check — script self-gates)" echo " Schedule: 0 0 15 * * (15th of each month at midnight)"
echo " Force flag: --force bypasses both gates" echo " Force flag: --force bypasses both gates"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
exit 0 exit 0
@@ -0,0 +1,14 @@
<?php
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/scheduler.php';
$id = trim($_POST['id'] ?? '');
$enabled = ($_POST['enabled'] ?? '0') === '1';
if (!$id || !preg_match('/^[a-zA-Z0-9_.\/\-]+\.sh$/', $id) || str_contains($id, '..')) {
echo json_encode(['ok' => false, 'error' => 'Invalid id']);
exit;
}
$ok = vv_conf_toggle_script($id, $enabled);
echo json_encode(['ok' => $ok, 'error' => $ok ? null : 'Failed to write master.conf']);
@@ -0,0 +1,14 @@
<?php
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/scheduler.php';
$name = trim($_POST['name'] ?? '');
$enabled = ($_POST['enabled'] ?? '0') === '1';
if (!$name || !preg_match('/^[A-Z_]+_RSYNC_ENABLED$/', $name)) {
echo json_encode(['ok' => false, 'error' => 'Invalid flag name']);
exit;
}
$ok = vv_conf_flag_set($name, $enabled);
echo json_encode(['ok' => $ok, 'error' => $ok ? null : 'Failed to write master.conf']);
@@ -12,8 +12,9 @@ if (!$id) {
exit; exit;
} }
// Basic cron validation — 5 fields or empty // Basic cron validation — 5 fields, or known @event trigger, or empty
if ($cron && !preg_match('/^(\S+\s+){4}\S+$/', $cron)) { if ($cron && !in_array($cron, ['@array_start', '@array_stop'], true)
&& !preg_match('/^(\S+\s+){4}\S+$/', $cron)) {
echo json_encode(['ok' => false, 'error' => 'Invalid cron expression']); echo json_encode(['ok' => false, 'error' => 'Invalid cron expression']);
exit; exit;
} }
@@ -0,0 +1,91 @@
<?php
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/scheduler.php';
$id = trim($_POST['id'] ?? '');
if (!$id || !preg_match('/^[a-zA-Z0-9_.\/\-]+\.sh$/', $id) || str_contains($id, '..')) {
echo json_encode(['ok' => false, 'error' => 'Invalid id']);
exit;
}
$statFile = vv_job_stat_path($id);
if (!file_exists($statFile)) {
echo json_encode(['ok' => false, 'error' => 'No stat file — script may not be running']);
exit;
}
$stat = json_decode(file_get_contents($statFile) ?: '{}', true) ?: [];
if (($stat['status'] ?? '') !== 'running') {
echo json_encode(['ok' => true, 'msg' => 'Not running']);
exit;
}
$pid = (int)($stat['pid'] ?? 0);
if ($pid < 2) {
echo json_encode(['ok' => false, 'error' => 'No valid PID in stat file']);
exit;
}
// Kill the whole process group so the script and all its children die together.
// pgid is usually the same as the session leader PID from run_job.sh.
$pgid = (int)trim(shell_exec("ps -o pgid= -p $pid 2>/dev/null") ?: '0');
if ($pgid > 1) {
shell_exec("kill -TERM -$pgid 2>/dev/null");
} else {
// Fallback: kill the direct PID and its children
shell_exec("pkill -TERM -P $pid 2>/dev/null");
shell_exec("kill -TERM $pid 2>/dev/null");
}
// Give it up to 3s to exit gracefully
$dead = false;
for ($i = 0; $i < 6; $i++) {
usleep(500000);
if (!file_exists("/proc/$pid")) { $dead = true; break; }
}
// Force-kill if still alive
if (!$dead) {
if ($pgid > 1) shell_exec("kill -KILL -$pgid 2>/dev/null");
shell_exec("pkill -KILL -P $pid 2>/dev/null");
shell_exec("kill -KILL $pid 2>/dev/null");
usleep(300000);
$dead = !file_exists("/proc/$pid");
}
// Clear any lock files in /tmp/unraid_locks whose content matches this PID
$lockDir = '/tmp/unraid_locks';
$cleared = [];
foreach (glob("$lockDir/*.lock") ?: [] as $lf) {
$content = trim(file_get_contents($lf) ?: '');
$lockPid = (int)explode(':', $content)[0];
if ($lockPid === $pid || !file_exists("/proc/$lockPid")) {
@unlink($lf);
$cleared[] = basename($lf);
}
}
// Also clear by script name in case PID rotated
$scriptBase = basename($id, '.sh');
$namedLock = "$lockDir/{$scriptBase}.lock";
if (file_exists($namedLock)) {
@unlink($namedLock);
if (!in_array(basename($namedLock), $cleared)) $cleared[] = basename($namedLock);
}
// Update stat file
$now = time();
$stat['status'] = 'stopped';
$stat['end'] = $now;
$stat['exit'] = -1;
unset($stat['pid']);
file_put_contents($statFile, json_encode($stat));
echo json_encode([
'ok' => true,
'killed' => $dead,
'locks' => $cleared,
]);
@@ -57,6 +57,16 @@
cursor: default; } cursor: default; }
.vv-cron { flex: 0 0 110px; width: 110px; background: #111; border: 1px solid #444; color: #ddd; .vv-cron { flex: 0 0 110px; width: 110px; background: #111; border: 1px solid #444; color: #ddd;
padding: 4px 6px; border-radius: 4px; font-family: monospace; font-size: 13px; } padding: 4px 6px; border-radius: 4px; font-family: monospace; font-size: 13px; }
.vv-cron-hint { font-size: 11px; font-family: monospace; color: #444; cursor: pointer;
white-space: nowrap; flex-shrink: 0; padding: 3px 5px; border-radius: 3px;
user-select: none; transition: color 0.15s; }
.vv-cron-hint:hover { color: #888; background: rgba(255,255,255,0.04); }
.vv-event-badge { flex: 0 0 auto; padding: 3px 8px; border-radius: 4px; font-size: 12px;
background: #1a3a1a; border: 1px solid #2e6b2e; color: #6fcf6f;
white-space: nowrap; font-weight: 500; }
.vv-flag-badge { flex: 0 0 auto; padding: 2px 6px; border-radius: 3px; font-size: 11px;
background: #2e2200; border: 1px solid #6b4e00; color: #d4a017;
white-space: nowrap; font-family: monospace; }
.vv-log-label { display: flex; align-items: center; gap: 4px; font-size: 12px; color: #888; .vv-log-label { display: flex; align-items: center; gap: 4px; font-size: 12px; color: #888;
cursor: pointer; white-space: nowrap; flex-shrink: 0; } cursor: pointer; white-space: nowrap; flex-shrink: 0; }
.vv-log-label input { cursor: pointer; accent-color: #4caf50; } .vv-log-label input { cursor: pointer; accent-color: #4caf50; }
@@ -189,6 +199,16 @@
font-size: 11px; white-space: nowrap; } font-size: 11px; white-space: nowrap; }
.vv-sug-path { color: #888; font-family: monospace; } .vv-sug-path { color: #888; font-family: monospace; }
.vv-sug-configured { color: #4caf50; font-size: 11px; } .vv-sug-configured { color: #4caf50; font-size: 11px; }
/* Info sections inside Scheduler Information panel */
.vv-info-block .vv-sug-title { color: #9ab; }
.vv-info-body { padding: 2px 10px 10px; }
.vv-info-cols { margin: 0; padding-left: 16px; columns: 2; column-gap: 20px; column-fill: balance; }
.vv-info-cols li { font-size: 12px; color: #aaa; margin-bottom: 5px; break-inside: avoid; line-height: 1.5; }
.vv-info-cols li strong { color: #ccc; }
.vv-info-cols code { background: #1a1a1a; padding: 0 4px; border-radius: 2px;
font-size: 11px; color: #9ab; border: 1px solid #333; }
.vv-info-divider { font-size: 10px; text-transform: uppercase; letter-spacing: 0.1em; color: #444;
padding: 10px 12px 4px; border-top: 1px solid #2a2a2a; margin-top: 2px; }
/* Log panel */ /* Log panel */
.vv-log-panel { margin-top: 10px; border-top: 1px solid #333; padding-top: 8px; } .vv-log-panel { margin-top: 10px; border-top: 1px solid #333; padding-top: 8px; }
@@ -0,0 +1,13 @@
#!/bin/bash
# Varaverk: run array_started.sh if enabled in the schedule (background — non-blocking).
php -r "
require_once '/usr/local/emhttp/plugins/varaverk/include/scheduler.php';
\$s = vv_schedule_load();
\$e = \$s['Orchestrators/array_started.sh'] ?? [];
if (empty(\$e['enabled'])) exit(0);
\$runner = '/usr/local/emhttp/plugins/varaverk/run_job.sh';
\$script = SCRIPTS_DIR . '/Orchestrators/array_started.sh';
if (!file_exists(\$script)) exit(0);
\$flags = !empty(\$e['log_enabled']) ? ' --log' : '';
exec('nohup bash ' . escapeshellarg(\$runner) . ' Orchestrators/array_started.sh ' . escapeshellarg(\$script) . \$flags . ' > /dev/null 2>&1 &');
" 2>/dev/null
@@ -0,0 +1,13 @@
#!/bin/bash
# Varaverk: run array_stopping.sh if enabled in the schedule (foreground — blocks until done).
php -r "
require_once '/usr/local/emhttp/plugins/varaverk/include/scheduler.php';
\$s = vv_schedule_load();
\$e = \$s['Orchestrators/array_stopping.sh'] ?? [];
if (empty(\$e['enabled'])) exit(0);
\$runner = '/usr/local/emhttp/plugins/varaverk/run_job.sh';
\$script = SCRIPTS_DIR . '/Orchestrators/array_stopping.sh';
if (!file_exists(\$script)) exit(0);
\$flags = !empty(\$e['log_enabled']) ? ' --log' : '';
passthru('bash ' . escapeshellarg(\$runner) . ' Orchestrators/array_stopping.sh ' . escapeshellarg(\$script) . \$flags);
" 2>/dev/null
@@ -54,12 +54,30 @@ function vv_cron_rebuild(array $schedule): bool {
$lines[] = "# Regenerated: " . date('Y-m-d H:i:s'); $lines[] = "# Regenerated: " . date('Y-m-d H:i:s');
$lines[] = ""; $lines[] = "";
// Build child→orch map so we can suppress a child's independent cron when its orch is enabled.
$childToOrch = [];
$confRaw = file_get_contents(CONF_DIR . '/master.conf') ?: '';
foreach (glob(SCRIPTS_DIR . '/Orchestrators/*.sh') ?: [] as $orchPath) {
$orchId = 'Orchestrators/' . basename($orchPath);
$content = file_get_contents($orchPath) ?: '';
preg_match_all('/\$[A-Z_]+\/(?:\.\.\/)?([A-Za-z][A-Za-z0-9_.\-]*\/[A-Za-z0-9_.\-]+\.sh)/', $content, $m1);
foreach ($m1[1] as $rel) $childToOrch[$rel] = $orchId;
preg_match_all('/\$\{([A-Z_]+_SCRIPTS)\[@\]\}/', $content, $refs);
foreach (array_unique($refs[1] ?? []) as $var) {
foreach (vv_parse_conf_array_full($confRaw, $var) as $item) $childToOrch[$item['path']] = $orchId;
}
}
$scriptsDir = SCRIPTS_DIR; $scriptsDir = SCRIPTS_DIR;
foreach ($schedule as $entry) { foreach ($schedule as $entry) {
if (empty($entry['enabled']) || empty($entry['cron']) || empty($entry['id'])) continue; if (empty($entry['enabled']) || empty($entry['cron']) || empty($entry['id'])) continue;
$script = "$scriptsDir/{$entry['id']}"; // Event-triggered jobs are handled by static event scripts, not cron.
if (str_starts_with($entry['cron'], '@array_')) continue;
$id = $entry['id'];
// When a child's orch is enabled it is the sole trigger — suppress independent cron.
if (isset($childToOrch[$id]) && !empty($schedule[$childToOrch[$id]]['enabled'])) continue;
$script = "$scriptsDir/$id";
$flags = !empty($entry['log_enabled']) ? ' --log' : ''; $flags = !empty($entry['log_enabled']) ? ' --log' : '';
$id = $entry['id'];
$lines[] = "{$entry['cron']} bash \"$runner\" \"$id\" \"$script\"$flags"; $lines[] = "{$entry['cron']} bash \"$runner\" \"$id\" \"$script\"$flags";
} }
$lines[] = ""; $lines[] = "";
@@ -193,31 +211,55 @@ function vv_custom_scripts(): array {
} }
// Walk the scripts repo and return the job tree: // Walk the scripts repo and return the job tree:
// orchestrators as top-level, individual scripts as children // hardcoded array-event entries first, then cron-scheduled orchestrators
function vv_job_tree(): array { function vv_job_tree(): array {
$scriptsDir = SCRIPTS_DIR; $scriptsDir = SCRIPTS_DIR;
$schedule = vv_schedule_load(); $schedule = vv_schedule_load();
// Orchestrators are in Orchestrators/ and their children are all scripts they call // Hardcoded array-event entries — always present, trigger via Unraid event scripts
// For now: walk top-level folders, treat *_management.sh or *_maintenance.sh as orchs $eventDefs = [
$orchPattern = "$scriptsDir/Orchestrators/*.sh"; ['id' => 'Orchestrators/array_started.sh', 'cron' => '@array_start', 'label' => 'Array Starting'],
$orchs = []; ['id' => 'Orchestrators/array_stopping.sh', 'cron' => '@array_stop', 'label' => 'Array Stopping'],
];
foreach (glob($orchPattern) ?: [] as $path) { $orchs = [];
$id = 'Orchestrators/' . basename($path); $eventIds = [];
$entry = $schedule[$id] ?? ['enabled' => false, 'cron' => '']; foreach ($eventDefs as $ev) {
$suggested = vv_script_suggested_cron($path); $id = $ev['id'];
$path = "$scriptsDir/$id";
$entry = $schedule[$id] ?? [];
$eventIds[] = $id;
$orchs[] = [ $orchs[] = [
'id' => $id, 'id' => $id,
'label' => basename($path, '.sh'), 'label' => $ev['label'],
'desc' => vv_script_description($path), 'desc' => vv_script_description($path),
'type' => 'orchestrator', 'type' => 'event',
'enabled' => (bool)($entry['enabled'] ?? false), 'enabled' => (bool)($entry['enabled'] ?? false),
'cron' => $entry['cron'] ?? '', 'cron' => $ev['cron'],
'log_enabled' => (bool)($entry['log_enabled'] ?? false), 'log_enabled' => (bool)($entry['log_enabled'] ?? false),
'suggested_cron' => '',
'suggested_label' => '',
'children' => vv_script_children($path, $schedule),
];
}
// Cron-scheduled orchestrators — discovered by glob, event entries excluded
$orchPattern = "$scriptsDir/Orchestrators/*.sh";
foreach (glob($orchPattern) ?: [] as $path) {
$id = 'Orchestrators/' . basename($path);
if (in_array($id, $eventIds, true)) continue;
$entry = $schedule[$id] ?? ['enabled' => false, 'cron' => ''];
$suggested = vv_script_suggested_cron($path);
$orchs[] = [
'id' => $id,
'label' => basename($path, '.sh'),
'desc' => vv_script_description($path),
'type' => 'orchestrator',
'enabled' => (bool)($entry['enabled'] ?? false),
'cron' => $entry['cron'] ?? '',
'log_enabled' => (bool)($entry['log_enabled'] ?? false),
'suggested_cron' => $suggested['cron'], 'suggested_cron' => $suggested['cron'],
'suggested_label' => $suggested['label'], 'suggested_label' => $suggested['label'],
'children' => vv_script_children($path, $schedule), 'children' => vv_script_children($path, $schedule),
]; ];
} }
return $orchs; return $orchs;
@@ -239,6 +281,101 @@ function vv_parse_conf_array(string $conf, string $varName): array {
return $scripts; return $scripts;
} }
// Like vv_parse_conf_array but includes commented entries.
// Returns array of ['path' => string, 'enabled' => bool].
function vv_parse_conf_array_full(string $conf, string $varName): array {
if (!preg_match('/^\s*' . preg_quote($varName, '/') . '\s*=\s*\((.*?)^\s*\)/ms', $conf, $m)) {
return [];
}
$results = [];
foreach (explode("\n", $m[1]) as $line) {
if (!preg_match('/^\s*(#\s*)?"([^"]+)"/', $line, $e)) continue;
$commented = trim($e[1]) !== '';
$parts = preg_split('/\s+/', trim($e[2]));
$path = $parts[0] ?? '';
if (substr($path, -3) !== '.sh') continue;
$results[] = ['path' => $path, 'enabled' => !$commented];
}
return $results;
}
// Build a map of script rel-path → conf status by scanning all *_SCRIPTS arrays in master.conf.
// Cached per-request so multiple callers only read the file once.
function vv_conf_script_map(): array {
static $cache = null;
if ($cache !== null) return $cache;
$confPath = CONF_DIR . '/master.conf';
if (!file_exists($confPath)) return $cache = [];
$lines = file($confPath, FILE_IGNORE_NEW_LINES) ?: [];
$map = [];
$inArray = false;
$arrayVar = '';
foreach ($lines as $line) {
if (preg_match('/^\s*([A-Z_]+_SCRIPTS)\s*=\s*\(/', $line, $am)) { $inArray = true; $arrayVar = $am[1]; }
if ($inArray && preg_match('/^\s*\)\s*(?:#.*)?$/', $line) && !str_contains($line, '(')) $inArray = false;
if (!$inArray) continue;
if (!preg_match('/^\s*(#\s*)?"([^"]+)"/', $line, $e)) continue;
$commented = trim($e[1]) !== '';
$parts = preg_split('/\s+/', trim($e[2]));
$path = $parts[0] ?? '';
if (substr($path, -3) !== '.sh') continue;
if (!isset($map[$path])) $map[$path] = ['array' => $arrayVar, 'enabled' => !$commented, 'managed' => true];
}
return $cache = $map;
}
// Read a boolean flag value (e.g. INTERMEDIATE_RSYNC_ENABLED) from master.conf.
function vv_conf_flag_value(string $name): bool {
$conf = file_get_contents(CONF_DIR . '/master.conf') ?: '';
if (preg_match('/^\s*' . preg_quote($name, '/') . '\s*=\s*(true|false)\s*$/m', $conf, $m)) {
return $m[1] === 'true';
}
return false;
}
// Write a boolean flag value to master.conf.
function vv_conf_flag_set(string $name, bool $value): bool {
$confPath = CONF_DIR . '/master.conf';
$content = file_get_contents($confPath);
if ($content === false) return false;
$val = $value ? 'true' : 'false';
$new = preg_replace(
'/^(\s*' . preg_quote($name, '/') . '\s*=\s*)(true|false)(\s*(?:#.*)?)$/m',
'${1}' . $val . '${3}',
$content, -1, $count
);
if (!$count) return false;
return file_put_contents($confPath, $new) !== false;
}
// Comment or uncomment a script's line in the first master.conf array that contains it.
function vv_conf_toggle_script(string $rel, bool $enable): bool {
$confPath = CONF_DIR . '/master.conf';
$lines = file($confPath, FILE_KEEP_BLANK_LINES);
if (!$lines) return false;
$changed = false;
$inArray = false;
$relEsc = preg_quote($rel, '/');
foreach ($lines as &$line) {
if (preg_match('/^\s*[A-Z_]+_SCRIPTS\s*=\s*\(/', $line)) $inArray = true;
if ($inArray && preg_match('/^\s*\)\s*(?:#.*)?$/', $line) && !str_contains($line, '(')) $inArray = false;
if (!$inArray) continue;
if (!preg_match('/^\s*(?:#\s*)?"' . $relEsc . '(?:\s[^"]*)?"/', $line)) continue;
$isCommented = (bool)preg_match('/^\s*#/', $line);
if ($enable && $isCommented) {
$line = preg_replace('/^(\s*)#\s*("' . $relEsc . ')/', '$1$2', $line);
$changed = true;
} elseif (!$enable && !$isCommented) {
$line = preg_replace('/^(\s*)("' . $relEsc . ')/', '$1# $2', $line);
$changed = true;
}
break;
}
unset($line);
if (!$changed) return true;
return file_put_contents($confPath, implode('', $lines)) !== false;
}
// Parse an orchestrator script to find which child scripts it calls. // Parse an orchestrator script to find which child scripts it calls.
// Two strategies, merged and deduped: // Two strategies, merged and deduped:
// 1. Static paths: $SCRIPT_DIR/../Category/script.sh or $SCRIPTS_ROOT/Category/script.sh // 1. Static paths: $SCRIPT_DIR/../Category/script.sh or $SCRIPTS_ROOT/Category/script.sh
@@ -250,19 +387,32 @@ function vv_script_children(string $orchPath, array $schedule): array {
$content = file_get_contents($orchPath) ?: ''; $content = file_get_contents($orchPath) ?: '';
$children = []; $children = [];
$seen = []; $seen = [];
$confMap = vv_conf_script_map();
$addChild = function(string $rel) use ($scriptsDir, $schedule, &$children, &$seen) { // Detect which tier rsync flag this orch controls (e.g. "INTERMEDIATE" → INTERMEDIATE_RSYNC_ENABLED)
$rsyncFlagName = null;
if (preg_match('/check_rsync_enabled\s+"([A-Z]+)"/', $content, $rm)) {
$rsyncFlagName = $rm[1] . '_RSYNC_ENABLED';
}
$addChild = function(string $rel) use ($scriptsDir, $schedule, $confMap, &$children, &$seen) {
if (isset($seen[$rel]) || !file_exists("$scriptsDir/$rel")) return; if (isset($seen[$rel]) || !file_exists("$scriptsDir/$rel")) return;
$seen[$rel] = true; $seen[$rel] = true;
$entry = $schedule[$rel] ?? ['enabled' => false, 'cron' => '']; $entry = $schedule[$rel] ?? ['enabled' => false, 'cron' => ''];
$children[] = [ $conf = $confMap[$rel] ?? ['array' => null, 'enabled' => null, 'managed' => false];
'id' => $rel, $suggested = vv_script_suggested_cron("$scriptsDir/$rel");
'label' => basename($rel, '.sh'), $children[] = [
'desc' => vv_script_description("$scriptsDir/$rel"), 'id' => $rel,
'type' => 'script', 'label' => basename($rel, '.sh'),
'enabled' => (bool)($entry['enabled'] ?? false), 'desc' => vv_script_description("$scriptsDir/$rel"),
'cron' => $entry['cron'] ?? '', 'type' => 'script',
'log_enabled' => (bool)($entry['log_enabled'] ?? false), 'enabled' => (bool)($entry['enabled'] ?? false),
'cron' => $entry['cron'] ?? '',
'log_enabled' => (bool)($entry['log_enabled'] ?? false),
'conf_managed' => $conf['managed'],
'conf_enabled' => $conf['enabled'], // null if not in any *_SCRIPTS array
'suggested_cron' => $suggested['cron'],
'suggested_label' => $suggested['label'],
]; ];
}; };
@@ -273,14 +423,27 @@ function vv_script_children(string $orchPath, array $schedule): array {
); );
foreach ($m[1] as $rel) $addChild($rel); foreach ($m[1] as $rel) $addChild($rel);
// Strategy 2: master.conf arrays — find every ${VARNAME_SCRIPTS[@]} the orch iterates // Strategy 2: master.conf arrays — includes commented (disabled) entries so they appear in the UI
preg_match_all('/\$\{([A-Z_]+_SCRIPTS)\[@\]\}/', $content, $refs); preg_match_all('/\$\{([A-Z_]+_SCRIPTS)\[@\]\}/', $content, $refs);
if (!empty($refs[1])) { if (!empty($refs[1])) {
$confRaw = file_get_contents(CONF_DIR . '/master.conf') ?: ''; $confRaw = file_get_contents(CONF_DIR . '/master.conf') ?: '';
foreach (array_unique($refs[1]) as $varName) { foreach (array_unique($refs[1]) as $varName) {
foreach (vv_parse_conf_array($confRaw, $varName) as $rel) $addChild($rel); foreach (vv_parse_conf_array_full($confRaw, $varName) as $item) $addChild($item['path']);
} }
} }
// Annotate Rsync/rsync.sh as a conf_flag child if this orch controls a rsync tier flag
if ($rsyncFlagName) {
foreach ($children as &$c) {
if ($c['id'] === 'Rsync/rsync.sh') {
$c['type'] = 'conf_flag';
$c['flag_name'] = $rsyncFlagName;
$c['flag_value'] = vv_conf_flag_value($rsyncFlagName);
break;
}
}
unset($c);
}
return $children; return $children;
} }
@@ -15,10 +15,6 @@ foreach ($tree as $orch) {
?> ?>
<div id="vv-scheduler"> <div id="vv-scheduler">
<p class="vv-hint">Toggle saves immediately. Fill in cron expressions and hit
<strong>Save Schedule</strong> at the bottom. <strong>Run</strong> fires now;
<strong>Dry Run</strong> sets DRY_RUN=1. Log opens on the right.</p>
<div id="vv-sched-layout"> <div id="vv-sched-layout">
<!-- ── Left: script cards ── --> <!-- ── Left: script cards ── -->
@@ -28,16 +24,29 @@ foreach ($tree as $orch) {
<?php foreach ($tree as $orch): $oid = htmlspecialchars($orch['id']); ?> <?php foreach ($tree as $orch): $oid = htmlspecialchars($orch['id']); ?>
<div class="vv-card vv-wide vv-sched-card" data-id="<?= $oid ?>"> <div class="vv-card vv-wide vv-sched-card" data-id="<?= $oid ?>">
<div class="vv-job-row"> <div class="vv-job-row vv-orch-row">
<label class="vv-toggle" title="Enable/disable"> <label class="vv-toggle" title="Enable/disable">
<input type="checkbox" class="vv-enabled" <input type="checkbox" class="vv-enabled"
<?= $orch['enabled'] ? 'checked' : '' ?> <?= $orch['enabled'] ? 'checked' : '' ?>
onchange="vvSaveJob(this)"> onchange="vvSaveOrch(this)">
<span class="vv-slider"></span> <span class="vv-slider"></span>
</label> </label>
<span class="vv-job-label"><?= htmlspecialchars($orch['label']) ?></span> <span class="vv-job-label"><?= htmlspecialchars($orch['label']) ?></span>
<input type="text" class="vv-cron" value="<?= htmlspecialchars($orch['cron']) ?>" <?php if ($orch['type'] === 'event'): ?>
placeholder="cron expression"> <span class="vv-event-badge"><?= $orch['cron'] === '@array_start' ? '⚡ Array Start' : '⚡ Array Stop' ?></span>
<input type="hidden" class="vv-cron" value="<?= htmlspecialchars($orch['cron']) ?>">
<?php else: ?>
<?php if ($orch['suggested_cron']): ?>
<span class="vv-cron-hint"
data-cron="<?= htmlspecialchars($orch['suggested_cron']) ?>"
onclick="this.nextElementSibling.value=this.dataset.cron"
title="Suggested: <?= htmlspecialchars($orch['suggested_cron'] . ($orch['suggested_label'] ? ' — ' . $orch['suggested_label'] : '')) ?>">
<?= htmlspecialchars($orch['suggested_cron']) ?>
</span>
<?php endif; ?>
<input type="text" class="vv-cron" value="<?= htmlspecialchars($orch['cron']) ?>"
placeholder="cron expression">
<?php endif; ?>
<span class="vv-save-check"></span> <span class="vv-save-check"></span>
</div> </div>
<?php if (!empty($orch['desc'])): ?> <?php if (!empty($orch['desc'])): ?>
@@ -66,17 +75,55 @@ foreach ($tree as $orch) {
<?php if (!empty($orch['children'])): ?> <?php if (!empty($orch['children'])): ?>
<div class="vv-children" style="display:none;"> <div class="vv-children" style="display:none;">
<?php foreach ($orch['children'] as $child): $cid = htmlspecialchars($child['id']); ?> <?php foreach ($orch['children'] as $child): $cid = htmlspecialchars($child['id']); ?>
<div class="vv-script" data-id="<?= $cid ?>"> <?php if (($child['type'] ?? 'script') === 'conf_flag'): ?>
<div class="vv-script" data-id="<?= $cid ?>"
data-type="conf_flag"
data-flag-name="<?= htmlspecialchars($child['flag_name']) ?>"
data-flag-value="<?= !empty($child['flag_value']) ? '1' : '0' ?>">
<div class="vv-job-row">
<label class="vv-toggle" title="Toggle <?= htmlspecialchars($child['flag_name']) ?> in master.conf">
<input type="checkbox" class="vv-enabled"
<?= !empty($child['flag_value']) ? 'checked' : '' ?>
onchange="vvSaveChild(this)">
<span class="vv-slider"></span>
</label>
<span class="vv-job-label"><?= htmlspecialchars($child['label']) ?></span>
<span class="vv-flag-badge"><?= htmlspecialchars($child['flag_name']) ?></span>
<span class="vv-save-check"></span>
</div>
<?php if (!empty($child['desc'])): ?>
<div class="vv-job-desc" title="<?= htmlspecialchars($child['desc']) ?>"><?= htmlspecialchars($child['desc']) ?></div>
<?php endif; ?>
<div class="vv-job-actions">
<button class="vv-btn-sm vv-run-btn" onclick="vvRunJob(this)">&#9654; Run</button>
<button class="vv-btn-sm vv-dry-btn" onclick="vvDryRun(this)">&#9654; Dry Run</button>
<?php $childLog = vv_job_log_path($child['id']); ?>
<button class="vv-btn-sm vv-log-btn<?= (file_exists($childLog) && filesize($childLog) > 0) ? ' vv-has-log' : '' ?>" onclick="vvSelectLog(this)">Log</button>
<span class="vv-job-dot"></span>
</div>
</div>
<?php else: ?>
<div class="vv-script" data-id="<?= $cid ?>"
data-conf-managed="<?= $child['conf_managed'] ? '1' : '0' ?>"
data-conf-enabled="<?= $child['conf_enabled'] === true ? '1' : ($child['conf_enabled'] === false ? '0' : '') ?>">
<div class="vv-job-row"> <div class="vv-job-row">
<label class="vv-toggle" title="Enable/disable"> <label class="vv-toggle" title="Enable/disable">
<input type="checkbox" class="vv-enabled" <input type="checkbox" class="vv-enabled"
<?= $child['enabled'] ? 'checked' : '' ?> <?= $child['enabled'] ? 'checked' : '' ?>
onchange="vvSaveJob(this)"> onchange="vvSaveChild(this)">
<span class="vv-slider"></span> <span class="vv-slider"></span>
</label> </label>
<span class="vv-job-label"><?= htmlspecialchars($child['label']) ?></span> <span class="vv-job-label"><?= htmlspecialchars($child['label']) ?></span>
<?php if (!empty($child['suggested_cron'])): ?>
<span class="vv-cron-hint"
data-cron="<?= htmlspecialchars($child['suggested_cron']) ?>"
onclick="this.nextElementSibling.value=this.dataset.cron"
title="Suggested: <?= htmlspecialchars($child['suggested_cron'] . (!empty($child['suggested_label']) ? ' — ' . $child['suggested_label'] : '')) ?>">
<?= htmlspecialchars($child['suggested_cron']) ?>
</span>
<?php endif; ?>
<input type="text" class="vv-cron" value="<?= htmlspecialchars($child['cron']) ?>" <input type="text" class="vv-cron" value="<?= htmlspecialchars($child['cron']) ?>"
placeholder="cron expression"> placeholder="cron (orch off only)">
<span class="vv-save-check"></span> <span class="vv-save-check"></span>
</div> </div>
<?php if (!empty($child['desc'])): ?> <?php if (!empty($child['desc'])): ?>
@@ -99,6 +146,7 @@ foreach ($tree as $orch) {
<span class="vv-job-dot"></span> <span class="vv-job-dot"></span>
</div> </div>
</div> </div>
<?php endif; ?>
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
<?php endif; ?> <?php endif; ?>
@@ -173,28 +221,85 @@ foreach ($tree as $orch) {
<div style="display:flex;align-items:center;gap:8px;"> <div style="display:flex;align-items:center;gap:8px;">
<button id="vv-back-btn" class="vv-btn-sm" onclick="vvBackToSuggestions()" style="display:none">&#9776; Scheduler Info</button> <button id="vv-back-btn" class="vv-btn-sm" onclick="vvBackToSuggestions()" style="display:none">&#9776; Scheduler Info</button>
<button id="vv-restore-btn" class="vv-btn-sm" onclick="vvRestoreLastLog()" style="display:none">&#8592; <span id="vv-restore-label"></span></button> <button id="vv-restore-btn" class="vv-btn-sm" onclick="vvRestoreLastLog()" style="display:none">&#8592; <span id="vv-restore-label"></span></button>
<span id="vv-log-title" style="font-size:13px;font-weight:bold;color:#ccc;">Suggested Schedules</span> <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> <span id="vv-log-dot" style="display:none;width:8px;height:8px;border-radius:50%;background:#4caf50;flex-shrink:0;"></span>
</div> </div>
<div style="display:flex;align-items:center;gap:12px;"> <div style="display:flex;align-items:center;gap:12px;">
<label id="vv-auto-scroll-label" class="vv-log-label" title="Auto-scroll to newest line" style="display:none"> <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> <input type="checkbox" id="vv-auto-scroll" checked> <span>Auto Scroll</span>
</label> </label>
<label id="vv-scroll-lock-label" class="vv-log-label" title="Lock viewport — cannot scroll while active" style="display:none">
<input type="checkbox" id="vv-scroll-lock" onchange="vvToggleScrollLock(this)"> <span>Scroll Lock</span>
</label>
<label id="vv-invert-log-label" class="vv-log-label" title="Show newest lines at top" style="display:none"> <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> <input type="checkbox" id="vv-invert-log" onchange="vvToggleInvert(this)"> <span>Invert</span>
</label> </label>
<button id="vv-save-script-btn" class="vv-btn-sm vv-save-script-btn-style" onclick="vvSaveScript()" style="display:none">Save Script</button> <button id="vv-save-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-conf-btn" class="vv-btn-sm vv-save-script-btn-style" onclick="vvSaveConf()" style="display:none">Save Config</button>
<span id="vv-log-ts" class="vv-log-ts"></span> <span id="vv-log-ts" class="vv-log-ts"></span>
<button id="vv-stop-btn" class="vv-btn-sm" onclick="vvStopJob()" style="display:none" title="Stop running script and clear any stuck locks">■ Stop</button>
<button id="vv-clear-btn" class="vv-btn-sm" onclick="vvClearRightLog()" style="display:none">Clear</button> <button id="vv-clear-btn" class="vv-btn-sm" onclick="vvClearRightLog()" style="display:none">Clear</button>
</div> </div>
</div> </div>
<!-- Suggestions view (default) --> <!-- Suggestions view (default) -->
<div id="vv-suggestions"> <div id="vv-suggestions">
<!-- ── Static info: Controls ── -->
<div class="vv-sug-block vv-info-block">
<div class="vv-sug-header" onclick="vvToggleSug(this)">
<span class="vv-sug-chevron">▾</span>
<span class="vv-sug-title">Controls</span>
</div>
<div class="vv-sug-body vv-info-body">
<ul class="vv-info-cols">
<li><strong>Toggle</strong> — saves to schedule.json immediately</li>
<li><strong>Cron</strong> — 5-field expression; hit <strong>Save Schedule</strong> to apply all at once</li>
<li><strong>Run</strong> — fires script immediately regardless of schedule</li>
<li><strong>Dry Run</strong> — same as Run with <code>DRY_RUN=1</code></li>
<li><strong>Log</strong> — opens script log in this panel; auto-scrolls to latest</li>
<li><strong>Stop</strong> — sends SIGTERM to process group → 3 s → SIGKILL; clears stuck locks</li>
<li><strong>Verbose</strong> — appends <code>--log</code> for detailed per-step output</li>
<li><strong>Config</strong> — edit host-scoped settings for scripts that support it</li>
</ul>
</div>
</div>
<!-- ── Static info: Orchestrators ── -->
<div class="vv-sug-block vv-info-block">
<div class="vv-sug-header" onclick="vvToggleSug(this)">
<span class="vv-sug-chevron">▾</span>
<span class="vv-sug-title">Orchestrators</span>
</div>
<div class="vv-sug-body vv-info-body">
<ul class="vv-info-cols">
<li><strong>Enabled (ON)</strong> — orchestrator is the sole trigger; its cron fires it and it calls all children in order</li>
<li><strong>Disabled (OFF)</strong> — orchestrator never runs; all children switch off and crons are suppressed</li>
<li><strong>Child toggles (orch ON)</strong> — comment or uncomment the script's line in master.conf <code>*_SCRIPTS</code> array</li>
<li><strong>Child cron (orch ON)</strong> — ignored; orch is the only trigger while it is enabled</li>
<li><strong>Child cron (orch OFF)</strong> — enable a child and give it a cron to run it on its own schedule, independent of the orch</li>
<li><strong>⚡ Array Start / Stop</strong> — triggered by Unraid array events; no cron field; toggle enables or disables the handler</li>
</ul>
</div>
</div>
<!-- ── Static info: Children & Advanced ── -->
<div class="vv-sug-block vv-info-block">
<div class="vv-sug-header" onclick="vvToggleSug(this)">
<span class="vv-sug-chevron">▾</span>
<span class="vv-sug-title">Children &amp; Advanced</span>
</div>
<div class="vv-sug-body vv-info-body">
<ul class="vv-info-cols">
<li><strong>Advanced ▸</strong> — expand to see child scripts managed by the orchestrator</li>
<li><strong>Toggle (orch ON)</strong> — comments or uncomments the script entry in master.conf; determines whether the orch calls it</li>
<li><strong>Toggle (orch OFF)</strong> — has no effect without a cron; pair with a cron expression to run standalone</li>
<li><strong>Cron (orch OFF)</strong> — child runs on its own schedule; completely independent of the parent orchestrator</li>
<li><strong>Rsync child</strong> — shows amber <code>TIER_RSYNC_ENABLED</code> badge; toggle writes <code>true</code> / <code>false</code> directly to master.conf — controls whether rsync runs inside that orchestrator</li>
<li><strong>Rsync (orch OFF)</strong> — toggle is disabled; rsync flag only matters when the orchestrator is running</li>
</ul>
</div>
</div>
<div class="vv-info-divider">Suggested Schedules</div>
<?php foreach ($blocks as $bi => $block): <?php foreach ($blocks as $bi => $block):
$scripts = $block['scripts']; $scripts = $block['scripts'];
$schedule = $block['schedule']; $schedule = $block['schedule'];
@@ -365,8 +470,12 @@ function vvShowLogMode(id) {
document.getElementById('vv-delete-script-btn').style.display = 'none'; document.getElementById('vv-delete-script-btn').style.display = 'none';
document.getElementById('vv-confform').style.display = 'none'; document.getElementById('vv-confform').style.display = 'none';
document.getElementById('vv-auto-scroll-label').style.display = ''; document.getElementById('vv-auto-scroll-label').style.display = '';
document.getElementById('vv-scroll-lock-label').style.display = '';
document.getElementById('vv-invert-log-label').style.display = ''; document.getElementById('vv-invert-log-label').style.display = '';
document.getElementById('vv-stop-btn').style.display = '';
document.getElementById('vv-auto-scroll').checked = true;
const _pre = document.getElementById('vv-log-pre');
_pre.removeEventListener('scroll', vvOnLogScroll);
_pre.addEventListener('scroll', vvOnLogScroll);
document.getElementById('vv-save-schedule-btn').disabled = false; document.getElementById('vv-save-schedule-btn').disabled = false;
document.getElementById('vv-save-schedule-btn').style.opacity = ''; document.getElementById('vv-save-schedule-btn').style.opacity = '';
const name = id.replace(/\.sh$/, '').split('/').pop(); const name = id.replace(/\.sh$/, '').split('/').pop();
@@ -379,17 +488,11 @@ function vvRestoreLastLog() {
if (last && document.querySelector('[data-id="' + CSS.escape(last) + '"]')) vvOpenRight(last); if (last && document.querySelector('[data-id="' + CSS.escape(last) + '"]')) vvOpenRight(last);
} }
function vvToggleScrollLock(cb) { function vvOnLogScroll() {
document.getElementById('vv-log-pre').style.overflowY = cb.checked ? 'hidden' : ''; const pre = document.getElementById('vv-log-pre');
localStorage.setItem('vv-scroll-lock', cb.checked ? '1' : '0'); const atBottom = pre.scrollHeight - pre.scrollTop - pre.clientHeight < 20;
} const cb = document.getElementById('vv-auto-scroll');
if (cb) cb.checked = atBottom;
function vvRestoreScrollLock() {
const val = localStorage.getItem('vv-scroll-lock');
const on = val === null ? true : val === '1';
const cb = document.getElementById('vv-scroll-lock');
cb.checked = on;
document.getElementById('vv-log-pre').style.overflowY = on ? 'hidden' : '';
} }
function vvToggleInvert(cb) { function vvToggleInvert(cb) {
@@ -429,11 +532,13 @@ function vvBackToSuggestions() {
document.getElementById('vv-restore-btn').style.display = ''; document.getElementById('vv-restore-btn').style.display = '';
} }
document.getElementById('vv-auto-scroll-label').style.display = 'none'; document.getElementById('vv-auto-scroll-label').style.display = 'none';
document.getElementById('vv-scroll-lock-label').style.display = 'none';
document.getElementById('vv-invert-log-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; document.getElementById('vv-auto-scroll').checked = true;
vvRestoreScrollLock(); const _preBack = document.getElementById('vv-log-pre');
document.getElementById('vv-log-title').textContent = 'Suggested Schedules'; _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-ts').textContent = '';
document.getElementById('vv-log-dot').style.display = 'none'; document.getElementById('vv-log-dot').style.display = 'none';
requestAnimationFrame(vvFitRight); requestAnimationFrame(vvFitRight);
@@ -450,6 +555,7 @@ function vvOpenRight(id) {
if (job) job.querySelector('.vv-job-row').classList.add('vv-row-selected'); if (job) job.querySelector('.vv-job-row').classList.add('vv-row-selected');
vvShowLogMode(id); vvShowLogMode(id);
vvSetStopBtn(vvRunningSet.has(id));
requestAnimationFrame(vvFitRight); requestAnimationFrame(vvFitRight);
vvFetchRight(); vvFetchRight();
@@ -464,16 +570,13 @@ function vvFetchRight() {
const pre = document.getElementById('vv-log-pre'); const pre = document.getElementById('vv-log-pre');
const ts = document.getElementById('vv-log-ts'); const ts = document.getElementById('vv-log-ts');
const autoScroll = document.getElementById('vv-auto-scroll').checked; const autoScroll = document.getElementById('vv-auto-scroll').checked;
const scrollLock = document.getElementById('vv-scroll-lock').checked;
const invert = document.getElementById('vv-invert-log').checked; const invert = document.getElementById('vv-invert-log').checked;
const savedScroll = pre.scrollTop; const savedScroll = pre.scrollTop;
if (!d.ok) { pre.textContent = '✗ ' + (d.error ?? 'Error'); return; } if (!d.ok) { pre.textContent = '✗ ' + (d.error ?? 'Error'); return; }
const content = d.content || ''; const content = d.content || '';
vvSetLogBtnState(vvActiveId, content.trim().length > 0); vvSetLogBtnState(vvActiveId, content.trim().length > 0);
pre.textContent = content.trim() ? (invert ? content.split('\n').reverse().join('\n') : content) : '(no log yet)'; pre.textContent = content.trim() ? (invert ? content.split('\n').reverse().join('\n') : content) : '(no log yet)';
if (scrollLock) { if (autoScroll) {
requestAnimationFrame(() => { pre.scrollTop = savedScroll; });
} else if (autoScroll) {
pre.scrollTop = invert ? 0 : pre.scrollHeight; pre.scrollTop = invert ? 0 : pre.scrollHeight;
} else { } else {
requestAnimationFrame(() => { pre.scrollTop = savedScroll; }); requestAnimationFrame(() => { pre.scrollTop = savedScroll; });
@@ -483,16 +586,51 @@ function vvFetchRight() {
.catch(() => {}); .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) { function vvSetDot(id) {
const job = document.querySelector('[data-id="' + CSS.escape(id) + '"]'); const job = document.querySelector('[data-id="' + CSS.escape(id) + '"]');
if (job) job.querySelector('.vv-job-dot').classList.add('vv-dot-running'); if (job) job.querySelector('.vv-job-dot').classList.add('vv-dot-running');
if (id === vvActiveId) document.getElementById('vv-log-dot').style.display = 'inline-block'; if (id === vvActiveId) {
document.getElementById('vv-log-dot').style.display = 'inline-block';
vvSetStopBtn(true);
}
} }
function vvClearDot(id) { function vvClearDot(id) {
const job = document.querySelector('[data-id="' + CSS.escape(id) + '"]'); const job = document.querySelector('[data-id="' + CSS.escape(id) + '"]');
if (job) job.querySelector('.vv-job-dot').classList.remove('vv-dot-running'); if (job) job.querySelector('.vv-job-dot').classList.remove('vv-dot-running');
if (id === vvActiveId) document.getElementById('vv-log-dot').style.display = 'none'; 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() { function vvPollStatus() {
@@ -572,26 +710,114 @@ function vvFlashStatus(el, msg, ok) {
el._t = setTimeout(() => { el.textContent = ''; }, 3000); el._t = setTimeout(() => { el.textContent = ''; }, 3000);
} }
// Generic save — used for log_enabled toggles and custom scripts.
function vvSaveJob(el) { function vvSaveJob(el) {
const job = el.closest('[data-id]'); const job = el.closest('[data-id]');
const id = job.dataset.id; const id = job.dataset.id;
const enabled = job.querySelector('.vv-enabled').checked ? '1' : '0'; const enabled = job.querySelector('.vv-enabled').checked ? '1' : '0';
const cron = job.querySelector('.vv-cron').value.trim(); const cron = job.querySelector('.vv-cron')?.value.trim() ?? '';
const log_enabled = job.querySelector('.vv-log-enabled')?.checked ? '1' : '0'; const log_enabled = job.querySelector('.vv-log-enabled')?.checked ? '1' : '0';
vvPost('/plugins/varaverk/api/scheduler.php', {id, enabled, cron, log_enabled}) vvPost('/plugins/varaverk/api/scheduler.php', {id, enabled, cron, log_enabled})
.then(d => { if (d.ok) vvFlashSaved(job); }); .then(d => { if (d.ok) vvFlashSaved(job); });
} }
// Orch toggle: saves orch state and propagates to children.
function vvSaveOrch(el) {
const card = el.closest('[data-id]');
const id = card.dataset.id;
const enabled = el.checked;
const cron = card.querySelector('.vv-orch-row .vv-cron')?.value.trim() ?? '';
const log_e = card.querySelector('.vv-log-enabled')?.checked ? '1' : '0';
vvPost('/plugins/varaverk/api/scheduler.php', {id, enabled: enabled ? '1' : '0', cron, log_enabled: log_e})
.then(d => { if (d.ok) vvFlashSaved(card); });
vvApplyOrchState(card, enabled);
}
// Apply visual + interactive state to children based on whether orch is on or off.
function vvApplyOrchState(orchCard, orchEnabled) {
orchCard.querySelectorAll('.vv-script').forEach(child => {
const toggle = child.querySelector('.vv-enabled');
const cronInput = child.querySelector('input.vv-cron');
// conf_flag children (e.g. Rsync): toggle reflects real flag value, enabled only when orch is on
if (child.dataset.type === 'conf_flag') {
toggle.checked = child.dataset.flagValue === '1';
toggle.disabled = !orchEnabled;
if (cronInput) { cronInput.disabled = true; cronInput.style.opacity = '0.35'; }
return;
}
const confManaged = child.dataset.confManaged === '1';
const confEnabled = child.dataset.confEnabled === '1';
if (orchEnabled) {
// Orch is god: set toggle from master.conf state; dim independent cron
toggle.checked = confManaged ? confEnabled : false;
toggle.disabled = !confManaged;
if (cronInput) { cronInput.disabled = true; cronInput.style.opacity = '0.35'; }
} else {
// Orch off: all children switch off; cron field becomes active
toggle.checked = false;
toggle.disabled = false;
if (cronInput) { cronInput.disabled = false; cronInput.style.opacity = ''; }
// Persist the off state to schedule.json so cron rebuild reflects it
const cid = child.dataset.id;
const ccron = cronInput?.value.trim() ?? '';
const clog = child.querySelector('.vv-log-enabled')?.checked ? '1' : '0';
vvPost('/plugins/varaverk/api/scheduler.php', {id: cid, enabled: '0', cron: ccron, log_enabled: clog});
}
});
}
// Child toggle: conf_toggle when orch is on; scheduler save when orch is off.
// conf_flag children always write directly to master.conf regardless of orch state.
function vvSaveChild(el) {
const child = el.closest('[data-id]');
const orchCard = child.closest('.vv-sched-card');
const orchOn = orchCard?.querySelector('.vv-orch-row .vv-enabled')?.checked ?? false;
const id = child.dataset.id;
const enabled = el.checked;
if (child.dataset.type === 'conf_flag') {
const name = child.dataset.flagName;
child.dataset.flagValue = enabled ? '1' : '0';
vvPost('/plugins/varaverk/api/flag_toggle.php', {name, enabled: enabled ? '1' : '0'})
.then(d => { if (d.ok) vvFlashSaved(child); });
return;
}
if (orchOn) {
vvPost('/plugins/varaverk/api/conf_toggle.php', {id, enabled: enabled ? '1' : '0'})
.then(d => { if (d.ok) vvFlashSaved(child); });
} else {
const cron = child.querySelector('input.vv-cron')?.value.trim() ?? '';
const log_e = child.querySelector('.vv-log-enabled')?.checked ? '1' : '0';
vvPost('/plugins/varaverk/api/scheduler.php', {id, enabled: enabled ? '1' : '0', cron, log_enabled: log_e})
.then(d => { if (d.ok) vvFlashSaved(child); });
}
}
function vvSaveAll() { function vvSaveAll() {
const status = document.getElementById('vv-save-all-status'); const status = document.getElementById('vv-save-all-status');
const jobs = document.querySelectorAll('#vv-sched-left [data-id]'); // Collect jobs to save: orchs always; children only when their orch is OFF (orch manages them when on).
let pending = jobs.length, allOk = true; // conf_flag children are always immediate-save (flag_toggle.php), never batch-saved.
if (!pending) { vvFlashStatus(status, '✗ No jobs found', false); return; } const toSave = [];
document.querySelectorAll('#vv-sched-left [data-id]').forEach(job => {
if (job.classList.contains('vv-script')) {
if (job.dataset.type === 'conf_flag') return; // immediate-save only
const orchCard = job.closest('.vv-sched-card');
const orchOn = orchCard?.querySelector('.vv-orch-row .vv-enabled')?.checked ?? false;
if (orchOn) return; // child under active orch — cron suppressed, skip
}
toSave.push(job);
});
let pending = toSave.length, allOk = true;
if (!pending) { vvFlashStatus(status, '✓ Saved', true); return; }
status.textContent = 'Saving…'; status.textContent = 'Saving…';
jobs.forEach(job => { toSave.forEach(job => {
const id = job.dataset.id; const id = job.dataset.id;
const enabled = job.querySelector('.vv-enabled').checked ? '1' : '0'; const enabled = job.querySelector('.vv-enabled').checked ? '1' : '0';
const cron = job.querySelector('.vv-cron').value.trim(); const cron = job.querySelector('.vv-cron')?.value.trim() ?? '';
const log_enabled = job.querySelector('.vv-log-enabled')?.checked ? '1' : '0'; const log_enabled = job.querySelector('.vv-log-enabled')?.checked ? '1' : '0';
vvPost('/plugins/varaverk/api/scheduler.php', {id, enabled, cron, log_enabled}) vvPost('/plugins/varaverk/api/scheduler.php', {id, enabled, cron, log_enabled})
.then(d => { .then(d => {
@@ -671,8 +897,8 @@ function vvShowEditorMode(title) {
document.getElementById('vv-restore-btn').style.display = 'none'; document.getElementById('vv-restore-btn').style.display = 'none';
document.getElementById('vv-save-script-btn').style.display = ''; document.getElementById('vv-save-script-btn').style.display = '';
document.getElementById('vv-clear-btn').style.display = 'none'; document.getElementById('vv-clear-btn').style.display = 'none';
document.getElementById('vv-stop-btn').style.display = 'none';
document.getElementById('vv-auto-scroll-label').style.display = 'none'; document.getElementById('vv-auto-scroll-label').style.display = 'none';
document.getElementById('vv-scroll-lock-label').style.display = 'none';
document.getElementById('vv-invert-log-label').style.display = 'none'; document.getElementById('vv-invert-log-label').style.display = 'none';
document.getElementById('vv-log-title').textContent = title; document.getElementById('vv-log-title').textContent = title;
document.getElementById('vv-log-ts').textContent = ''; document.getElementById('vv-log-ts').textContent = '';
@@ -731,8 +957,8 @@ function vvShowConfMode(title) {
document.getElementById('vv-save-conf-btn').style.display = ''; document.getElementById('vv-save-conf-btn').style.display = '';
document.getElementById('vv-delete-script-btn').style.display = 'none'; document.getElementById('vv-delete-script-btn').style.display = 'none';
document.getElementById('vv-clear-btn').style.display = 'none'; document.getElementById('vv-clear-btn').style.display = 'none';
document.getElementById('vv-stop-btn').style.display = 'none';
document.getElementById('vv-auto-scroll-label').style.display = 'none'; document.getElementById('vv-auto-scroll-label').style.display = 'none';
document.getElementById('vv-scroll-lock-label').style.display = 'none';
document.getElementById('vv-invert-log-label').style.display = 'none'; document.getElementById('vv-invert-log-label').style.display = 'none';
document.getElementById('vv-log-title').textContent = title; document.getElementById('vv-log-title').textContent = title;
document.getElementById('vv-log-ts').textContent = ''; document.getElementById('vv-log-ts').textContent = '';
@@ -821,23 +1047,32 @@ function vvToggleSugBlock(bi) {
body.style.display = open ? 'none' : 'block'; body.style.display = open ? 'none' : 'block';
chevron.textContent = open ? '▸' : '▾'; chevron.textContent = open ? '▸' : '▾';
} }
function vvToggleSug(header) {
const body = header.nextElementSibling;
const chevron = header.querySelector('.vv-sug-chevron');
const open = body.style.display !== 'none';
body.style.display = open ? 'none' : '';
chevron.textContent = open ? '▸' : '▾';
}
</script> </script>
<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() { requestAnimationFrame(function() {
vvRestoreScrollLock();
vvRestoreInvert(); vvRestoreInvert();
const last = localStorage.getItem('vv-last-job'); const last = localStorage.getItem('vv-last-job');
if (last && document.querySelector('[data-id="' + CSS.escape(last) + '"]')) { if (last) {
vvOpenRight(last); const lname = last.replace(/\.sh$/, '').split('/').pop();
} else { document.getElementById('vv-restore-label').textContent = lname;
if (last) { document.getElementById('vv-restore-btn').style.display = '';
const lname = last.replace(/\.sh$/, '').split('/').pop();
document.getElementById('vv-restore-label').textContent = lname;
document.getElementById('vv-restore-btn').style.display = '';
}
vvFitRight();
} }
vvFitRight();
vvStartStatusPoll(); vvStartStatusPoll();
}); });
</script> </script>
View File
+7 -8
View File
@@ -26,7 +26,7 @@
# sunday_morning_coffee_report.sh 7am Sunday — full weekly digest # sunday_morning_coffee_report.sh 7am Sunday — full weekly digest
# weekly_health_digest.sh 8am daily — profile-controlled health notification # weekly_health_digest.sh 8am daily — profile-controlled health notification
# system_tuning_monitor.sh every 6 hours — inotify + php-fpm trend tracking # system_tuning_monitor.sh every 6 hours — inotify + php-fpm trend tracking
# monthly_maintenance.sh 3am daily — uptime-gated monthly tasks (self-gates to monthly) # monthly_maintenance.sh 15th monthly — uptime-gated heavy tasks (ZFS scrub, SMART long test)
# #
# ── INDIVIDUAL SCRIPTS ──────────────────────────────────────────────────────────────────────── # ── INDIVIDUAL SCRIPTS ────────────────────────────────────────────────────────────────────────
# Every child script is also listed below, individually. # Every child script is also listed below, individually.
@@ -90,7 +90,7 @@
# before rsync_stop) — fallback runs as a bare subprocess, not via /tmp/user.scripts, # before rsync_stop) — fallback runs as a bare subprocess, not via /tmp/user.scripts,
# so it is not caught by user_scripts_stop.sh. # so it is not caught by user_scripts_stop.sh.
# v2.6 — monthly_maintenance.sh: uptime-gated orchestrator for heavy monthly tasks (ZFS scrub, # v2.6 — monthly_maintenance.sh: uptime-gated orchestrator for heavy monthly tasks (ZFS scrub,
# SMART long test). Schedule 0 3 * * * — script self-gates to monthly cadence. # SMART long test). Schedule 0 0 15 * * — 15th of each month at midnight.
# storage_watchdog.sh + network_watchdog.sh: pool growth + connectivity watchdogs, # storage_watchdog.sh + network_watchdog.sh: pool growth + connectivity watchdogs,
# called via SYSTEM_WATCHDOG_SCRIPTS by system_watchdog.sh (watchdog chain, no cron). # called via SYSTEM_WATCHDOG_SCRIPTS by system_watchdog.sh (watchdog chain, no cron).
# webgui_watchdog.sh moved from standalone cron to SYSTEM_WATCHDOG_SCRIPTS chain. # webgui_watchdog.sh moved from standalone cron to SYSTEM_WATCHDOG_SCRIPTS chain.
@@ -346,17 +346,16 @@
# ── MONTHLY MAINTENANCE ─────────────────────────────────────────────────────────────────────── # ── MONTHLY MAINTENANCE ───────────────────────────────────────────────────────────────────────
# Schedule: 0 3 * * * (daily 3am check — script self-gates to monthly cadence) # Schedule: 0 0 15 * * (15th of each month at midnight)
# Background: YES # Background: YES
# #
# Runs heavy tasks that require a long-stable, settled system. Fires only when BOTH gates pass: # Runs heavy tasks that require a long-stable, settled system. Fires only when BOTH gates pass:
# 1. Server uptime >= MONTHLY_UPTIME_THRESHOLD_DAYS (default: 30 days) # 1. Server uptime >= MONTHLY_UPTIME_THRESHOLD_DAYS (default: 30 days)
# 2. Last run was >= MONTHLY_RUN_INTERVAL_DAYS ago (default: 30 days) # 2. Last run was >= MONTHLY_RUN_INTERVAL_DAYS ago (default: 30 days)
# #
# Why uptime-gated not cron: a scheduled reboot resets uptime. Monthly tasks (ZFS scrub, # If either gate is not met on the 15th, the run is skipped until next month's 15th.
# SMART extended tests) need a system that has been healthy for a full month — not one that # A reboot between the 14th and 15th will defer maintenance — which is intentional;
# just rebooted. The daily 3am cron is just the trigger. The gates inside enforce the cadence. # heavy tasks (ZFS scrub, SMART long test) should not run on a freshly rebooted system.
# Silent exit 0 when either gate is not met — calling daily is always safe.
# #
# Configure via master.conf MONTHLY_MAINTENANCE_SCRIPTS. Scripts planned but not yet built: # Configure via master.conf MONTHLY_MAINTENANCE_SCRIPTS. Scripts planned but not yet built:
# zfs_pool_scrub.sh ZFS pool integrity scrub (Tools/) # zfs_pool_scrub.sh ZFS pool integrity scrub (Tools/)
@@ -1436,7 +1435,7 @@
# 0 1 * * * 1am daily: # 0 1 * * * 1am daily:
# Orchestrators/daily_sync_maintenance.sh # Orchestrators/daily_sync_maintenance.sh
# #
# 0 3 * * * 3am daily (fires monthly — self-gated by uptime + interval): # 0 0 15 * * 15th of each month at midnight:
# Orchestrators/monthly_maintenance.sh # Orchestrators/monthly_maintenance.sh
# #
# 0 7 * * 0 Sunday 7am: # 0 7 * * 0 Sunday 7am: