Files
Varaverk/Plugin/unraid/pages/rsync.php
T

1798 lines
92 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<style>
.vv-ry-card { background:#161616;border:1px solid #2a2a2a;border-radius:6px;padding:10px 12px;min-width:0; }
.vv-ry-sec { font-size:10px;font-weight:bold;color:#444;letter-spacing:.07em;text-transform:uppercase;margin-bottom:6px; }
.vv-ry-row { display:flex;justify-content:space-between;align-items:baseline;gap:6px;margin:2px 0; }
.vv-ry-lbl { font-size:11px;color:#444;white-space:nowrap; }
.vv-ry-val { font-size:12px;color:#bbb;text-align:right; }
.vv-ry-sep { border:none;border-top:1px solid #1e1e1e;margin:7px 0; }
.vv-ry-pill { display:inline-block;font-size:10px;padding:1px 7px;border-radius:3px;font-weight:bold;letter-spacing:.04em; }
.vv-ry-pill.on { background:#0d1f0d;color:#4caf50;border:1px solid #1a3a1a; }
.vv-ry-pill.off { background:#1e1e1e;color:#555;border:1px solid #2a2a2a; }
.vv-ry-pill.warn { background:#1f1500;color:#ffb74d;border:1px solid #3a2800; }
.vv-ry-pill.err { background:#200d0d;color:#ef5350;border:1px solid #3a1a1a; }
.vv-ry-pill.run { background:#0a1a2a;color:#4a9eff;border:1px solid #1a3a5a; }
.vv-ry-dot { display:inline-block;width:7px;height:7px;border-radius:50%;flex-shrink:0;margin-right:5px;margin-bottom:-1px; }
/* Window cards row */
.vv-ry-wins { grid-column:1/-1;display:grid;grid-template-columns:repeat(5,1fr);gap:10px; }
@media (max-width:900px) { .vv-ry-wins { grid-template-columns:repeat(2,1fr); } }
.vv-ry-win { background:#161616;border:1px solid #2a2a2a;border-radius:6px;padding:10px 12px; }
.vv-ry-win.enabled { border-color:#1a3a1a; }
.vv-ry-win.disabled { opacity:.55; }
.vv-ry-win-lbl { font-size:10px;font-weight:bold;letter-spacing:.08em;text-transform:uppercase;color:#555;margin-bottom:6px; }
.vv-ry-win-name{ font-size:15px;font-weight:bold;color:#888;margin-bottom:6px; }
.vv-ry-win-stat{ font-size:11px;color:#3a3a3a;margin-top:4px; }
.vv-ry-win-dur { font-size:10px;color:#3a3a3a; }
/* Bandwidth section */
.vv-ry-bw { grid-column:1/-1;display:grid;grid-template-columns:5fr 3fr;gap:10px; }
@media (max-width:900px) { .vv-ry-bw { grid-template-columns:1fr; } }
.vv-ry-prof-row{ display:flex;align-items:center;gap:8px;margin:3px 0; }
.vv-ry-prof-nm { font-size:11px;color:#888;width:120px;flex-shrink:0; }
.vv-ry-prof-bar{ flex:1;height:5px;background:#1e1e1e;border-radius:2px;overflow:hidden; }
.vv-ry-prof-fill{ height:100%;border-radius:2px;transition:width .3s; }
.vv-ry-prof-cnt{ font-size:10px;color:#555;width:60px;text-align:right;flex-shrink:0; }
.vv-ry-run-row { display:grid;grid-template-columns:80px 90px 1fr 55px 48px;gap:4px;align-items:center;padding:2px 0;border-bottom:1px solid #1a1a1a; }
.vv-ry-run-row:last-child { border-bottom:none; }
.vv-ry-runs-scroll { max-height:200px;overflow-y:auto;scrollbar-width:none;-ms-overflow-style:none; }
.vv-ry-runs-scroll::-webkit-scrollbar { display:none; }
.vv-ry-run-dt { font-size:10px;color:#3a3a3a; }
.vv-ry-run-tm { font-size:10px;color:#333; }
.vv-ry-run-pr { font-size:11px;color:#888;white-space:nowrap;overflow:hidden;text-overflow:ellipsis; }
.vv-ry-run-dur { font-size:10px;color:#555;text-align:right; }
.vv-ry-run-st { text-align:right; }
/* Sync log card */
.vv-ry-slog-row { display:grid;grid-template-columns:38px 1fr 70px 50px 46px;gap:6px;align-items:center;
padding:3px 0;border-bottom:1px solid #111; }
.vv-ry-slog-row:last-child { border-bottom:none; }
.vv-ry-slog-name { font-size:11px;color:#888;overflow:hidden;text-overflow:ellipsis;white-space:nowrap; }
.vv-ry-slog-dt { font-size:10px;color:#3a3a3a; }
.vv-ry-slog-dur { font-size:10px;color:#555;text-align:right; }
.vv-ry-slog-byt { font-size:10px;color:#555;text-align:right; }
.vv-ry-slog-scrl { overflow-y:auto;max-height:200px; }
/* Settings */
.vv-ry-set-grid { display:grid;grid-template-columns:repeat(2,1fr);gap:8px;margin-top:6px; }
.vv-ry-toggle { display:inline-flex;align-items:center;gap:8px;cursor:pointer;user-select:none; }
.vv-ry-toggle-track { width:32px;height:18px;border-radius:9px;background:#222;border:1px solid #333;position:relative;transition:background .15s,border-color .15s;flex-shrink:0; }
.vv-ry-toggle-track.on { background:#1a3a1a;border-color:#2d5a2d; }
.vv-ry-toggle-track::after { content:'';position:absolute;top:2px;left:2px;width:12px;height:12px;border-radius:50%;background:#555;transition:left .15s,background .15s; }
.vv-ry-toggle-track.on::after { left:16px;background:#4caf50; }
.vv-ry-toggle-lbl { font-size:11px;color:#666; }
.vv-ry-set-row { display:flex;align-items:center;gap:10px;margin:4px 0; }
.vv-ry-set-lbl { font-size:11px;color:#555;width:130px;flex-shrink:0; }
.vv-ry-set-inp { background:#111;border:1px solid #2a2a2a;border-radius:3px;color:#bbb;font-size:12px;padding:3px 7px;width:80px;outline:none; }
.vv-ry-set-inp:focus { border-color:#444; }
.vv-ry-set-hint { font-size:10px;color:#3a3a3a; }
.vv-ry-save-btn { background:#1a2a1a;border:1px solid #2d4a2d;color:#4caf50;font-size:11px;padding:4px 14px;border-radius:3px;cursor:pointer;transition:background .15s; }
.vv-ry-save-btn:hover { background:#223a22; }
.vv-ry-save-btn:disabled { opacity:.4;cursor:default; }
.vv-ry-feedback { font-size:11px;margin-left:8px; }
/* ── Info layer ──────────────────────────────────────────────────── */
.vv-ry-info-block { border:1px solid #1a1a1a;border-radius:4px;margin-bottom:6px;overflow:hidden; }
.vv-ry-info-hdr { padding:7px 12px;background:#111;cursor:pointer;font-size:11px;font-weight:600;
color:#666;display:flex;align-items:center;gap:6px;user-select:none; }
.vv-ry-info-hdr:hover { color:#999;background:#141414; }
.vv-ry-info-chev { font-size:10px;color:#333;flex-shrink:0; }
.vv-ry-info-body { padding:10px 14px;background:#0d0d0d; }
.vv-ry-info-cols { display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:8px 20px; }
.vv-ry-info-cols > div { font-size:11px;color:#555;line-height:1.5; }
.vv-ry-info-cols strong { color:#777; }
.vv-ry-info-cols code { font-size:10px;color:#4a7a4a;background:#080808;padding:1px 4px;border-radius:2px;font-family:monospace; }
/* ── Profile editor flag checkboxes ─────────────────────────────── */
.vv-rp-flag { display:inline-flex;align-items:center;gap:4px;cursor:pointer;
padding:3px 8px;border:1px solid #1e1e1e;border-radius:3px;background:#0d0d0d;
font-size:10px;color:#555;user-select:none;transition:border-color .1s; }
.vv-rp-flag:hover { border-color:#2a3a2a;color:#888; }
.vv-rp-flag input[type=checkbox] { accent-color:#4caf50;width:11px;height:11px;cursor:pointer; }
.vv-rp-flag input:checked ~ span { color:#4caf50; }
.vv-rp-flag:has(input:checked) { border-color:#1a3a1a;background:#080f08; }
.vv-rp-flag em { font-style:normal;color:#2e4e2e; }
/* ── Manual Sync card ──────────────────────────────────────────────── */
.vv-ms-row { display:flex;gap:10px;margin-bottom:8px;align-items:flex-start;flex-wrap:wrap; }
.vv-ms-col { display:flex;flex-direction:column;gap:4px;flex:1 1 200px;min-width:0; }
.vv-ms-lbl { font-size:9px;font-weight:700;color:#444;text-transform:uppercase;letter-spacing:.07em; }
.vv-ms-inp { background:#0d0d0d;border:1px solid #252525;border-radius:3px;color:#ccc;
font-size:12px;padding:5px 8px;width:100%;box-sizing:border-box;font-family:monospace;outline:none; }
.vv-ms-inp:focus { border-color:#3a5a3a; }
.vv-ms-sel { background:#0d0d0d;border:1px solid #252525;border-radius:3px;color:#ccc;
font-size:12px;padding:5px 8px;width:100%;box-sizing:border-box;outline:none;cursor:pointer; }
.vv-ms-sel:focus { border-color:#3a5a3a; }
.vv-ms-dirs { background:#080808;border:1px solid #1e1e1e;border-radius:3px;
max-height:160px;overflow-y:auto;margin-top:2px; }
.vv-ms-dir { padding:4px 10px;font-size:11px;color:#555;font-family:monospace;cursor:pointer;
white-space:nowrap;overflow:hidden;text-overflow:ellipsis;border-bottom:1px solid #111; }
.vv-ms-dir:hover { background:#0f1f0f;color:#4caf50; }
.vv-ms-dir.parent { color:#444;font-style:italic; }
.vv-ms-dir.parent:hover { color:#888; }
.vv-ms-optrow { display:flex;align-items:center;gap:16px;flex-wrap:wrap;margin-bottom:8px; }
.vv-ms-chk { display:flex;align-items:center;gap:5px;cursor:pointer; }
.vv-ms-chk input { accent-color:#4caf50;width:13px;height:13px; }
.vv-ms-chk span { font-size:11px;color:#555; }
.vv-ms-run { background:#1a2a1a;border:1px solid #2d4a2d;color:#4caf50;font-size:12px;
font-weight:600;padding:6px 18px;border-radius:3px;cursor:pointer;letter-spacing:.03em; }
.vv-ms-run:hover { background:#223a22; }
.vv-ms-run:disabled { opacity:.4;cursor:default; }
.vv-ms-out { background:#080808;border:1px solid #1a1a1a;border-radius:3px;padding:8px 10px;
font-family:monospace;font-size:10px;color:#555;white-space:pre-wrap;word-break:break-all;
max-height:220px;overflow-y:auto;margin-top:8px;display:none; }
.vv-ms-badge { font-size:9px;padding:1px 7px;border-radius:3px;font-weight:700; }
@media (max-width:700px) { #vv-ms-body > div:first-child { grid-template-columns:1fr !important; } }
</style>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;padding:0 2px;">
<span style="font-size:13px;font-weight:bold;color:#888;text-transform:uppercase;letter-spacing:.06em;">Rsync</span>
<span style="font-size:11px;color:#3a3a3a;" id="vv-ry-ts"></span>
</div>
<!-- Manual Sync card static, not re-rendered by the poll loop -->
<div class="vv-ry-card" id="vv-ms-card" style="margin-bottom:12px;">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;">
<div style="display:flex;align-items:center;gap:8px;">
<span style="font-size:12px;font-weight:700;color:#888;text-transform:uppercase;letter-spacing:.06em;">Manual Rsync</span>
<span id="vv-ms-badge" class="vv-ms-badge" style="display:none;"></span>
</div>
<button onclick="vvMsToggle()" style="font-size:10px;color:#4a6a4a;background:#0d1a0d;border:1px solid #1a3a1a;
border-radius:3px;padding:2px 10px;cursor:pointer;" id="vv-ms-tog"> Show</button>
</div>
<div id="vv-ms-body" style="display:none;">
<!-- Two-column browse layout -->
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:10px;">
<!-- ── Left: local source ── -->
<div>
<div class="vv-ms-lbl" style="margin-bottom:5px;">Local source</div>
<div style="display:flex;gap:5px;align-items:center;margin-bottom:4px;">
<input class="vv-ms-inp" id="vv-ms-local" type="text" placeholder="/mnt/user/…"
style="flex:1;min-width:0;" onkeydown="if(event.key==='Enter')vvMsBrowseLocal()">
<button onclick="vvMsLocalNavUp()" title="Parent"
style="background:#111;border:1px solid #222;color:#555;border-radius:3px;
padding:4px 8px;cursor:pointer;font-size:12px;flex-shrink:0;"></button>
<button onclick="vvMsBrowseLocal()" id="vv-ms-lbrowse-btn"
style="background:#111;border:1px solid #222;color:#4a9eff;border-radius:3px;
padding:4px 9px;cursor:pointer;font-size:11px;white-space:nowrap;flex-shrink:0;"> Browse</button>
</div>
<div id="vv-ms-local-dirs" class="vv-ms-dirs" style="display:none;"></div>
<span id="vv-ms-lbrowse-err" style="font-size:9px;color:#ef5350;display:none;margin-top:2px;display:block;"></span>
<span style="font-size:9px;color:#2a2a2a;margin-top:3px;display:block;">trailing / = sync contents · without / = sync the folder</span>
</div>
<!-- ── Right: remote destination ── -->
<div>
<div style="display:flex;gap:8px;margin-bottom:6px;flex-wrap:wrap;">
<div style="flex:1 1 140px;min-width:0;">
<div class="vv-ms-lbl" style="margin-bottom:3px;">Remote server</div>
<select class="vv-ms-sel" id="vv-ms-host" onchange="vvMsHostChanged()">
<option value="">Loading hosts…</option>
</select>
<span id="vv-ms-host-status" style="font-size:9px;color:#333;margin-top:2px;display:block;"></span>
</div>
<div style="flex:0 1 80px;">
<div class="vv-ms-lbl" style="margin-bottom:3px;">User</div>
<select class="vv-ms-sel" id="vv-ms-user">
<option value="root">root</option>
</select>
</div>
</div>
<div class="vv-ms-lbl" style="margin-bottom:5px;">Remote path</div>
<div style="display:flex;gap:5px;align-items:center;margin-bottom:4px;">
<input class="vv-ms-inp" id="vv-ms-rpath" type="text" placeholder="/mnt/user/…"
style="flex:1;min-width:0;" onkeydown="if(event.key==='Enter')vvMsBrowse()">
<button onclick="vvMsNavUp()" title="Parent"
style="background:#111;border:1px solid #222;color:#555;border-radius:3px;
padding:4px 8px;cursor:pointer;font-size:12px;flex-shrink:0;"></button>
<button onclick="vvMsBrowse()" id="vv-ms-browse-btn"
style="background:#111;border:1px solid #222;color:#4a9eff;border-radius:3px;
padding:4px 9px;cursor:pointer;font-size:11px;white-space:nowrap;flex-shrink:0;"> Browse</button>
</div>
<div id="vv-ms-dirs" class="vv-ms-dirs" style="display:none;"></div>
<span id="vv-ms-browse-err" style="font-size:9px;color:#ef5350;display:none;margin-top:2px;display:block;"></span>
</div>
</div>
<!-- Rsync flags -->
<div style="border-top:1px solid #1a1a1a;padding-top:8px;margin-bottom:8px;">
<div class="vv-ms-lbl" style="margin-bottom:6px;">Rsync flags
<span style="font-size:9px;font-weight:normal;color:#2a2a2a;margin-left:4px;">does not inherit defaults check every desired flag</span>
</div>
<div style="display:flex;gap:5px;flex-wrap:wrap;">
<label class="vv-rp-flag"><input type="checkbox" data-flag="-av" checked onchange="vvMsUpdatePreview()"><span>-av</span></label>
<label class="vv-rp-flag"><input type="checkbox" data-flag="--human-readable" checked onchange="vvMsUpdatePreview()"><span>--human-readable</span></label>
<label class="vv-rp-flag"><input type="checkbox" data-flag="--stats" checked onchange="vvMsUpdatePreview()"><span>--stats</span></label>
<span style="border-left:1px solid #1e1e1e;margin:0 3px;"></span>
<label class="vv-rp-flag"><input type="checkbox" data-flag="--delete" onchange="vvMsUpdatePreview()"><span>--delete</span></label>
<label class="vv-rp-flag"><input type="checkbox" data-flag="--inplace" onchange="vvMsUpdatePreview()"><span>--inplace</span></label>
<label class="vv-rp-flag"><input type="checkbox" data-flag="--no-whole-file" onchange="vvMsUpdatePreview()"><span>--no-whole-file</span></label>
<label class="vv-rp-flag"><input type="checkbox" data-flag="--checksum" onchange="vvMsUpdatePreview()"><span>--checksum</span></label>
<label class="vv-rp-flag"><input type="checkbox" data-flag="--compress" onchange="vvMsUpdatePreview()"><span>--compress</span></label>
<span style="border-left:1px solid #1e1e1e;margin:0 3px;"></span>
<label class="vv-rp-flag"><input type="checkbox" id="vv-ms-dryrun" data-flag="--dry-run" onchange="vvMsUpdatePreview()"><span>--dry-run</span></label>
</div>
<div style="margin-top:6px;background:#080808;border:1px solid #181818;border-radius:3px;
padding:4px 8px;font-family:monospace;font-size:10px;color:#3a5a3a;display:flex;gap:8px;align-items:baseline;">
<span style="font-size:9px;color:#2a2a2a;flex-shrink:0;">rsync</span>
<span id="vv-ms-flags-preview"></span>
<span id="vv-ms-bwlimit-preview" style="color:#2e4e2e;"></span>
<span style="color:#2a2a2a;">…src host:dst</span>
</div>
</div>
<!-- BW limit + SSH key -->
<div class="vv-ms-optrow" style="margin-bottom:8px;">
<div style="display:flex;align-items:center;gap:6px;">
<span style="font-size:11px;color:#444;">BW limit</span>
<input class="vv-ms-inp" id="vv-ms-bw" type="number" min="0" value="0"
style="width:72px;padding:3px 6px;" oninput="vvMsUpdatePreview()">
<span style="font-size:10px;color:#333;">KB/s (0 = unlimited)</span>
</div>
<label class="vv-ms-chk">
<input type="checkbox" id="vv-ms-usekey" checked>
<span>SSH key: <code id="vv-ms-key-info"
style="font-size:9px;color:#2a3a2a;margin-left:2px;font-family:monospace;">loading…</code></span>
</label>
</div>
<!-- Run + Show + Stop + status -->
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:4px;">
<button class="vv-ms-run" id="vv-ms-run-btn" onclick="vvMsRun()"> Run Rsync</button>
<button id="vv-ms-show-btn" onclick="vvMsToggleOutput()"
style="background:#111;border:1px solid #252525;color:#555;font-size:11px;
padding:5px 14px;border-radius:3px;cursor:pointer;"> Show Rsync</button>
<button id="vv-ms-stop-btn" onclick="vvMsStop()"
style="display:none;background:#2a1010;border:1px solid #5a2020;color:#ef5350;
font-size:11px;padding:5px 14px;border-radius:3px;cursor:pointer;font-weight:600;"> Stop</button>
<span id="vv-ms-run-status" style="font-size:10px;color:#444;"></span>
</div>
<!-- Output (auto-opens on run) -->
<pre class="vv-ms-out" id="vv-ms-out" style="display:none;margin-top:8px;"></pre>
</div>
</div>
<div id="vv-ry-grid" style="display:grid;grid-template-columns:repeat(8,1fr);gap:12px;margin-bottom:12px;"></div>
<!-- ── Info / Profile-editor layer ───────────────────────────────────────── -->
<div id="vv-ry-layer">
<!-- Info view (default) -->
<div id="vv-ry-info-view">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:10px;flex-wrap:wrap;gap:8px;">
<span style="font-size:11px;font-weight:700;color:#666;text-transform:uppercase;letter-spacing:.07em;">Rsync Reference</span>
<button onclick="vvRpOpen()"
style="background:#0a1a2a;border:1px solid #1a3a5a;color:#4a9eff;font-size:11px;
padding:4px 14px;border-radius:3px;cursor:pointer;font-weight:600;"> Profile Editor</button>
</div>
<!-- How to use this page -->
<div class="vv-ry-info-block">
<div class="vv-ry-info-hdr" onclick="vvRyInfoToggle(this)">
<span class="vv-ry-info-chev"></span> How to use this page
</div>
<div class="vv-ry-info-body">
<div class="vv-ry-info-cols">
<div><strong>Status card</strong> shows whether the global rsync gate is open. Red = all syncs blocked regardless of tier toggles.</div>
<div><strong>Sync windows</strong> per-tier enable/disable. A tier can be enabled here but still blocked by the global gate.</div>
<div><strong>Settings Global gate</strong> RSYNC_ENABLED in master.conf. Master switch; turn off during rebuilds or maintenance.</div>
<div><strong>Settings Tier toggles</strong> CRITICAL/DAILY/etc._RSYNC_ENABLED. Disable one tier without affecting others.</div>
<div><strong>Bandwidth history</strong> last 30 days of per-profile rsync runs. Bar = relative time spent. Click recent runs for detail.</div>
<div><strong>Manual Rsync</strong> one-off transfer to any partner host. Browse remote dirs via SSH. Runs in background, output streams below.</div>
<div><strong>Profile Editor</strong> create and edit named profiles used by appdata syncs. Each profile controls flags, BW limit, container stop lists, and excludes.</div>
</div>
</div>
</div>
<!-- Sync windows explained -->
<div class="vv-ry-info-block">
<div class="vv-ry-info-hdr" onclick="vvRyInfoToggle(this)">
<span class="vv-ry-info-chev"></span> Sync windows
</div>
<div class="vv-ry-info-body" style="display:none;">
<div class="vv-ry-info-cols">
<div><strong>Critical (30 min)</strong> appdata (arrs, auth, databases), partnership health check, play state sync. Designed to be fast uses dirty-sync profiles where possible.</div>
<div><strong>Intermediate (4 hr)</strong> arr sync between the nightly runs. Catches new indexer hits without waiting until midnight.</div>
<div><strong>Daily (nightly)</strong> media shares, arr maintenance, media cleaner, docker updates, daily container restarts.</div>
<div><strong>Weekly</strong> bulk media shares, update checks, weekly restarts, SMART health digest.</div>
<div><strong>Fallback</strong> fires when a server comes back online after being the active fallback. Syncs any changes that happened during downtime.</div>
<div><strong>Two-tier toggle</strong> global gate (RSYNC_ENABLED) blocks everything. Per-tier flags (e.g. DAILY_RSYNC_ENABLED) disable just that window. Both must be ON for syncs to run.</div>
</div>
</div>
</div>
<!-- Profile system -->
<div class="vv-ry-info-block">
<div class="vv-ry-info-hdr" onclick="vvRyInfoToggle(this)">
<span class="vv-ry-info-chev"></span> Profile system
</div>
<div class="vv-ry-info-body" style="display:none;">
<div class="vv-ry-info-cols">
<div><strong>What is a profile?</strong> A named set of rsync options applied to a specific appdata directory. Appdata shares match a profile by their directory basename (lowercased).</div>
<div><strong>PROFILE_RSYNC_OPTS</strong> Full rsync flag string for this profile. Does NOT inherit DEFAULT_RSYNC_OPTS list every desired flag explicitly. Use <code>$BW_LIMIT</code> to reference the profile's bandwidth limit.</div>
<div><strong>PROFILE_BW_LIMIT</strong> — Bandwidth cap in KB/s. Overrides the global BW_LIMIT for this profile.</div>
<div><strong>PROFILE_RETRY_COUNT / SLEEP</strong> — How many times to retry on failure, and how long to wait between attempts.</div>
<div><strong>PROFILE_CRITICAL_CONTAINER_NAMES</strong> — Containers stopped on BOTH hosts before rsync. Local stops first (flush DBs), then remote. Only running containers get restarted — stopped ones stay stopped.</div>
<div><strong>PROFILE_DELAYED_CONTAINERS</strong> — Containers that need a delay after the others start (e.g. NextCloud waits for Postgres).</div>
<div><strong>PROFILE_CONTAINER_DELAY</strong> — Seconds to wait before starting delayed containers.</div>
<div><strong>PROFILE_EXCLUDE_DIRS</strong> — Space-separated patterns excluded from the rsync. Supports globs (e.g. <code>logs *.tmp *.db-wal</code>).</div>
<div><strong>PROFILE_REMOTE_RESTART_CONTAINERS</strong> — Used by dirty-sync profiles. Restarts these containers on the remote after sync so it picks up the new data.</div>
<div><strong>Clean vs dirty sync</strong> — Clean: stop containers, <code>--delete</code>, full mirror. Dirty: containers keep running, WAL files excluded, <code>--inplace --no-whole-file</code> for safe partial writes. Use dirty for frequent critical syncs.</div>
<div><strong>App layer</strong> — Scripts add their appdata directory to HOST*_CRITICAL_SYNC_SHARES or HOST*_DAILY_SYNC_SHARES using <code>/path/to/dir|profile-name</code> syntax. Creating the profile here makes it available for that reference.</div>
</div>
</div>
</div>
<!-- Rsync flags reference -->
<div class="vv-ry-info-block">
<div class="vv-ry-info-hdr" onclick="vvRyInfoToggle(this)">
<span class="vv-ry-info-chev">▸</span> Common flag combinations
</div>
<div class="vv-ry-info-body" style="display:none;">
<div class="vv-ry-info-cols">
<div><strong>Clean sync (weekly appdata)</strong><br><code>-av --human-readable --bwlimit=$BW_LIMIT --delete</code><br>Full mirror — removes files on destination that no longer exist at source. Safe when containers are stopped.</div>
<div><strong>Dirty sync (every 30 min)</strong><br><code>-av --human-readable --bwlimit=$BW_LIMIT --delete --inplace --no-whole-file</code><br>Containers stay running. <code>--inplace</code> writes directly to destination file (avoids temp-file rename issues). <code>--no-whole-file</code> uses delta transfer. Exclude WAL files to avoid corrupt state.</div>
<div><strong>Database sync (arrs)</strong><br><code>-av --info=progress2 --human-readable --bwlimit=$BW_LIMIT --delete --inplace</code><br>Stop containers first. <code>--info=progress2</code> gives cleaner progress output in logs. Lower BW limit to not starve other syncs running concurrently.</div>
<div><strong>Large media (weekly)</strong><br><code>-av --human-readable --bwlimit=$BW_LIMIT --delete --inplace --no-whole-file</code><br>High BW limit, inplace for large files, delete to keep mirror clean. No container stops — media files are read-only.</div>
<div><code>--delete</code> — remove files at destination that no longer exist at source. Essential for a true mirror.</div>
<div><code>--inplace</code> — write file data directly into destination file instead of creating a temp copy first. Faster for large files; required for some databases.</div>
<div><code>--no-whole-file</code> — force delta transfer even over fast LAN. Reduces data sent for files that change incrementally.</div>
<div><code>--checksum</code> — compare files by checksum rather than mtime+size. Slower but catches any corruption. Use for archive syncs, not frequent syncs.</div>
</div>
</div>
</div>
</div><!-- /info view -->
<!-- Profile editor view (hidden until button click) -->
<div id="vv-rp-view" style="display:none;">
<div style="display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;">
<button onclick="vvRpClose()"
style="background:#111;border:1px solid #222;color:#555;font-size:11px;
padding:4px 12px;border-radius:3px;cursor:pointer;">← Rsync Info</button>
<span style="font-size:11px;font-weight:700;color:#666;text-transform:uppercase;letter-spacing:.07em;">Profile Editor</span>
<div style="margin-left:auto;display:flex;align-items:center;gap:6px;">
<select id="vv-rp-selector"
style="background:#111;border:1px solid #2a2a2a;color:#bbb;font-size:11px;
padding:3px 8px;border-radius:3px;outline:none;cursor:pointer;max-width:200px;"
onchange="vvRpOnSelect(this.value)">
<option value="">Loading…</option>
</select>
<button onclick="vvRpNew()"
style="background:#0a1a0a;border:1px solid #1a3a1a;color:#4caf50;
font-size:11px;padding:3px 12px;border-radius:3px;cursor:pointer;font-weight:600;"> New</button>
</div>
</div>
<!-- Empty state -->
<div id="vv-rp-empty" class="vv-ry-card" style="color:#333;font-size:11px;padding:10px 14px;">
Select a profile above to edit, or click New to create one.
</div>
<!-- Profile form -->
<div id="vv-rp-form" style="display:none;">
<div class="vv-ry-card">
<!-- Name row -->
<div style="display:flex;align-items:center;gap:10px;margin-bottom:12px;flex-wrap:wrap;">
<span style="font-size:10px;font-weight:700;color:#555;text-transform:uppercase;letter-spacing:.06em;width:110px;flex-shrink:0;">Profile name</span>
<input id="vv-rp-name" type="text"
style="background:#0d0d0d;border:1px solid #2a2a2a;border-radius:3px;color:#ccc;
font-size:12px;padding:4px 8px;font-family:monospace;outline:none;width:200px;"
placeholder="e.g. my-app">
<span id="vv-rp-name-hint" style="font-size:10px;color:#333;"></span>
</div>
<hr class="vv-ry-sep">
<!-- Rsync opts — checkboxes + preview -->
<div style="margin-bottom:10px;">
<div style="font-size:10px;font-weight:700;color:#555;text-transform:uppercase;letter-spacing:.06em;margin-bottom:8px;">
Rsync opts
<span style="font-size:9px;font-weight:normal;color:#2a2a2a;margin-left:6px;">does NOT inherit defaults — list every desired flag</span>
</div>
<div style="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:8px;" id="vv-rp-flag-group">
<label class="vv-rp-flag"><input type="checkbox" data-flag="-av" onchange="vvRpBuildOpts()"><span>-av</span></label>
<label class="vv-rp-flag"><input type="checkbox" data-flag="--human-readable" onchange="vvRpBuildOpts()"><span>--human-readable</span></label>
<label class="vv-rp-flag"><input type="checkbox" data-flag="--info=progress2" onchange="vvRpBuildOpts()"><span>--info=progress2</span></label>
<span style="border-left:1px solid #1e1e1e;margin:0 4px;"></span>
<label class="vv-rp-flag"><input type="checkbox" data-flag="--delete" onchange="vvRpBuildOpts()"><span>--delete</span></label>
<label class="vv-rp-flag"><input type="checkbox" data-flag="--inplace" onchange="vvRpBuildOpts()"><span>--inplace</span></label>
<label class="vv-rp-flag"><input type="checkbox" data-flag="--no-whole-file" onchange="vvRpBuildOpts()"><span>--no-whole-file</span></label>
<label class="vv-rp-flag"><input type="checkbox" data-flag="--checksum" onchange="vvRpBuildOpts()"><span>--checksum</span></label>
<span style="border-left:1px solid #1e1e1e;margin:0 4px;"></span>
<label class="vv-rp-flag"><input type="checkbox" data-flag="--bwlimit=$BW_LIMIT" onchange="vvRpBuildOpts()"><span>--bwlimit=<em>$BW_LIMIT</em></span></label>
<label class="vv-rp-flag"><input type="checkbox" data-flag="--compress" onchange="vvRpBuildOpts()"><span>--compress</span></label>
</div>
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;">
<span style="font-size:10px;color:#444;flex-shrink:0;">Extra flags</span>
<input id="vv-rp-opts-extra" type="text"
style="background:#0d0d0d;border:1px solid #1e1e1e;border-radius:3px;color:#888;
font-size:11px;padding:3px 7px;font-family:monospace;outline:none;flex:1;"
placeholder="any additional flags"
oninput="vvRpBuildOpts()">
</div>
<div style="background:#080808;border:1px solid #181818;border-radius:3px;padding:5px 9px;
font-family:monospace;font-size:11px;color:#4a6a4a;display:flex;align-items:baseline;gap:8px;">
<span style="font-size:9px;color:#2a2a2a;flex-shrink:0;">rsync_opts =</span>
<span id="vv-rp-opts-preview" style="word-break:break-all;"></span>
</div>
<input type="hidden" id="vv-rp-rsync_opts">
</div>
<hr class="vv-ry-sep">
<!-- Numeric fields -->
<div style="display:flex;gap:12px;flex-wrap:wrap;margin-bottom:10px;">
<div>
<div style="font-size:9px;color:#444;margin-bottom:3px;">BW limit <span style="color:#2a2a2a;">KB/s</span></div>
<input class="vv-ry-set-inp" id="vv-rp-bw_limit" type="number" min="0" style="width:80px;">
</div>
<div>
<div style="font-size:9px;color:#444;margin-bottom:3px;">Retry count</div>
<input class="vv-ry-set-inp" id="vv-rp-retry_count" type="number" min="0" max="20" style="width:60px;">
</div>
<div>
<div style="font-size:9px;color:#444;margin-bottom:3px;">Retry sleep <span style="color:#2a2a2a;">s</span></div>
<input class="vv-ry-set-inp" id="vv-rp-sleep" type="number" min="0" style="width:70px;">
</div>
<div>
<div style="font-size:9px;color:#444;margin-bottom:3px;">Container delay <span style="color:#2a2a2a;">s</span></div>
<input class="vv-ry-set-inp" id="vv-rp-container_delay" type="number" min="0" style="width:70px;">
</div>
</div>
<hr class="vv-ry-sep">
<!-- Container / path fields -->
<div style="display:flex;flex-direction:column;gap:8px;">
<div>
<div style="font-size:9px;color:#444;margin-bottom:3px;">Stop containers <span style="color:#2a2a2a;">space-separated — stopped on both hosts before rsync, restarted after</span></div>
<input class="vv-ry-set-inp" id="vv-rp-critical_containers" type="text" style="width:100%;box-sizing:border-box;font-family:monospace;">
</div>
<div>
<div style="font-size:9px;color:#444;margin-bottom:3px;">Delayed containers <span style="color:#2a2a2a;">start after delay (e.g. app waits for its database)</span></div>
<input class="vv-ry-set-inp" id="vv-rp-delayed_containers" type="text" style="width:100%;box-sizing:border-box;font-family:monospace;">
</div>
<div>
<div style="font-size:9px;color:#444;margin-bottom:3px;">Exclude dirs <span style="color:#2a2a2a;">space-separated globs — e.g. <code>logs *.tmp *.db-wal *.db-shm</code></span></div>
<input class="vv-ry-set-inp" id="vv-rp-exclude_dirs" type="text" style="width:100%;box-sizing:border-box;font-family:monospace;">
</div>
<div>
<div style="font-size:9px;color:#444;margin-bottom:3px;">Remote restart <span style="color:#2a2a2a;">dirty-sync only — restarted on remote after sync so it picks up changes</span></div>
<input class="vv-ry-set-inp" id="vv-rp-remote_restart" type="text" style="width:100%;box-sizing:border-box;font-family:monospace;">
</div>
</div>
<div style="margin-top:14px;display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
<button class="vv-ry-save-btn" onclick="vvRpSave()">Save Profile</button>
<button id="vv-rp-del-btn" onclick="vvRpDelete()"
style="background:#1a0a0a;border:1px solid #3a1a1a;color:#ef5350;font-size:11px;
padding:4px 12px;border-radius:3px;cursor:pointer;display:none;">Delete</button>
<span id="vv-rp-fb" class="vv-ry-feedback"></span>
</div>
</div>
</div>
</div><!-- /profile view -->
</div><!-- /layer -->
<script>
// Shared state — globals so IIFE internals and onclick handlers can both reach them
var _vvRyWinOpen = new Set(); // which window panels are expanded
var _vvRyLastData = null; // last successful poll response
var _vvRyEditMode = false; // true = skip poll re-renders
var _vvRyWinEditing = null; // key of window currently in edit mode
var _vvRyWinEState = {}; // {key: {scripts:[{id,enabled}], shares:[{path,profile}]}}
var _vvRyScriptLib = null; // cached {Folder:[{id,label}]} — null = not yet loaded
var _vvRyProfiles = null; // cached profile name list
// View-mode panel inner HTML — shared by _windowsRow (inside IIFE) and vvRyWinCancel (outside)
function _vvRyViewPanelInner(key, cfg) {
const scripts = cfg.scripts || [];
const shares = cfg.shares || [];
let inner = '';
if (scripts.length) {
inner += `<div style="font-size:9px;font-weight:700;color:#2a2a2a;text-transform:uppercase;letter-spacing:.07em;margin-bottom:4px;">Scripts</div>`;
for (const s of scripts) {
const parts = s.split('/');
const folder = parts.length > 1 ? parts.slice(0, -1).join('/') : '';
const name = parts[parts.length - 1].replace(/\.sh$/, '');
inner += `<div style="font-size:10px;color:#555;padding:1px 0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">
${folder ? `<span style="color:#2a2a2a;">${folder}/</span>` : ''}<span>${name}</span>
</div>`;
}
}
if (shares.length) {
if (scripts.length) inner += `<div style="border-top:1px solid #161616;margin:6px 0;"></div>`;
inner += `<div style="font-size:9px;font-weight:700;color:#2a2a2a;text-transform:uppercase;letter-spacing:.07em;margin-bottom:4px;">Sync shares</div>`;
for (const sh of shares) {
const [rawPath, profile] = sh.split('|');
const basename = rawPath.trim().split('/').pop();
inner += `<div style="font-size:10px;color:#555;padding:1px 0;display:flex;align-items:center;gap:5px;min-width:0;">
<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;" title="${rawPath.trim()}">${basename}</span>
${profile ? `<span style="font-size:9px;color:#2a3a2a;flex-shrink:0;font-family:monospace;">${profile.trim()}</span>` : ''}
</div>`;
}
}
if (scripts.length || shares.length) {
inner += `<div style="margin-top:7px;padding-top:6px;border-top:1px solid #161616;">
<button onclick="event.stopPropagation();vvRyWinEdit('${key}')"
style="background:#0a1a2a;border:1px solid #1a3a5a;color:#4a9eff;font-size:10px;
padding:2px 10px;border-radius:3px;cursor:pointer;">⚙ Edit</button>
</div>`;
}
return inner;
}
(function() {
const WIN_META = {
critical: { label: 'Critical', cadence: '30 min' },
intermediate: { label: 'Intermediate', cadence: '4 hr' },
daily: { label: 'Daily', cadence: 'nightly' },
weekly: { label: 'Weekly', cadence: 'weekly' },
fallback: { label: 'Fallback', cadence: 'on handback' },
};
const PROF_COLORS = [
'#4caf50','#4a9eff','#ffb74d','#ce93d8','#ef5350',
'#4dd0e1','#a5d6a7','#ff8a65','#90caf9','#f48fb1',
];
function _dur(s) {
if (!s) return '—';
const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), sec = s % 60;
if (h) return h + 'h ' + m + 'm';
if (m) return m + 'm ' + sec + 's';
return sec + 's';
}
// ── Status + active card ──────────────────────────────────────────────────────
function _statusCard(data) {
const en = data.enabled;
const dotC = en ? '#4caf50' : '#ef5350';
const active = data.active || [];
let activeHtml;
if (active.length === 0) {
activeHtml = '<div style="color:#333;font-size:11px;margin-top:4px;">No transfers running</div>';
} else {
activeHtml = active.map(a =>
`<div style="display:flex;align-items:center;gap:7px;margin:3px 0;">
<span class="vv-ry-pill run">RUNNING</span>
<span style="font-size:12px;color:#bbb;">${a.profile}</span>
<span style="font-size:11px;color:#555;margin-left:auto;">${_dur(a.elapsed)}</span>
</div>`
).join('');
}
return `<div class="vv-ry-card" style="grid-column:span 4;">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
<span class="vv-ry-dot" style="background:${dotC}"></span>
<span style="font-size:13px;font-weight:bold;color:${en ? '#4caf50' : '#ef5350'};">
${en ? 'RSYNC ENABLED' : 'RSYNC DISABLED'}
</span>
<span class="vv-ry-pill ${en ? 'on' : 'err'}" style="margin-left:auto;">
${en ? 'global gate open' : 'all syncs blocked'}
</span>
</div>
<hr class="vv-ry-sep">
<div class="vv-ry-sec">Active transfers</div>
${activeHtml}
</div>`;
}
// ── Last sync summary card ────────────────────────────────────────────────────
function _lastSyncCard(data) {
const ls = data.last_sync || {};
const wins = ['critical','intermediate','daily','weekly'];
const rows = wins.map(k => {
const e = ls[k];
if (!e || !e.ts) return `<div class="vv-ry-row">
<span class="vv-ry-lbl">${WIN_META[k]?.label || k}</span>
<span class="vv-ry-val" style="color:#333;">never</span>
</div>`;
const cls = e.status === 'success' ? 'color:#4caf50' : 'color:#ef5350';
return `<div class="vv-ry-row">
<span class="vv-ry-lbl">${WIN_META[k]?.label || k}</span>
<span class="vv-ry-val">
<span style="${cls}">${e.status}</span>
<span style="color:#444;margin-left:6px;">${_relTime(e.ts)}</span>
${e.duration ? `<span style="color:#3a3a3a;margin-left:4px;">${_dur(e.duration)}</span>` : ''}
</span>
</div>`;
});
return `<div class="vv-ry-card" style="grid-column:span 4;">
<div class="vv-ry-sec">Last completed run</div>
${rows.join('')}
</div>`;
}
// ── Window cards row ──────────────────────────────────────────────────────────
function _windowsRow(data) {
const windows = data.windows || {};
const lastSync = data.last_sync || {};
const winArr = data.win_arrays || {};
const cards = Object.entries(WIN_META).map(([key, meta]) => {
const enabled = windows[key] !== false;
const ls = lastSync[key];
const cls = enabled ? 'enabled' : 'disabled';
const cfg = winArr[key] || {};
const scripts = cfg.scripts || [];
const shares = cfg.shares || [];
let statusHtml = '<div class="vv-ry-win-stat">No run history</div>';
if (ls && ls.ts) {
const okCol = ls.status === 'success' ? '#4caf50' : '#ef5350';
statusHtml = `<div class="vv-ry-win-stat" style="color:${okCol};">${ls.status}</div>
<div class="vv-ry-win-dur">${_relTime(ls.ts)}${ls.duration ? ' · ' + _dur(ls.duration) : ''}</div>`;
}
const expandable = !!(scripts.length || shares.length);
const panelHtml = expandable
? `<div class="vv-ry-win-panel" style="display:none;margin-top:9px;padding-top:8px;border-top:1px solid #1e1e1e;">${_vvRyViewPanelInner(key, cfg)}</div>`
: '';
return `<div class="vv-ry-win ${cls}" data-win-key="${key}"
${expandable ? 'onclick="vvRyWinExpand(this)" style="cursor:pointer;"' : ''}>
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:4px;">
<div style="min-width:0;">
<div class="vv-ry-win-lbl">${meta.cadence}</div>
<div class="vv-ry-win-name">${meta.label}</div>
<span class="vv-ry-pill ${enabled ? 'on' : 'off'}">${enabled ? 'enabled' : 'disabled'}</span>
${statusHtml}
</div>
${expandable ? `<span class="vv-ry-win-chev" style="font-size:9px;color:#2a2a2a;flex-shrink:0;padding-top:2px;"></span>` : ''}
</div>
${panelHtml}
</div>`;
});
return `<div class="vv-ry-wins">${cards.join('')}</div>`;
}
// ── Bandwidth section ─────────────────────────────────────────────────────────
function _bwSection(data) {
const history = data.bw_history || [];
if (!history.length) {
return `<div class="vv-ry-card" style="grid-column:1/-1;">
<div class="vv-ry-sec">Bandwidth history</div>
<div style="color:#333;font-size:11px;padding:8px 0;">No data in last 30 days</div>
</div>`;
}
// Per-profile stats
const profileMap = {};
const profileOrder = [];
for (const r of history) {
if (!profileMap[r.profile]) {
profileMap[r.profile] = { runs: 0, success: 0, totalDur: 0, totalBytes: 0 };
profileOrder.push(r.profile);
}
const p = profileMap[r.profile];
p.runs++;
if (r.status === 'success') p.success++;
p.totalDur += r.duration;
p.totalBytes += r.bytes;
}
const maxDur = Math.max(...Object.values(profileMap).map(p => p.totalDur), 1);
const profRows = profileOrder.map((name, i) => {
const p = profileMap[name];
const col = PROF_COLORS[i % PROF_COLORS.length];
const pct = Math.round((p.totalDur / maxDur) * 100);
const rate = Math.round((p.success / p.runs) * 100);
const byteStr = p.totalBytes > 0 ? _fmtBytes(p.totalBytes) : _dur(p.totalDur) + ' total';
return `<div class="vv-ry-prof-row">
<span class="vv-ry-prof-nm">${name}</span>
<div class="vv-ry-prof-bar"><div class="vv-ry-prof-fill" style="width:${pct}%;background:${col}"></div></div>
<span class="vv-ry-prof-cnt">${p.runs} runs · ${byteStr}</span>
</div>`;
});
// Recent runs (last 20, newest first)
const recent = [...history].reverse().slice(0, 20);
const runRows = recent.map(r => {
const stCls = r.status === 'success' ? 'on' : 'err';
const durStr = r.duration >= 3600
? Math.floor(r.duration / 3600) + 'h ' + Math.floor((r.duration % 3600) / 60) + 'm'
: r.duration >= 60
? Math.floor(r.duration / 60) + 'm'
: r.duration + 's';
return `<div class="vv-ry-run-row">
<span class="vv-ry-run-dt">${r.date}</span>
<span class="vv-ry-run-tm">${r.time}</span>
<span class="vv-ry-run-pr">${r.profile}</span>
<span class="vv-ry-run-dur">${durStr}</span>
<span class="vv-ry-run-st"><span class="vv-ry-pill ${stCls}" style="font-size:9px;">${r.status}</span></span>
</div>`;
});
return `<div class="vv-ry-bw" style="grid-column:1/-1;">
<div class="vv-ry-card">
<div class="vv-ry-sec">Profile activity — last 30 days</div>
${profRows.join('')}
</div>
<div class="vv-ry-card">
<div class="vv-ry-sec">Recent runs</div>
<div style="font-size:10px;color:#3a3a3a;margin-bottom:4px;display:grid;grid-template-columns:80px 90px 1fr 55px 48px;gap:4px;">
<span>Date</span><span>Time</span><span>Profile</span><span style="text-align:right;">Duration</span><span></span>
</div>
<div class="vv-ry-runs-scroll">${runRows.join('')}</div>
</div>
</div>`;
}
// ── Sync log card ─────────────────────────────────────────────────────────────
function _esc(s) {
return (s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
let _liveTicker = null;
let _fastPollTimer = null;
function _syncLogCard(data) {
const active = data.active || [];
const history = data.bw_history || [];
const recent = [...history].reverse().slice(0, 14);
const now = Math.floor(Date.now() / 1000);
const activeRows = active.map(a => {
const startedTs = now - (a.elapsed || 0);
return `<div class="vv-ry-slog-row" style="background:#0a1a0a;border-radius:3px;margin-bottom:4px;padding:4px 6px;border:none;">
<span class="vv-ry-pill run">LIVE</span>
<span class="vv-ry-slog-name" style="color:#ccc;">${_esc(a.profile)}</span>
<span class="vv-ry-elapsed" data-started="${startedTs}"
style="font-size:11px;color:#4a9eff;font-family:monospace;grid-column:3/span 3;text-align:right;">${_dur(a.elapsed || 0)}</span>
</div>`;
}).join('');
const histRows = recent.map(r => {
const cls = r.status === 'success' ? 'on' : 'err';
const bytes = r.bytes > 0 ? _fmtBytes(r.bytes) : '';
return `<div class="vv-ry-slog-row">
<span class="vv-ry-pill ${cls}" style="font-size:9px;">${r.status === 'success' ? 'ok' : 'fail'}</span>
<span class="vv-ry-slog-name">${_esc(r.profile)}</span>
<span class="vv-ry-slog-dt">${r.date} ${r.time.slice(0,5)}</span>
<span class="vv-ry-slog-dur">${_dur(r.duration)}</span>
<span class="vv-ry-slog-byt">${bytes}</span>
</div>`;
}).join('');
return `<div class="vv-ry-card" style="grid-column:span 5;display:flex;flex-direction:column;min-height:0;">
<div class="vv-ry-sec">Sync Activity</div>
${activeRows}
${activeRows && histRows ? '<hr class="vv-ry-sep">' : ''}
<div class="vv-ry-slog-scrl">
${histRows || '<div style="color:#333;font-size:11px;padding:6px 0;">No sync history</div>'}
</div>
</div>`;
}
// ── Settings section ──────────────────────────────────────────────────────────
function _toggle(name, currentVal, label) {
const id = 'vv-ry-tog-' + name;
return `<label class="vv-ry-toggle" title="${name}">
<div class="vv-ry-toggle-track ${currentVal ? 'on' : ''}" id="${id}"
onclick="vvRyToggle(this,'${name}')"></div>
<span class="vv-ry-toggle-lbl">${label}</span>
</label>`;
}
function _settingsSection(data) {
const w = data.windows || {};
const s = data.settings || {};
const en = data.enabled;
const mbit = s.bw_limit ? ' (~' + (s.bw_limit / 125).toFixed(0) + ' Mbit/s)' : '';
return `<div class="vv-ry-card" style="grid-column:6/-1;">
<div class="vv-ry-sec" style="margin-bottom:10px;">Settings</div>
<div style="margin-bottom:12px;">
<div style="font-size:10px;color:#444;letter-spacing:.05em;text-transform:uppercase;margin-bottom:6px;">Global gate</div>
${_toggle('RSYNC_ENABLED', en, 'RSYNC_ENABLED — blocks all syncs when off')}
</div>
<hr class="vv-ry-sep">
<div style="margin-bottom:12px;">
<div style="font-size:10px;color:#444;letter-spacing:.05em;text-transform:uppercase;margin-bottom:6px;">Sync windows</div>
<div class="vv-ry-set-grid">
${_toggle('CRITICAL_RSYNC_ENABLED', w.critical, 'Critical')}
${_toggle('INTERMEDIATE_RSYNC_ENABLED', w.intermediate, 'Intermediate')}
${_toggle('DAILY_RSYNC_ENABLED', w.daily, 'Daily')}
${_toggle('WEEKLY_RSYNC_ENABLED', w.weekly, 'Weekly')}
${_toggle('FALLBACK_RSYNC_ENABLED', w.fallback, 'Fallback')}
</div>
</div>
<hr class="vv-ry-sep">
<div>
<div style="font-size:10px;color:#444;letter-spacing:.05em;text-transform:uppercase;margin-bottom:8px;">Defaults</div>
<div class="vv-ry-set-row">
<span class="vv-ry-set-lbl">Bandwidth limit</span>
<input class="vv-ry-set-inp" id="vv-ry-bwlimit" type="number" min="0" value="${s.bw_limit || 0}">
<span class="vv-ry-set-hint">KB/s${mbit}</span>
</div>
<div class="vv-ry-set-row">
<span class="vv-ry-set-lbl">Retry count</span>
<input class="vv-ry-set-inp" id="vv-ry-retry" type="number" min="0" max="20" value="${s.retry_count ?? 3}">
<span class="vv-ry-set-hint">attempts</span>
</div>
<div class="vv-ry-set-row">
<span class="vv-ry-set-lbl">Retry delay</span>
<input class="vv-ry-set-inp" id="vv-ry-sleep" type="number" min="0" value="${s.sleep ?? 300}">
<span class="vv-ry-set-hint">seconds between attempts</span>
</div>
<div class="vv-ry-set-row">
<span class="vv-ry-set-lbl">BW warn threshold</span>
<input class="vv-ry-set-inp" id="vv-ry-bwwarn" type="number" min="0" value="${s.bw_warn_gb ?? 50}">
<span class="vv-ry-set-hint">GB flag large transfers in weekly report</span>
</div>
<div style="margin-top:10px;display:flex;align-items:center;">
<button class="vv-ry-save-btn" id="vv-ry-save" onclick="vvRySaveDefaults()">Save</button>
<span class="vv-ry-feedback" id="vv-ry-save-fb"></span>
</div>
</div>
</div>`;
}
// ── Main render ───────────────────────────────────────────────────────────────
function _render(data) {
_vvRyLastData = data;
if (_vvRyEditMode) return; // don't clobber the edit UI during a poll
let html = '';
html += _statusCard(data);
html += _lastSyncCard(data);
html += _windowsRow(data);
html += _bwSection(data);
html += _syncLogCard(data);
html += _settingsSection(data);
document.getElementById('vv-ry-grid').innerHTML = html;
// Live elapsed ticker — updates every second without re-fetching
if (_liveTicker) { clearInterval(_liveTicker); _liveTicker = null; }
if ((data.active || []).length) {
_liveTicker = setInterval(() => {
document.querySelectorAll('.vv-ry-elapsed').forEach(el => {
const s = parseInt(el.dataset.started, 10);
if (s) el.textContent = _dur(Math.floor(Date.now() / 1000) - s);
});
}, 1000);
}
// Fast-poll (5s) while syncs are active; return to 30s when idle
const hasActive = (data.active || []).length > 0;
if (hasActive && !_fastPollTimer) {
_fastPollTimer = setInterval(vvRyLoad, 5000);
} else if (!hasActive && _fastPollTimer) {
clearInterval(_fastPollTimer);
_fastPollTimer = null;
}
// Restore any window panels that were open before the re-render
_vvRyWinOpen.forEach(key => {
const card = document.querySelector(`.vv-ry-win[data-win-key="${key}"]`);
if (!card) return;
const panel = card.querySelector('.vv-ry-win-panel');
const chev = card.querySelector('.vv-ry-win-chev');
if (panel) panel.style.display = '';
if (chev) chev.textContent = '▾';
});
const ts = data.ts
? new Date(data.ts * 1000).toLocaleString([], {
month:'numeric', day:'numeric', year:'numeric',
hour:'2-digit', minute:'2-digit', second:'2-digit'})
: '';
document.getElementById('vv-ry-ts').textContent = ts ? 'Updated: ' + ts : '';
}
function vvRyLoad() {
fetch('/plugins/varaverk/api/rsync.php')
.then(r => r.json())
.then(_render)
.catch(() => {});
}
vvRyLoad();
setInterval(vvRyLoad, 30000);
})();
// ── Window card expand/collapse ───────────────────────────────────────────────
function vvRyWinExpand(card) {
const key = card.dataset.winKey;
const panel = card.querySelector('.vv-ry-win-panel');
const chev = card.querySelector('.vv-ry-win-chev');
if (!panel) return;
const open = panel.style.display !== 'none';
panel.style.display = open ? 'none' : '';
if (chev) chev.textContent = open ? '▸' : '▾';
if (key) open ? _vvRyWinOpen.delete(key) : _vvRyWinOpen.add(key);
}
// ── Window card edit ──────────────────────────────────────────────────────────
function vvRyWinEdit(key) {
const cfg = (_vvRyLastData?.win_arrays || {})[key] || {};
_vvRyWinEState[key] = {
scripts: (cfg.scripts || []).map(s => ({id: s, enabled: true})),
shares: (cfg.shares || []).map(s => {
const [p, pr] = s.split('|');
return {path: p.trim(), profile: (pr || '').trim()};
}),
};
_vvRyEditMode = true;
_vvRyWinEditing = key;
const go = () => _vvRyBuildEditPanel(key);
if (!_vvRyScriptLib) {
fetch('/plugins/varaverk/api/rsync_win_arrays.php?action=list_scripts&_=' + Date.now())
.then(r => r.json()).then(d => { if (d.ok) _vvRyScriptLib = d.groups; go(); }).catch(go);
} else { go(); }
}
function vvRyWinCancel(key) {
_vvRyEditMode = false;
_vvRyWinEditing = null;
const cfg = (_vvRyLastData?.win_arrays || {})[key] || {};
const panel = document.querySelector(`.vv-ry-win[data-win-key="${key}"] .vv-ry-win-panel`);
if (panel) { panel.innerHTML = _vvRyViewPanelInner(key, cfg); panel.style.display = ''; }
const chev = document.querySelector(`.vv-ry-win[data-win-key="${key}"] .vv-ry-win-chev`);
if (chev) chev.textContent = '▾';
}
function vvRyWinSave(key) {
const state = _vvRyWinEState[key];
if (!state) return;
const btn = document.getElementById('vv-ry-esave-' + key);
const fb = document.getElementById('vv-ry-efb-' + key);
if (btn) { btn.disabled = true; btn.textContent = 'Saving…'; }
if (fb) fb.textContent = '';
const fd = new FormData();
fd.append('action', 'save');
fd.append('win_key', key);
fd.append('scripts', JSON.stringify(state.scripts));
fd.append('shares', JSON.stringify(state.shares));
fetch('/plugins/varaverk/api/rsync_win_arrays.php', {method:'POST', body:fd})
.then(r => r.json())
.then(d => {
if (d.ok) {
// Update cached data so cancel/re-render shows the new values
if (_vvRyLastData) {
_vvRyLastData.win_arrays ??= {};
_vvRyLastData.win_arrays[key] = {
scripts: state.scripts.map(s => s.id),
shares: state.shares.map(s => s.profile ? `${s.path}|${s.profile}` : s.path),
};
}
vvRyWinCancel(key);
} else {
if (btn) { btn.disabled = false; btn.textContent = 'Save changes'; }
if (fb) { fb.style.color = '#ef5350'; fb.textContent = (d.errors || ['Save failed']).join(', '); }
}
})
.catch(e => {
if (btn) { btn.disabled = false; btn.textContent = 'Save changes'; }
if (fb) { fb.style.color = '#ef5350'; fb.textContent = 'Request failed'; }
});
}
// ── Edit panel builder ────────────────────────────────────────────────────────
function _vvRyBuildEditPanel(key) {
const panel = document.querySelector(`.vv-ry-win[data-win-key="${key}"] .vv-ry-win-panel`);
if (!panel) return;
panel.style.display = '';
const state = _vvRyWinEState[key] || {scripts:[], shares:[]};
const scripts = state.scripts;
const shares = state.shares;
let h = '';
// ── Scripts list ─────────────────────────────────────────────────────────────
h += `<div style="font-size:9px;font-weight:700;color:#2a2a2a;text-transform:uppercase;letter-spacing:.07em;margin-bottom:5px;">Scripts</div>`;
for (let i = 0; i < scripts.length; i++) {
const s = scripts[i];
const parts = s.id.split('/');
const dir = parts.length > 1 ? parts.slice(0,-1).join('/') : '';
const name = parts[parts.length-1].replace(/\.sh$/,'');
h += `<div style="display:flex;align-items:center;gap:3px;padding:2px 0;border-bottom:1px solid #111;">
<button onclick="event.stopPropagation();vvRyERemScript('${key}',${i})"
style="background:none;border:none;color:#4a1a1a;cursor:pointer;padding:0 3px;font-size:13px;line-height:1;flex-shrink:0;">×</button>
<div style="display:flex;flex-direction:column;flex-shrink:0;">
<button onclick="event.stopPropagation();vvRyEMvScript('${key}',${i},-1)" ${i===0?'disabled':''}
style="background:none;border:none;color:${i===0?'#1e1e1e':'#3a5a3a'};cursor:${i===0?'default':'pointer'};padding:0;font-size:8px;line-height:1.2;"></button>
<button onclick="event.stopPropagation();vvRyEMvScript('${key}',${i},1)" ${i===scripts.length-1?'disabled':''}
style="background:none;border:none;color:${i===scripts.length-1?'#1e1e1e':'#3a5a3a'};cursor:${i===scripts.length-1?'default':'pointer'};padding:0;font-size:8px;line-height:1.2;"></button>
</div>
<span style="font-size:10px;color:#666;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;">
${dir?`<span style="color:#2a2a2a;">${dir}/</span>`:''}${name}
</span>
</div>`;
}
// Script add row
const existIds = new Set(scripts.map(s => s.id));
h += `<div style="display:flex;gap:4px;margin-top:5px;align-items:center;" onclick="event.stopPropagation()">
<select id="vv-ry-esel-${key}"
style="background:#0d0d0d;border:1px solid #1e1e1e;border-radius:3px;color:#666;
font-size:10px;padding:3px 5px;flex:1;outline:none;min-width:0;">
<option value=""> add script </option>`;
if (_vvRyScriptLib) {
for (const [folder, items] of Object.entries(_vvRyScriptLib)) {
const avail = items.filter(it => !existIds.has(it.id));
if (!avail.length) continue;
h += `<optgroup label="${folder}">`;
for (const it of avail) h += `<option value="${it.id}">${it.label}</option>`;
h += `</optgroup>`;
}
}
h += `</select>
<button onclick="event.stopPropagation();vvRyEAddScript('${key}')"
style="background:#0a1a0a;border:1px solid #1a3a1a;color:#4caf50;font-size:10px;
padding:3px 9px;border-radius:3px;cursor:pointer;flex-shrink:0;">+ Add</button>
</div>`;
// ── Shares list ───────────────────────────────────────────────────────────────
h += `<div style="border-top:1px solid #161616;margin:8px 0;"></div>
<div style="font-size:9px;font-weight:700;color:#2a2a2a;text-transform:uppercase;letter-spacing:.07em;margin-bottom:5px;">Sync shares</div>`;
for (let i = 0; i < shares.length; i++) {
const sh = shares[i];
const bn = sh.path.split('/').pop();
h += `<div style="display:flex;align-items:center;gap:3px;padding:2px 0;border-bottom:1px solid #111;">
<button onclick="event.stopPropagation();vvRyERemShare('${key}',${i})"
style="background:none;border:none;color:#4a1a1a;cursor:pointer;padding:0 3px;font-size:13px;line-height:1;flex-shrink:0;">×</button>
<div style="display:flex;flex-direction:column;flex-shrink:0;">
<button onclick="event.stopPropagation();vvRyEMvShare('${key}',${i},-1)" ${i===0?'disabled':''}
style="background:none;border:none;color:${i===0?'#1e1e1e':'#3a5a3a'};cursor:${i===0?'default':'pointer'};padding:0;font-size:8px;line-height:1.2;"></button>
<button onclick="event.stopPropagation();vvRyEMvShare('${key}',${i},1)" ${i===shares.length-1?'disabled':''}
style="background:none;border:none;color:${i===shares.length-1?'#1e1e1e':'#3a5a3a'};cursor:${i===shares.length-1?'default':'pointer'};padding:0;font-size:8px;line-height:1.2;"></button>
</div>
<span style="font-size:10px;color:#666;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${sh.path}">${bn}</span>
${sh.profile?`<span style="font-size:9px;color:#2a3a2a;flex-shrink:0;font-family:monospace;">${sh.profile}</span>`:''}
</div>`;
}
// Share add form
h += `<div style="margin-top:6px;" onclick="event.stopPropagation()">
<div style="display:flex;gap:4px;margin-bottom:3px;">
<input id="vv-ry-epath-${key}" type="text" placeholder="/mnt/user/…" autocomplete="off"
style="background:#0d0d0d;border:1px solid #1e1e1e;border-radius:3px;color:#888;
font-size:10px;padding:3px 6px;flex:1;min-width:0;font-family:monospace;outline:none;"
onkeydown="if(event.key==='Enter')vvRyEBrowse('${key}')">
<button onclick="vvRyEBrowse('${key}')"
style="background:#0a1a2a;border:1px solid #1a3a5a;color:#4a9eff;font-size:10px;
padding:3px 8px;border-radius:3px;cursor:pointer;flex-shrink:0;white-space:nowrap;"> Browse</button>
</div>
<div id="vv-ry-ebrowse-${key}" class="vv-ms-dirs" style="display:none;max-height:120px;" data-last-path=""></div>
<div style="display:flex;gap:4px;margin-top:4px;align-items:center;">
<select id="vv-ry-eprof-${key}"
style="background:#0d0d0d;border:1px solid #1e1e1e;border-radius:3px;color:#666;
font-size:10px;padding:3px 5px;flex:1;min-width:0;outline:none;">
<option value="">no profile</option>
</select>
<button onclick="vvRyEAddShare('${key}')"
style="background:#0a1a0a;border:1px solid #1a3a1a;color:#4caf50;font-size:10px;
padding:3px 9px;border-radius:3px;cursor:pointer;flex-shrink:0;white-space:nowrap;">+ Add</button>
</div>
</div>`;
// ── Save / Cancel ─────────────────────────────────────────────────────────────
h += `<div style="display:flex;gap:6px;align-items:center;margin-top:10px;padding-top:8px;border-top:1px solid #1e1e1e;">
<button id="vv-ry-esave-${key}" onclick="event.stopPropagation();vvRyWinSave('${key}')"
style="background:#1a2a1a;border:1px solid #2d4a2d;color:#4caf50;font-size:11px;
padding:4px 14px;border-radius:3px;cursor:pointer;">Save changes</button>
<button onclick="event.stopPropagation();vvRyWinCancel('${key}')"
style="background:#111;border:1px solid #222;color:#555;font-size:11px;
padding:4px 12px;border-radius:3px;cursor:pointer;">Cancel</button>
<span id="vv-ry-efb-${key}" style="font-size:10px;color:#ef5350;"></span>
</div>`;
panel.innerHTML = h;
_vvRyLoadProfiles(key);
}
// ── Script mutations ──────────────────────────────────────────────────────────
function vvRyEMvScript(key, idx, dir) {
const s = _vvRyWinEState[key]?.scripts; if (!s) return;
const n = idx + dir; if (n < 0 || n >= s.length) return;
[s[idx], s[n]] = [s[n], s[idx]]; _vvRyBuildEditPanel(key);
}
function vvRyERemScript(key, idx) {
const s = _vvRyWinEState[key]?.scripts; if (!s) return;
s.splice(idx, 1); _vvRyBuildEditPanel(key);
}
function vvRyEAddScript(key) {
const sel = document.getElementById('vv-ry-esel-' + key);
if (!sel?.value) return;
const s = _vvRyWinEState[key]?.scripts; if (!s) return;
if (s.find(x => x.id === sel.value)) return;
s.push({id: sel.value, enabled: true}); _vvRyBuildEditPanel(key);
}
// ── Share mutations ───────────────────────────────────────────────────────────
function vvRyEMvShare(key, idx, dir) {
const s = _vvRyWinEState[key]?.shares; if (!s) return;
const n = idx + dir; if (n < 0 || n >= s.length) return;
[s[idx], s[n]] = [s[n], s[idx]]; _vvRyBuildEditPanel(key);
}
function vvRyERemShare(key, idx) {
const s = _vvRyWinEState[key]?.shares; if (!s) return;
s.splice(idx, 1); _vvRyBuildEditPanel(key);
}
function vvRyEAddShare(key) {
const path = document.getElementById('vv-ry-epath-' + key)?.value.trim();
const prof = document.getElementById('vv-ry-eprof-' + key)?.value.trim() || '';
if (!path || !path.startsWith('/')) return;
const s = _vvRyWinEState[key]?.shares; if (!s) return;
s.push({path, profile: prof});
document.getElementById('vv-ry-epath-' + key).value = '';
const dirsEl = document.getElementById('vv-ry-ebrowse-' + key);
if (dirsEl) dirsEl.style.display = 'none';
_vvRyBuildEditPanel(key);
}
// ── Share path browser ────────────────────────────────────────────────────────
function vvRyEBrowse(key) {
const pathEl = document.getElementById('vv-ry-epath-' + key);
const dirsEl = document.getElementById('vv-ry-ebrowse-' + key);
if (!pathEl || !dirsEl) return;
const path = pathEl.value.trim() || '/mnt/user';
if (dirsEl.dataset.lastPath === path && dirsEl.style.display !== 'none') {
dirsEl.style.display = 'none'; dirsEl.dataset.lastPath = ''; return;
}
dirsEl.innerHTML = '<div style="color:#444;font-size:10px;padding:4px;">Loading…</div>';
dirsEl.style.display = '';
fetch(`/plugins/varaverk/api/manual_sync.php?action=browse_local&path=${encodeURIComponent(path)}&_=${Date.now()}`)
.then(r => r.json())
.then(d => {
if (!d.ok) { dirsEl.innerHTML = `<div class="vv-ms-dir" style="color:#ef5350;cursor:default;">${d.error}</div>`; return; }
if (pathEl) pathEl.value = d.path;
dirsEl.dataset.lastPath = d.path;
let h = '';
if (d.parent) {
const pn = d.parent === '/' ? '/' : (d.parent.replace(/^.*\//,'') || d.parent) + '/';
h += `<div class="vv-ms-dir parent" onclick="vvRyENavTo('${key}',${JSON.stringify(d.parent)})"> ${pn}</div>`;
}
if (!d.dirs.length) {
h += '<div class="vv-ms-dir" style="color:#2a2a2a;cursor:default;">— empty —</div>';
} else {
for (const dir of d.dirs) {
const name = dir.replace(/^.*\//,'') || dir;
h += `<div class="vv-ms-dir" onclick="vvRyENavTo('${key}',${JSON.stringify(dir)})" title="${dir}"> ${name}</div>`;
}
}
dirsEl.innerHTML = h;
})
.catch(() => { dirsEl.innerHTML = '<div class="vv-ms-dir" style="color:#ef5350;cursor:default;">Browse failed</div>'; });
}
function vvRyENavTo(key, path) {
const pathEl = document.getElementById('vv-ry-epath-' + key);
if (pathEl) pathEl.value = path;
vvRyEBrowse(key);
}
// ── Profile dropdown loader ───────────────────────────────────────────────────
function _vvRyLoadProfiles(key) {
const sel = document.getElementById('vv-ry-eprof-' + key);
if (!sel) return;
const populate = () => {
sel.innerHTML = '<option value="">no profile</option>' +
(_vvRyProfiles || []).map(p => `<option value="${p}">${p}</option>`).join('');
};
if (_vvRyProfiles !== null) { populate(); return; }
fetch('/plugins/varaverk/api/rsync_win_arrays.php?action=list_profiles&_=' + Date.now())
.then(r => r.json())
.then(d => { _vvRyProfiles = d.ok ? (d.profiles || []) : []; populate(); })
.catch(() => { _vvRyProfiles = []; populate(); });
}
// ── Toggle a boolean flag — called by inline onclick ──────────────────────────
function vvRyToggle(trackEl, name) {
const on = !trackEl.classList.contains('on');
trackEl.classList.toggle('on', on);
const fd = new FormData();
fd.append('name', name);
fd.append('enabled', on ? '1' : '0');
fetch('/plugins/varaverk/api/flag_toggle.php', { method: 'POST', body: fd })
.then(r => r.json())
.then(d => { if (!d.ok) { trackEl.classList.toggle('on', !on); } })
.catch(() => { trackEl.classList.toggle('on', !on); });
}
// ── Save scalar defaults ──────────────────────────────────────────────────────
function vvRySaveDefaults() {
const btn = document.getElementById('vv-ry-save');
const fb = document.getElementById('vv-ry-save-fb');
const changes = [
{ file: 'master.conf', key: 'BW_LIMIT', value: document.getElementById('vv-ry-bwlimit').value, type: 'scalar' },
{ file: 'master.conf', key: 'RETRY_COUNT', value: document.getElementById('vv-ry-retry').value, type: 'scalar' },
{ file: 'master.conf', key: 'SLEEP', value: document.getElementById('vv-ry-sleep').value, type: 'scalar' },
{ file: 'master.conf', key: 'BANDWIDTH_WARN_GB', value: document.getElementById('vv-ry-bwwarn').value, type: 'scalar' },
];
btn.disabled = true;
btn.textContent = 'Saving…';
fb.textContent = '';
const fd = new FormData();
fd.append('id', 'rsync');
fd.append('changes', JSON.stringify(changes));
fetch('/plugins/varaverk/api/confform.php', { method: 'POST', body: fd })
.then(r => r.json())
.then(d => {
btn.disabled = false;
btn.textContent = 'Save';
fb.style.color = d.ok ? '#4caf50' : '#ef5350';
fb.textContent = d.ok ? 'Saved ✓' : ('Error: ' + (d.error || 'unknown'));
if (d.ok) setTimeout(() => { fb.textContent = ''; }, 3000);
})
.catch(() => {
btn.disabled = false;
btn.textContent = 'Save';
fb.style.color = '#ef5350';
fb.textContent = 'Request failed';
});
}
// ── Profile Editor ────────────────────────────────────────────────────────────
let _vvRpProfiles = {};
let _vvRpSelected = null;
let _vvRpIsNew = false;
let _vvRpLoaded = false;
const RP_FIELDS = ['bw_limit','retry_count','sleep','container_delay',
'critical_containers','delayed_containers','exclude_dirs','remote_restart'];
// Known checkbox flags in order
const RP_FLAG_ORDER = ['-av','--human-readable','--info=progress2',
'--delete','--inplace','--no-whole-file','--checksum',
'--bwlimit=$BW_LIMIT','--compress'];
function vvRpOpen() {
document.getElementById('vv-ry-info-view').style.display = 'none';
document.getElementById('vv-rp-view').style.display = '';
if (!_vvRpLoaded) vvRpLoad();
}
function vvRpClose() {
document.getElementById('vv-rp-view').style.display = 'none';
document.getElementById('vv-ry-info-view').style.display = '';
}
function vvRyInfoToggle(hdr) {
const body = hdr.nextElementSibling;
const chev = hdr.querySelector('.vv-ry-info-chev');
const open = body.style.display !== 'none';
body.style.display = open ? 'none' : '';
if (chev) chev.textContent = open ? '▸' : '▾';
}
// Build opts string from checkboxes + extra, update preview + hidden input
function vvRpBuildOpts() {
const checked = Array.from(document.querySelectorAll('#vv-rp-flag-group input[type=checkbox]:checked'))
.map(c => c.dataset.flag);
const extra = (document.getElementById('vv-rp-opts-extra')?.value || '').trim();
const parts = [...checked, ...(extra ? [extra] : [])];
const val = parts.join(' ');
const preview = document.getElementById('vv-rp-opts-preview');
const hidden = document.getElementById('vv-rp-rsync_opts');
if (preview) preview.textContent = val || '(none)';
if (hidden) hidden.value = val;
}
// Parse opts string → set checkboxes + extra input
function _vvRpApplyOpts(opts) {
const tokens = (opts || '').trim().split(/\s+/).filter(Boolean);
const knownSet = new Set(RP_FLAG_ORDER);
const extra = [];
document.querySelectorAll('#vv-rp-flag-group input[type=checkbox]').forEach(cb => {
cb.checked = tokens.includes(cb.dataset.flag);
});
for (const t of tokens) {
if (!knownSet.has(t)) extra.push(t);
}
const extraEl = document.getElementById('vv-rp-opts-extra');
if (extraEl) extraEl.value = extra.join(' ');
vvRpBuildOpts();
}
function vvRpLoad() {
fetch('/plugins/varaverk/api/rsync_profiles.php?action=list&_=' + Date.now())
.then(r => r.json())
.then(d => {
if (!d.ok) return;
_vvRpProfiles = d.profiles || {};
_vvRpLoaded = true;
_vvRpPopulateSelector();
if (_vvRpSelected && _vvRpProfiles[_vvRpSelected]) {
_vvRpFillForm(_vvRpSelected);
} else if (_vvRpIsNew) {
_vvRpClearForm();
} else if (Object.keys(_vvRpProfiles).length) {
_vvRpSelected = Object.keys(_vvRpProfiles).sort()[0];
_vvRpFillForm(_vvRpSelected);
}
})
.catch(() => {});
}
function _vvRpPopulateSelector() {
const sel = document.getElementById('vv-rp-selector');
if (!sel) return;
const names = Object.keys(_vvRpProfiles).sort();
sel.innerHTML = (names.length ? '' : '<option value="">No profiles</option>')
+ names.map(n => `<option value="${n}"${n === _vvRpSelected ? ' selected' : ''}>${n}</option>`).join('');
}
function _vvRpFillForm(name) {
const p = _vvRpProfiles[name] || {};
document.getElementById('vv-rp-name').value = name;
document.getElementById('vv-rp-name').readOnly = true;
document.getElementById('vv-rp-name-hint').textContent = 'rename by creating a new profile with a different name';
_vvRpApplyOpts(p.rsync_opts || '');
RP_FIELDS.forEach(f => {
const el = document.getElementById('vv-rp-' + f);
if (el) el.value = p[f] ?? '';
});
document.getElementById('vv-rp-form').style.display = '';
document.getElementById('vv-rp-empty').style.display = 'none';
const del = document.getElementById('vv-rp-del-btn');
if (del) del.style.display = '';
const sel = document.getElementById('vv-rp-selector');
if (sel) sel.value = name;
}
function _vvRpClearForm() {
document.getElementById('vv-rp-name').value = '';
document.getElementById('vv-rp-name').readOnly = false;
document.getElementById('vv-rp-name-hint').textContent = 'new profile name';
// Default opts for new profile
_vvRpApplyOpts('-av --human-readable --bwlimit=$BW_LIMIT');
document.getElementById('vv-rp-bw_limit').value = '5000';
document.getElementById('vv-rp-retry_count').value = '3';
document.getElementById('vv-rp-sleep').value = '300';
document.getElementById('vv-rp-container_delay').value = '5';
RP_FIELDS.filter(f => !['bw_limit','retry_count','sleep','container_delay'].includes(f))
.forEach(f => { const el = document.getElementById('vv-rp-' + f); if (el) el.value = ''; });
document.getElementById('vv-rp-form').style.display = '';
document.getElementById('vv-rp-empty').style.display = 'none';
const del = document.getElementById('vv-rp-del-btn');
if (del) del.style.display = 'none';
const sel = document.getElementById('vv-rp-selector');
if (sel) sel.value = '';
}
function vvRpOnSelect(name) {
if (!name) return;
_vvRpSelected = name; _vvRpIsNew = false;
_vvRpFillForm(name);
document.getElementById('vv-rp-fb').textContent = '';
}
function vvRpNew() {
_vvRpSelected = null; _vvRpIsNew = true;
_vvRpClearForm();
document.getElementById('vv-rp-fb').textContent = '';
document.getElementById('vv-rp-name').focus();
}
function vvRpSave() {
const name = document.getElementById('vv-rp-name').value.trim();
if (!name) { document.getElementById('vv-rp-fb').style.color='#ef5350'; document.getElementById('vv-rp-fb').textContent='Enter a profile name'; return; }
const fb = document.getElementById('vv-rp-fb');
const btn = document.querySelector('#vv-rp-card .vv-ry-save-btn');
btn.disabled = true; btn.textContent = 'Saving…'; fb.textContent = '';
const fd = new FormData();
fd.append('action', 'save');
fd.append('name', name);
fd.append('rsync_opts', document.getElementById('vv-rp-rsync_opts')?.value?.trim() || '');
RP_FIELDS.forEach(f => {
const el = document.getElementById('vv-rp-' + f);
fd.append(f, el ? el.value.trim() : '');
});
fetch('/plugins/varaverk/api/rsync_profiles.php', { method: 'POST', body: fd })
.then(r => r.json())
.then(d => {
btn.disabled = false; btn.textContent = 'Save Profile';
if (d.ok) {
fb.style.color = '#4caf50';
fb.textContent = 'Saved ✓';
_vvRpSelected = name;
_vvRpIsNew = false;
vvRpLoad(); // refresh profile list
setTimeout(() => { fb.textContent = ''; }, 3000);
} else {
fb.style.color = '#ef5350';
fb.textContent = d.error || 'Save failed';
}
})
.catch(() => { btn.disabled = false; btn.textContent = 'Save Profile'; fb.style.color='#ef5350'; fb.textContent='Request failed'; });
}
function vvRpDelete() {
const name = document.getElementById('vv-rp-name').value.trim();
if (!name) return;
if (!confirm(`Delete profile "${name}"?\n\nThis removes it from all PROFILE_* arrays in master.conf.`)) return;
const fb = document.getElementById('vv-rp-fb');
const del = document.getElementById('vv-rp-del-btn');
del.disabled = true; fb.textContent = '';
const fd = new FormData();
fd.append('action', 'delete');
fd.append('name', name);
fetch('/plugins/varaverk/api/rsync_profiles.php', { method: 'POST', body: fd })
.then(r => r.json())
.then(d => {
del.disabled = false;
if (d.ok) {
_vvRpSelected = null;
_vvRpIsNew = false;
document.getElementById('vv-rp-form').style.display = 'none';
document.getElementById('vv-rp-empty').style.display = '';
fb.style.color = '#4caf50'; fb.textContent = 'Deleted ✓';
vvRpLoad();
setTimeout(() => { fb.textContent = ''; }, 3000);
} else {
fb.style.color = '#ef5350'; fb.textContent = d.error || 'Delete failed';
}
})
.catch(() => { del.disabled = false; fb.style.color='#ef5350'; fb.textContent='Request failed'; });
}
// ── Manual Sync ───────────────────────────────────────────────────────────────
let _vvMsHosts = [];
let _vvMsParent = null;
let _vvMsPollId = null;
let _vvMsSshKey = ''; // key path from host*.conf
function vvMsToggleOutput() {
const out = document.getElementById('vv-ms-out');
const btn = document.getElementById('vv-ms-show-btn');
const open = out.style.display !== 'none';
out.style.display = open ? 'none' : '';
btn.textContent = open ? '▾ Show Rsync' : '▴ Hide Rsync';
}
function vvMsStop() {
const token = document.getElementById('vv-ms-stop-btn').dataset.token;
if (!token) return;
const btn = document.getElementById('vv-ms-stop-btn');
btn.textContent = '⟳ Stopping…'; btn.disabled = true;
const fd = new FormData();
fd.append('action', 'stop');
fd.append('token', token);
fetch('/plugins/varaverk/api/manual_sync.php', { method: 'POST', body: fd })
.then(r => r.json())
.then(() => {})
.catch(() => {})
.finally(() => { btn.textContent = '■ Stop'; btn.disabled = false; });
}
function vvMsToggle() {
const body = document.getElementById('vv-ms-body');
const btn = document.getElementById('vv-ms-tog');
const open = body.style.display !== 'none';
body.style.display = open ? 'none' : '';
btn.textContent = open ? '▾ Show' : '▴ Hide';
if (!open) {
if (!_vvMsHosts.length) _vvMsLoadHosts();
else vvMsUpdatePreview();
}
}
function _vvMsLoadHosts() {
const sel = document.getElementById('vv-ms-host');
sel.innerHTML = '<option value="">Loading…</option>';
fetch('/plugins/varaverk/api/manual_sync.php?action=hosts&_=' + Date.now())
.then(r => r.json())
.then(d => {
if (!d.ok) { sel.innerHTML = '<option value="">Error loading hosts</option>'; return; }
_vvMsHosts = d.hosts;
_vvMsSshKey = d.ssh_key || '';
const keyEl = document.getElementById('vv-ms-key-info');
if (keyEl) keyEl.textContent = _vvMsSshKey ? _vvMsSshKey.replace(/^.*\//, '') : '(none configured)';
vvMsUpdatePreview();
if (!d.hosts.length) {
sel.innerHTML = '<option value="">No remote hosts configured</option>';
return;
}
sel.innerHTML = d.hosts.map(h => {
const dot = h.online === null ? '◌' : h.online ? '●' : '○';
const col = h.online === null ? '#444' : h.online ? '#4caf50' : '#ef5350';
return `<option value="${h.slot}" data-col="${col}">${dot} ${h.id}${h.hostname}</option>`;
}).join('');
// Auto-select first and auto-browse
vvMsHostChanged();
})
.catch(() => { sel.innerHTML = '<option value="">Failed to load</option>'; });
}
function vvMsHostChanged() {
const sel = document.getElementById('vv-ms-host');
const slot = sel.value;
const host = _vvMsHosts.find(h => h.slot === slot);
const stat = document.getElementById('vv-ms-host-status');
if (!host) { stat.textContent = ''; return; }
const onlineTxt = host.online === null ? 'status unknown'
: host.online ? '● online' + (host.ip ? ' · ' + host.ip : '')
: '○ offline';
const onlineCol = host.online === null ? '#333' : host.online ? '#4caf50' : '#ef5350';
stat.style.color = onlineCol;
stat.textContent = onlineTxt;
// Auto-browse /mnt/user on host change
const rpEl = document.getElementById('vv-ms-rpath');
if (!rpEl.value) rpEl.value = '/mnt/user';
vvMsBrowse();
}
function vvMsBrowse() {
const slot = document.getElementById('vv-ms-host').value;
const path = document.getElementById('vv-ms-rpath').value.trim() || '/mnt/user';
const browseBtn = document.getElementById('vv-ms-browse-btn');
const dirsEl = document.getElementById('vv-ms-dirs');
const errEl = document.getElementById('vv-ms-browse-err');
if (!slot) return;
// Toggle: already showing this path → collapse
if (dirsEl.style.display !== 'none' && browseBtn.dataset.lastPath === path) {
dirsEl.style.display = 'none';
errEl.style.display = 'none';
browseBtn.textContent = '⟳ Browse';
browseBtn.dataset.lastPath = '';
return;
}
browseBtn.textContent = '⟳';
browseBtn.disabled = true;
dirsEl.style.display = 'none';
errEl.style.display = 'none';
fetch(`/plugins/varaverk/api/manual_sync.php?action=browse&host=${encodeURIComponent(slot)}&path=${encodeURIComponent(path)}&_=${Date.now()}`)
.then(r => r.json())
.then(d => {
browseBtn.textContent = '⟳ Browse';
browseBtn.disabled = false;
if (!d.ok) {
errEl.textContent = d.error || 'Browse failed';
errEl.style.display = '';
return;
}
_vvMsParent = d.parent;
document.getElementById('vv-ms-rpath').value = d.path;
browseBtn.dataset.lastPath = d.path;
_vvMsRenderDirs('vv-ms-dirs', d.dirs, d.parent, 'vvMsNavTo');
})
.catch(e => {
browseBtn.textContent = '⟳ Browse';
browseBtn.disabled = false;
errEl.textContent = 'Request failed: ' + e;
errEl.style.display = '';
});
}
function vvMsUpdatePreview() {
const flags = Array.from(document.querySelectorAll('#vv-ms-card input[data-flag]:checked'))
.map(el => el.dataset.flag).join(' ');
const bw = parseInt(document.getElementById('vv-ms-bw')?.value) || 0;
const prev = document.getElementById('vv-ms-flags-preview');
const bwPrev= document.getElementById('vv-ms-bwlimit-preview');
if (prev) prev.textContent = flags || '(none)';
if (bwPrev) bwPrev.textContent = bw > 0 ? '--bwlimit=' + bw : '';
}
function _vvMsRenderDirs(dirsElId, dirs, parent, navFn) {
const dirsEl = document.getElementById(dirsElId);
if (!dirsEl) return;
let html = '';
if (parent !== null) {
const pname = parent === '/' ? '/' : (parent.replace(/^.*\//, '') || parent) + '/';
html += `<div class="vv-ms-dir parent" onclick="${navFn}(${JSON.stringify(parent)})">↑ ${pname}</div>`;
}
if (!dirs.length) {
html += '<div class="vv-ms-dir" style="color:#2a2a2a;cursor:default;">— no subdirectories —</div>';
} else {
for (const d of dirs) {
const name = d.replace(/^.*\//, '') || d;
html += `<div class="vv-ms-dir" onclick="${navFn}(${JSON.stringify(d)})" title="${d}">▶ ${name}</div>`;
}
}
dirsEl.innerHTML = html;
dirsEl.style.display = '';
}
// ── Remote browse ──────────────────────────────────────────────────────────────
function vvMsNavTo(path) {
document.getElementById('vv-ms-rpath').value = path;
vvMsBrowse();
}
function vvMsNavUp() {
const cur = (document.getElementById('vv-ms-rpath').value || '/mnt/user').replace(/\/$/, '') || '/';
const up = cur === '/' ? '/' : (cur.substring(0, cur.lastIndexOf('/')) || '/');
document.getElementById('vv-ms-rpath').value = up;
vvMsBrowse();
}
// ── Local browse ───────────────────────────────────────────────────────────────
function vvMsLocalNavTo(path) {
document.getElementById('vv-ms-local').value = path;
vvMsBrowseLocal();
}
function vvMsLocalNavUp() {
const cur = (document.getElementById('vv-ms-local').value || '/mnt/user').replace(/\/$/, '') || '/';
const up = cur === '/' ? '/' : (cur.substring(0, cur.lastIndexOf('/')) || '/');
document.getElementById('vv-ms-local').value = up;
vvMsBrowseLocal();
}
function vvMsBrowseLocal() {
const path = document.getElementById('vv-ms-local').value.trim() || '/mnt/user';
const btn = document.getElementById('vv-ms-lbrowse-btn');
const dirsEl = document.getElementById('vv-ms-local-dirs');
const errEl = document.getElementById('vv-ms-lbrowse-err');
// Toggle: already showing this path → collapse
if (dirsEl.style.display !== 'none' && btn.dataset.lastPath === path) {
dirsEl.style.display = 'none';
errEl.style.display = 'none';
btn.textContent = '⟳ Browse';
btn.dataset.lastPath = '';
return;
}
btn.textContent = '⟳'; btn.disabled = true;
dirsEl.style.display = 'none';
errEl.style.display = 'none';
fetch(`/plugins/varaverk/api/manual_sync.php?action=browse_local&path=${encodeURIComponent(path)}&_=${Date.now()}`)
.then(r => r.json())
.then(d => {
btn.textContent = '⟳ Browse'; btn.disabled = false;
if (!d.ok) { errEl.textContent = d.error || 'Browse failed'; errEl.style.display = ''; return; }
document.getElementById('vv-ms-local').value = d.path;
btn.dataset.lastPath = d.path;
_vvMsRenderDirs('vv-ms-local-dirs', d.dirs, d.parent, 'vvMsLocalNavTo');
})
.catch(e => {
btn.textContent = '⟳ Browse'; btn.disabled = false;
errEl.textContent = 'Request failed: ' + e; errEl.style.display = '';
});
}
function vvMsRun() {
const local = document.getElementById('vv-ms-local').value.trim();
const slot = document.getElementById('vv-ms-host').value;
const rpath = document.getElementById('vv-ms-rpath').value.trim();
const user = document.getElementById('vv-ms-user').value;
const bw = parseInt(document.getElementById('vv-ms-bw').value) || 0;
const useKey = document.getElementById('vv-ms-usekey').checked;
const isDry = document.getElementById('vv-ms-dryrun').checked;
// Build flags from all checked checkboxes in the card
const flags = Array.from(document.querySelectorAll('#vv-ms-card input[data-flag]:checked'))
.map(el => el.dataset.flag).join(' ');
if (!local) { alert('Enter a local source path.'); return; }
if (!slot) { alert('Select a remote server.'); return; }
if (!rpath) { alert('Enter a remote destination path.'); return; }
const btn = document.getElementById('vv-ms-run-btn');
const stat = document.getElementById('vv-ms-run-status');
const out = document.getElementById('vv-ms-out');
const badge = document.getElementById('vv-ms-badge');
const stopBtn = document.getElementById('vv-ms-stop-btn');
const showBtn = document.getElementById('vv-ms-show-btn');
btn.disabled = true;
out.style.display = ''; // auto-open output
showBtn.textContent = '▴ Hide Rsync';
out.textContent = isDry ? '⟳ Dry run starting…' : '⟳ Rsync starting in background…';
stat.style.color = '#4a9eff';
stat.textContent = 'Starting…';
badge.style.display = '';
badge.textContent = isDry ? 'DRY RUN' : 'RUNNING';
badge.style.background = '#0a1a2a';
badge.style.color = '#4a9eff';
badge.style.border = '1px solid #1a3a5a';
const fd = new FormData();
fd.append('action', 'run');
fd.append('local', local);
fd.append('host', slot);
fd.append('remote_path', rpath);
fd.append('user', user);
fd.append('flags', flags);
fd.append('bw_limit', bw);
fd.append('use_key', useKey ? '1' : '0');
fetch('/plugins/varaverk/api/manual_sync.php', { method: 'POST', body: fd })
.then(r => r.json())
.then(d => {
if (!d.ok) {
btn.disabled = false;
out.textContent = '✗ ' + (d.error || 'Failed to start');
stat.style.color = '#ef5350';
stat.textContent = 'Error';
badge.style.display = 'none';
return;
}
stat.textContent = 'Running…';
stopBtn.dataset.token = d.token;
stopBtn.style.display = '';
_vvMsStartPoll(d.token, btn, stat, out, badge, stopBtn);
})
.catch(e => {
btn.disabled = false;
out.textContent = '✗ Request failed: ' + e;
stat.style.color = '#ef5350';
stat.textContent = 'Error';
badge.style.display = 'none';
});
}
function _vvMsStartPoll(token, btn, stat, out, badge, stopBtn) {
if (_vvMsPollId) clearInterval(_vvMsPollId);
let ticks = 0;
_vvMsPollId = setInterval(() => {
ticks++;
fetch(`/plugins/varaverk/api/manual_sync.php?action=poll&token=${token}&_=${Date.now()}`)
.then(r => r.json())
.then(d => {
if (!d.ok) return;
if (!d.started && ticks < 6) return;
if (d.output) {
out.textContent = d.output;
out.scrollTop = out.scrollHeight;
}
if (d.done || (!d.started && ticks >= 6)) {
clearInterval(_vvMsPollId);
_vvMsPollId = null;
btn.disabled = false;
if (stopBtn) { stopBtn.style.display = 'none'; stopBtn.dataset.token = ''; }
const cancelled = d.output && d.output.includes('Cancelled by user');
const ok = !cancelled && d.output && !d.output.includes('rsync error');
stat.style.color = cancelled ? '#ff9800' : ok ? '#4caf50' : '#ef5350';
stat.textContent = cancelled ? 'Cancelled' : ok ? 'Done ✓' : 'Finished with errors';
badge.textContent = cancelled ? 'CANCELLED' : ok ? 'DONE' : 'ERROR';
badge.style.background = cancelled ? '#1a1200' : ok ? '#0a1a0a' : '#200d0d';
badge.style.color = cancelled ? '#ff9800' : ok ? '#4caf50' : '#ef5350';
badge.style.border = cancelled ? '1px solid #3a2800' : ok ? '1px solid #1a3a1a' : '1px solid #3a1a1a';
setTimeout(() => { badge.style.display = 'none'; stat.textContent = ''; }, 6000);
}
})
.catch(() => {});
}, 1500);
}
</script>