last_sync reads the maintenance orchestrators' run records, and those do git pull, permissions, cleaners and docker updates on schedule whether or not the global gate is open — so the page reported a healthy 56-minute "daily sync" while nothing had transferred since 16 July. Both cards also compared status against 'success' while being fed 'ok', so every clean run rendered in the failure colour. Adds the assistant, scoped to the tab.
222 lines
11 KiB
PHP
222 lines
11 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'] ?? '';
|
|
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 for the HOST2 rebuild.
|
|
//
|
|
// 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(),
|
|
]);
|