Files
Varaverk/Plugin/unraid/api/rsync.php
T
Gmer4Lfe 28987240aa Let a sync window take a whole share in one click
The window editor is path-first, which is exact but slow for the case that is nearly all of them.
The chips only offer whole shares; a subpath already in the window marks its share partial rather
than offering to widen it, and the path box stays the only way to express a subpath or a profile.
2026-08-23 17:16:24 -04:00

254 lines
12 KiB
PHP

<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// Rsync tab data. Tier enablement, the currently running sync, last-run results, 30 days of
// bandwidth history, the per-window script and share lists, and the tuning settings — plus
// a separate action serving the live sync log.
//
// OPERATIONAL MODEL
// Two responses on one URL. The default is the tab's full state. ?action=rsync_log is the
// log viewer, split out because it polls far faster than the rest and returns a payload
// nothing else needs.
//
// The log action has two sources and prefers the live one. An active sync is identified by
// a lock file whose pid is still in /proc, and its in-progress log is read directly. With
// no active sync, the most recently modified .last.log is served instead — so the panel
// shows the run that is happening, or failing that the run that just happened, without the
// caller having to know which.
//
// Window definitions are a table, not a set of branches. Each tier names the master.conf
// script array and the host.conf shares array it draws from, so adding a tier is a row.
// monthly is deliberately half-populated — monthly_maintenance.sh does ZFS scrub and SMART
// tests and has no rsync section, so no shares variable exists for it. fallback has neither:
// it is driven entirely by fallback.sh.
//
// DESIGN PRINCIPLES
// Liveness is proven by the process table, not by the lock file's existence.
// A lock whose pid is gone is a crashed run, and treating it as active would show a
// stale log as a sync in progress forever.
//
// Toggles default to on when the key is absent.
// !== 'false' rather than === 'true', so a conf that predates a flag behaves as it did
// before the flag existed. That is the correct default for a tier that was previously
// unconditional.
//
// History is filtered by date, not by line count.
// The bandwidth log is append-only and unbounded; a 30-day cutoff keeps the response
// proportional to the window the page renders rather than to the file's age.
//
// Elapsed time comes from the lock's mtime.
// The lock is touched when the run starts, so its age is the run's age without the
// script having to report progress.
//
// OPERATIONAL SAFEGUARDS
// Read-only. Nothing here starts, stops, or reconfigures a sync — this endpoint reports on
// rsync.sh, and the interlocks that make a sync safe live there. In particular, the
// --merge-run / --delete ordering is rsync.sh's to enforce; nothing in this payload implies
// it has been satisfied.
//
// The lock and log scan is bounded to a hardcoded directory.
// glob over /tmp/unraid_locks — a literal, not a config value.
//
// Every read degrades to empty.
// @file_get_contents, file() with ?: [] fallbacks, and file_exists() before each read.
// A lock caught mid-write or a log removed between the glob and the read yields an empty
// line list rather than a fatal.
//
// Malformed history lines are skipped, not repaired.
// A count check before the fields are used, and an isset() on the optional bytes column,
// so a truncated or older-format row is dropped instead of producing a row of nulls.
//
// Output is bounded and stripped.
// Last 200 lines, with ANSI escapes removed. rsync logs are long and coloured, and the
// escapes would otherwise reach the page as control codes.
//
// Every setting has a default.
// ?? on all four tuning values and on the bandwidth warning threshold, so a conf missing
// a key renders a usable panel rather than zeros that read as "no limit configured".
//
// The profile name is derived from the lock's own contents, with the filename as fallback,
// so a lock written by an older format still identifies its run.
//
// REQUEST
// GET full rsync tab state
// GET ?action=rsync_log live sync log, or the most recent completed one
//
// RESPONSE
// default {"enabled","windows","active","last_sync","bw_history":[…],"bw_warn_gb",
// "win_arrays":{<window>:{"scripts":[…],"shares":[…]}},"settings":{…},"ts"}
// log {"ok":true,"live":bool,"profile","elapsed","lines":[…]}
//
// DEPENDS ON
// include/monitor.php vv_rsync_status()
// include/config.php vv_conf_vars(), vv_read_conf_raw(), vv_detect_host(),
// vv_parse_bash_array(), DATA_DIR
// /tmp/unraid_locks rsync_*.lock, rsync_*.log, rsync_*.last.log — written by rsync.sh
// DATA_DIR bandwidth_history.db
// ═══════════════════════════════════════════════════════════════════════════════════════════════
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/config.php';
require_once dirname(__DIR__) . '/include/monitor.php';
// ── Live sync log ─────────────────────────────────────────────────────────────
$action = $_GET['action'] ?? '';
// ── Share list, for the quick-select strip on the window editor ──────────────────────────────
// The editor is path-first — type or browse, then Add — which is exact but slow for the case that
// is nearly all of them: add one whole share. This answers "what shares exist here", so the strip
// can offer them as one click each.
//
// Read from /boot/config/shares/*.cfg rather than by listing /mnt/user, because a share is a
// declared thing: the directory can be absent on a share that has never been written to, and
// listing the mount would also invent entries for stray directories that are not shares at all.
if ($action === 'shares') {
$out = [];
foreach (glob('/boot/config/shares/*.cfg') ?: [] as $cfg) {
$name = basename($cfg, '.cfg');
$raw = (string) @file_get_contents($cfg);
$get = function (string $k) use ($raw): string {
return preg_match('/^' . $k . '="([^"]*)"/m', $raw, $m) ? $m[1] : '';
};
$path = '/mnt/user/' . $name;
$out[] = [
'name' => $name,
'path' => $path,
'comment' => $get('shareComment'),
'pool' => $get('shareCachePool'),
'cache' => $get('shareUseCache'),
// Present is not the same as declared — a share can exist in conf with no directory yet.
'exists' => is_dir($path),
];
}
usort($out, fn($a, $b) => strcasecmp($a['name'], $b['name']));
header('Content-Type: application/json');
echo json_encode(['ok' => true, 'shares' => $out]);
exit;
}
if ($action === 'rsync_log') {
$lockDir = '/tmp/unraid_locks';
$lines = [];
$profile = null;
$live = false;
$elapsed = 0;
// Active sync — read live log
foreach (glob("$lockDir/rsync_*.lock") ?: [] as $lf) {
$content = trim(@file_get_contents($lf) ?: '');
[$pid, $locked_name] = array_pad(explode(':', $content, 2), 2, '');
if (!$pid || !file_exists("/proc/$pid")) continue;
$profile = preg_replace('/^rsync_/', '', $locked_name ?: basename($lf, '.lock'));
$elapsed = time() - (int)filemtime($lf);
$liveLog = "$lockDir/rsync_{$profile}.log";
$raw = file_exists($liveLog) ? (file($liveLog, FILE_IGNORE_NEW_LINES) ?: []) : [];
$live = true;
$lines = $raw;
break;
}
// No active sync — use most recent last.log
if (!$live) {
$lastLogs = glob("$lockDir/rsync_*.last.log") ?: [];
if ($lastLogs) {
usort($lastLogs, fn($a, $b) => filemtime($b) <=> filemtime($a));
$lastLog = $lastLogs[0];
$profile = preg_replace('/^rsync_(.+)\.last\.log$/', '$1', basename($lastLog));
$lines = file($lastLog, FILE_IGNORE_NEW_LINES) ?: [];
}
}
// Strip ANSI escape codes, keep last 200 lines
$lines = array_map(fn($l) => preg_replace('/\x1b\[[0-9;]*[mGKHF]/', '', $l), $lines);
$lines = array_slice($lines, -200);
echo json_encode(['ok' => true, 'live' => $live, 'profile' => $profile, 'elapsed' => $elapsed, 'lines' => array_values($lines)]);
exit;
}
$base = vv_rsync_status();
$vars = vv_conf_vars();
$base['windows']['fallback'] = ($vars['FALLBACK_RSYNC_ENABLED'] ?? 'true') !== 'false';
$base['windows']['monthly'] = ($vars['MONTHLY_RSYNC_ENABLED'] ?? 'true') !== 'false';
// Bandwidth history — last 30 days
$bwLog = DB_DIR . '/bandwidth_history.db';
$warnGb = (float)($vars['BANDWIDTH_WARN_GB'] ?? 50);
$cutoff = date('Y-m-d', strtotime('-30 days'));
$history = [];
// When rsync last actually moved anything, taken from the whole file rather than the window above.
//
// This is the only record of a transfer. last_sync is built from the orchestrators' run records,
// and those orchestrators do a great deal that is not rsync — git pull, permissions, cleaners, arr
// cleanup, docker updates — and run on their schedule whether or not RSYNC_ENABLED is true. So the
// page could report a healthy 56-minute "daily sync" for a subsystem that had not run in a month,
// which is exactly what it was doing: the last entry here is 2026-07-16, the day the global gate
// was closed.
//
// Deliberately outside the 30-day cutoff. The last transfer is 29 days old as this is written and
// would have dropped out of the window within days, taking the page from a wrong answer to no
// answer — which on this question is not an improvement.
$lastTransfer = null;
if (file_exists($bwLog)) {
foreach (file($bwLog, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $line) {
$p = explode('|', $line);
if (count($p) < 5) continue;
// Recorded before the cutoff test, and unconditionally: the file is chronological, so the
// last line to pass the shape check is the most recent transfer however old it is.
$lastTransfer = [
'ts' => strtotime($p[0] . ' ' . $p[1]) ?: null,
'profile' => $p[2],
'status' => trim($p[4]),
];
if ($p[0] < $cutoff) continue;
$history[] = [
'date' => $p[0],
'time' => $p[1],
'profile' => $p[2],
'duration' => (int)$p[3],
'status' => trim($p[4]),
'bytes' => isset($p[5]) ? (int)$p[5] : 0,
];
}
}
// Per-window orch arrays (scripts + sync shares)
$masterRaw = vv_read_conf_raw('master.conf');
$myId = strtoupper(vv_detect_host());
$hostRaw = vv_read_conf_raw(vv_detect_host() . '.conf');
$winArrayDefs = [
'critical' => ['CRITICAL_MAINTENANCE_SCRIPTS', "{$myId}_CRITICAL_SYNC_SHARES"],
'intermediate' => ['INTERMEDIATE_MAINTENANCE_SCRIPTS', "{$myId}_INTERMEDIATE_SYNC_SHARES"],
'daily' => ['DAILY_MAINTENANCE_SCRIPTS', "{$myId}_DAILY_SYNC_SHARES"],
'weekly' => ['WEEKLY_MAINTENANCE_SCRIPTS', "{$myId}_WEEKLY_SYNC_SHARES"],
// monthly_maintenance.sh has no rsync section (ZFS scrub/SMART tests only) — no shares var exists.
'monthly' => ['MONTHLY_MAINTENANCE_SCRIPTS', null],
'fallback' => [null, null],
];
$winArrays = [];
foreach ($winArrayDefs as $win => [$sv, $shv]) {
$winArrays[$win] = [
'scripts' => $sv ? vv_parse_bash_array($masterRaw, $sv) : [],
'shares' => $shv ? vv_parse_bash_array($hostRaw, $shv) : [],
];
}
echo json_encode([
'enabled' => $base['enabled'],
'windows' => $base['windows'],
'active' => $base['active'],
'last_sync' => $base['last_sync'],
'last_transfer' => $lastTransfer,
'bw_history' => $history,
'bw_warn_gb' => $warnGb,
'win_arrays' => $winArrays,
'settings' => [
'bw_limit' => (int)($vars['BW_LIMIT'] ?? 0),
'retry_count' => (int)($vars['RETRY_COUNT'] ?? 3),
'sleep' => (int)($vars['SLEEP'] ?? 300),
'bw_warn_gb' => (float)($vars['BANDWIDTH_WARN_GB'] ?? 50),
],
'ts' => time(),
]);