diff --git a/Plugin/unraid/include/partnership.php b/Plugin/unraid/include/partnership.php index 8f2d9f2..dafd069 100644 --- a/Plugin/unraid/include/partnership.php +++ b/Plugin/unraid/include/partnership.php @@ -240,6 +240,72 @@ function vv_pt_remote_system(string $ip, string $sshKey): array { ]; } +// ── Transfer volume ─────────────────────────────────────────────────────────── +// +// Reads data/db/bandwidth_history.db, one line per completed rsync: +// YYYY-MM-DD|HH:MM|profile|duration|status|bytes +// +// Rows before VV_BW_EPOCH are excluded outright. Until 2026-08-14 rsync.sh read the byte count +// from the wrong field of rsync --stats and every row was written as 0 — 1,423 of them. Summing +// those produces a confident 0.00 GB for any window that reaches back far enough, which is worse +// than an empty card: it is a measurement that looks taken. The cut is on the fix, not on a +// guess about which rows look plausible. +// +// A window whose rows are all zero reports null, not 0, so the UI can say "no transfers +// recorded" rather than assert nothing moved. Those are different claims and only one of them +// is supported by an all-zero column. +define('VV_BW_EPOCH', '2026-08-14'); + +function vv_pt_transfer_stats(): array { + $file = DATA_DIR . '/db/bandwidth_history.db'; + $out = ['epoch' => VV_BW_EPOCH, 'windows' => [], 'live' => null, 'usable_rows' => 0]; + + $windows = ['24h' => 86400, '7d' => 604800, '30d' => 2592000]; + foreach ($windows as $k => $_) $out['windows'][$k] = ['bytes' => null, 'runs' => 0, 'failed' => 0]; + + if (is_file($file)) { + $now = time(); + $epoch = strtotime(VV_BW_EPOCH . ' 00:00:00'); + $fh = fopen($file, 'r'); + if ($fh) { + while (($line = fgets($fh)) !== false) { + $p = explode('|', trim($line)); + if (count($p) < 6) continue; + $ts = strtotime($p[0] . ' ' . $p[1]); + if (!$ts || $ts < $epoch) continue; + $out['usable_rows']++; + $bytes = (int)$p[5]; + $failed = ($p[4] ?? '') !== 'success'; + foreach ($windows as $k => $span) { + if ($ts < $now - $span) continue; + $w = &$out['windows'][$k]; + $w['runs']++; + if ($failed) $w['failed']++; + if ($bytes > 0) $w['bytes'] = (int)$w['bytes'] + $bytes; + unset($w); + } + } + fclose($fh); + } + } + + // Live: an rsync this host is running right now. The lock name carries the share, which is + // the only part of "what is moving" worth showing without parsing progress output. + $running = trim((string)shell_exec("pgrep -c -f '^rsync -av' 2>/dev/null")) ; + if ((int)$running > 0) { + $share = ''; + foreach (glob('/tmp/unraid_locks/rsync_*.lock') ?: [] as $l) { + $pid = (int)preg_replace('/\D/', '', (string)@file_get_contents($l)); + if ($pid && is_dir("/proc/$pid")) { + $share = preg_replace('/^rsync_|\.lock$/', '', basename($l)); + break; + } + } + $out['live'] = ['running' => (int)$running, 'share' => $share]; + } + return $out; +} + // ── Media seed progress ─────────────────────────────────────────────────────── // // Phase 3 dispatches Rsync/media_seed.sh detached, because a first seed of a full @@ -501,6 +567,7 @@ function vv_partnership_all(): array { 'config' => vv_pt_config(), 'nodes' => vv_pt_nodes(), 'sync' => vv_pt_sync(), + 'xfer' => vv_pt_transfer_stats(), 'ts' => time(), ]; } diff --git a/Plugin/unraid/pages/partnership.php b/Plugin/unraid/pages/partnership.php index b925036..71f9aa2 100644 --- a/Plugin/unraid/pages/partnership.php +++ b/Plugin/unraid/pages/partnership.php @@ -105,16 +105,6 @@ textarea.vv-pt-set-input { resize:vertical; white-space:pre; }
Loading…
- - - @@ -123,8 +113,11 @@ textarea.vv-pt-set-input { resize:vertical; white-space:pre; }
Loading…
- - + +
+

Data Transferred

+
Loading…
+
@@ -153,9 +146,28 @@ textarea.vv-pt-set-input { resize:vertical; white-space:pre; }
- Partnership configuration for this server. + Partnership, host and array configuration for this server. +
+ + + - @@ -504,9 +516,9 @@ function _vvRenderArrayCards() { if (!hasArrays) { wrap.style.display = 'none'; return; } wrap.style.display = ''; - wrap.innerHTML = `
+ wrap.innerHTML = `
-

Array Settings

+
Array settings
@@ -570,7 +582,9 @@ function vvPtToggleSettings() { } function vvPtLoadSettings() { - const body = document.getElementById('vv-pt-settings-body'); + // The conf rows render into their own sub-node, not the whole collapsible body — the body now + // also holds the host and array sections, and writing over it would delete them. + const body = document.getElementById('vv-pt-settings-conf'); body.innerHTML = '
Loading…
'; fetch('/plugins/varaverk/api/partnership_settings.php?_=' + Date.now()) .then(r => r.json()) @@ -627,12 +641,12 @@ function vvPtSetChanged(el) { const changed = el.value !== el.dataset.orig; el.classList.toggle('changed', changed); // Show Save button if any field is changed - const any = document.querySelector('#vv-pt-settings-body .vv-pt-set-input.changed'); + const any = document.querySelector('#vv-pt-settings-conf .vv-pt-set-input.changed'); document.getElementById('vv-pt-settings-save').style.display = any ? '' : 'none'; } async function vvPtSaveSettings(btn) { - const changedEls = document.querySelectorAll('#vv-pt-settings-body .vv-pt-set-input.changed'); + const changedEls = document.querySelectorAll('#vv-pt-settings-conf .vv-pt-set-input.changed'); if (!changedEls.length) return; const changes = []; changedEls.forEach(el => changes.push({ @@ -723,6 +737,64 @@ function _syncToggle(gate, dimmed) { title="${gate.var} — click to ${on ? 'disable' : 'enable'}">${on ? 'ON' : 'OFF'}`; } +// ── Data transferred ────────────────────────────────────────────────────────── +// +// A window with runs but no bytes says "no data moved", not "0.00 GB". They are different +// claims: rsync completing with nothing to send is the normal state for a mirror that is +// already in step, and printing a zero total invites the reading that the sync is broken. +// A window with no runs at all says so separately again. +function _renderXfer(x) { + const el = document.getElementById('vv-pt-xfer-body'); + if (!el) return; + x = x || {}; + const w = x.windows || {}; + + const fmt = (b) => { + if (b === null || b === undefined) return null; + const u = ['B','KB','MB','GB','TB']; + let i = 0, v = b; + while (v >= 1024 && i < u.length - 1) { v /= 1024; i++; } + return (i >= 3 ? v.toFixed(2) : Math.round(v)) + ' ' + u[i]; + }; + + const row = (label, key) => { + const d = w[key] || {}; + const bytes = fmt(d.bytes); + let val, col; + if (!d.runs) { val = 'no syncs ran'; col = '#333'; } + else if (bytes) { val = bytes; col = '#4caf50'; } + else { val = 'no data moved'; col = '#555'; } + const extra = d.runs + ? `${d.runs} run${d.runs===1?'':'s'}${d.failed?` · ${d.failed} failed`:''}` + : ''; + return `
+ ${label} + + ${extra}${val} +
`; + }; + + const live = x.live; + const liveHtml = live + ? `
+ Live + ⟳ ${live.running} transfer${live.running===1?'':'s'}${live.share?` · ${vvEscHtml(live.share)}`:''} +
` + : `
+ Live + idle +
`; + + el.innerHTML = liveHtml + row('Last 24 hours','24h') + row('Last 7 days','7d') + row('Last 30 days','30d') + + `
+ Counted from ${vvEscHtml(x.epoch || '')} — before that rsync logged every transfer as + 0 bytes, so those rows are excluded rather than summed into a false total. +
`; +} + function _renderSync(sync) { const jobs = sync.jobs || {}; const gates = sync.gates || {}; @@ -1332,7 +1404,7 @@ function _render(data) { document.getElementById('vv-pt-config-body').innerHTML = _renderConfig(cfg); // Host settings — always shown upfront - const hostsCard = document.getElementById('vv-pt-hosts-card'); + const hostsCard = document.getElementById("vv-pt-hosts-section"); hostsCard.style.display = nodes.length ? '' : 'none'; document.getElementById('vv-pt-hosts-body').innerHTML = _renderHostSettings(nodes, isOwner); document.getElementById('vv-pt-hosts-save').style.display = 'none'; @@ -1341,6 +1413,7 @@ function _render(data) { _renderOfflineWarn(cfg); // Mirror sync health + _renderXfer(data.xfer || {}); document.getElementById('vv-pt-sync-body').innerHTML = _renderSync(data.sync || {}); // Node grid — columns based on count