Files
Varaverk/Plugin/unraid/pages/rsync.php
T
Gmer4Lfe 793a75b8a2 Remember the last ten manual syncs so a repeat is one click
Retyping two paths and nine checkboxes correctly every time is where the
mistakes come from, and "one-off" described how a transfer is scheduled rather
than how often it is run. Pinned entries are exempt from the rotation — the
command used twice a year is both the most worth keeping and the first that ten
ordinary runs evict. Stored server-side, so the list is there from any screen,
and loading one fills the form and stops rather than running it.
2026-08-14 17:38:48 -04:00

2131 lines
110 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.
<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// Rsync tab. Sync profiles, share lists, transfer progress, per-run history, and manual
// sync triggers.
//
// DESIGN PRINCIPLES
// Pure view — profile and status data from api/rsync.php, profile edits through
// api/rsync_profiles.php, manual runs through api/manual_sync.php.
//
// Progress polls fast (2s) while a transfer is live and falls back to the slow interval
// otherwise, so an idle page is not hammering the endpoint.
//
// Profiles are edited as structured data, not as raw rsync flags typed by hand.
//
// OPERATIONAL SAFEGUARDS
// Manual sync is confirmed before it fires. A sync moves real data between two machines and
// is not something to trigger with a stray click.
//
// The page shows the global RSYNC_ENABLED gate alongside the per-tier toggles, so a run
// that will be skipped by the gate is visibly skipped rather than silently doing nothing.
//
// Heavy escaping throughout — share paths, profile names and rsync output all render as
// text, never as markup.
//
// The page does not compose rsync flags. Deletion semantics in particular are owned by
// Rsync/rsync.sh, where the pass-1-must-complete interlock lives.
//
// RENDERS
// Profile editor, share lists, live transfer progress, run history, manual sync controls
//
// DEPENDS ON
// api/rsync.php status, polled 2s live / 30s idle
// api/rsync_profiles.php profile read and write
// api/manual_sync.php manual run trigger
// api/rsync_win_arrays.php share array editing
// api/flag_toggle.php enable/disable toggles
// api/confform.php inline conf edits
require_once dirname(__DIR__) . '/include/confui.php';
require_once dirname(__DIR__) . '/include/ai_chat.php';
?>
<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;flex:1;min-height:0; }
/* 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-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:#0d0d0d;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 #2a2a2a;border-radius:3px;color:#bbb;
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 #2a2a2a;border-radius:3px;color:#bbb;
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; }
/* Recent-sync rows. The whole row loads the entry; the controls on the right are the exceptions,
which is why they stop the click rather than the row opting in. */
.vv-ms-rec { display:flex;align-items:center;gap:8px;padding:4px 8px;border-radius:3px;
background:#0d0d0d;border:1px solid #1a1a1a;margin-bottom:3px;cursor:pointer; }
.vv-ms-rec:hover { background:#101a10;border-color:#2d4a2d; }
.vv-ms-rec-nm { font-size:11px;color:#bbb;flex-shrink:0;max-width:38%;overflow:hidden;
text-overflow:ellipsis;white-space:nowrap; }
.vv-ms-rec-pt { font-size:10px;color:#3a5a7a;font-family:monospace;flex:1;min-width:0;
overflow:hidden;text-overflow:ellipsis;white-space:nowrap; }
.vv-ms-rec-age { font-size:9px;color:#333;flex-shrink:0; }
.vv-ms-rec-del { font-size:9px;color:#7a3030;flex-shrink:0; }
.vv-ms-rec-b { background:none;border:none;color:#3a3a3a;cursor:pointer;font-size:11px;
padding:0 3px;flex-shrink:0;line-height:1; }
.vv-ms-rec-b:hover { color:#bbb; }
.vv-ms-rec-b.on { color:#ffb74d; }
.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;">
<!-- ── Recent syncs ──────────────────────────────────────────────────────
First, not last. The reason to open this card is usually a copy that has been done
before, and putting the recall list under the form means scrolling past the very thing
you were trying to avoid filling in again. -->
<div id="vv-ms-recent-wrap" style="margin-bottom:10px;display:none;">
<div class="vv-ms-lbl" style="margin-bottom:5px;">
Recent — click to load
<span style="font-size:9px;font-weight:normal;color:#2a2a2a;margin-left:4px;">
pinned are kept, the rest roll off after ten</span>
</div>
<div id="vv-ms-recent"></div>
</div>
<!-- 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/…" oninput="vvMsUpdatePreview()"
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" onchange="vvMsUpdatePreview()">
<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/…" oninput="vvMsUpdatePreview()"
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>
</div>
<div style="margin-top:3px;background:#080808;border:1px solid #181818;border-radius:3px;
padding:4px 8px;font-family:monospace;font-size:10px;color:#4a6a8a;
word-break:break-all;" id="vv-ms-target-preview"></div>
<div id="vv-ms-delete-warn" style="display:none;margin-top:3px;background:#1a0d0d;
border:1px solid #3a1a1a;border-radius:3px;padding:4px 8px;font-size:10px;
color:#ef5350;"></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>
<?php if (vv_ai_ui_on()): ?>
<div class="vv-card" id="vv-ry-ai-card" style="margin-bottom:12px;">
<?php
// All three before the markup — the factory, the profile registry and the store are separate
// and none is implied by the others. Omitting them renders a chat that looks complete and dies
// on the first click with VvAiChat undefined.
vv_ai_profiles_script();
vv_ai_chat_store_script();
vv_ai_chat_assets();
// Scoped to the tab, so "why hasn't it run" and "what does this profile copy" resolve here
// rather than asking which of eleven profiles is meant. Varaverk assistant rather than General
// Chat for the same reason as the Watchdog tab: this is a page you open with a question about
// this installation, and the answer should come from its own docs and state with sources.
vv_ai_chat_markup('vv-ry-ai', [
'profile' => 'varaverk',
'compact' => true,
'title' => 'Assistant',
'scopeLabel' => 'Rsync',
'empty' => 'Ask about a profile, a window, or why a sync did or did not run.',
'placeholder' => 'Ask about what is on this page…',
]); ?>
</div>
<?php endif; ?>
<!-- ── 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 -->
<?php
// The conf sections this page is about, drawn by the shared renderer. They were reachable
// only from the Settings tab's catch-all, which is a long way to go for a setting named
// after the page you are already looking at.
vv_conf_ui_card('vv-cf-rsync', 'rsync', 'Rsync settings');
?>
<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' },
monthly: { label: 'Monthly', cadence: '30-day gate' },
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';
}
// Escapes text going INSIDE a double-quoted HTML attribute. A bare " there ends the attribute
// early and silently destroys everything after it — an onclick built by string concatenation
// becomes a syntax error and the element simply stops responding, with nothing logged.
//
// Duplicated from scheduler.php rather than shared: js/varaverk.js is loaded after the page
// partials, so a page cannot rely on it during its own setup. Four lines in two places beats a
// load-order bug that only shows up on a slow load.
function vvRyEscAttr(s) {
return String(s).replace(/&/g,'&amp;').replace(/"/g,'&quot;')
.replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
// ── Two status vocabularies, one colour ───────────────────────────────────────
// Transfers record success/failed; orchestrator run records record ok/warn/running. This page is
// fed both and compared everything against 'success', so every window that had run perfectly well
// rendered in the failure colour — four red "ok"s describing four clean runs.
//
// Written as an allow-list of the good states rather than a test for the bad ones: a status this
// does not recognise should read as a problem and be looked at, not be quietly coloured green.
function _statusCol(s) {
const v = String(s || '').toLowerCase();
if (v === 'success' || v === 'ok') return '#4caf50';
if (v === 'running' || v === 'warn') return '#ffb74d';
return '#ef5350';
}
function _statusOk(s) { return _statusCol(s) === '#4caf50'; }
// ── Status + active card ──────────────────────────────────────────────────────
function _statusCard(data) {
const en = data.enabled;
const dotC = en ? '#4caf50' : '#ef5350';
const active = data.active || [];
// The one figure on this page that answers "has anything actually been copied", as opposed to
// "did the window that contains rsync run". Those diverged the day the global gate closed and
// nothing said so — the windows kept reporting healthy runs because their orchestrators kept
// doing their other work. Aged deliberately: a transfer that is weeks old is the finding.
const xfer = data.last_transfer;
const xferAge = xfer && xfer.ts ? Math.floor(Date.now() / 1000) - xfer.ts : null;
const xferCol = xferAge == null ? '#555'
: xferAge > 86400 * 7 ? '#ef5350'
: xferAge > 86400 * 2 ? '#ffb74d' : '#4caf50';
const xferHtml = !xfer || !xfer.ts
? '<div style="color:#333;font-size:11px;margin-top:4px;">No transfer has ever been recorded</div>'
: `<div style="display:flex;align-items:baseline;gap:7px;margin-top:4px;">
<span style="font-size:12px;color:${xferCol};">${_relTime(xfer.ts)}</span>
<span style="font-size:11px;color:#555;">${vvEscHtml(xfer.profile || '')}</span>
<span style="font-size:10px;color:${_statusCol(xfer.status)};margin-left:auto;">${vvEscHtml(xfer.status || '')}</span>
</div>`;
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}
<hr class="vv-ry-sep">
<div class="vv-ry-sec">Last transfer</div>
${xferHtml}
</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 = 'color:' + _statusCol(e.status);
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>`;
});
// Named for what it measures. These are the maintenance orchestrators that contain the rsync
// step, not the rsync step — daily_sync_maintenance also does the git pull, permissions, the
// cleaners, arr cleanup and docker updates, so its duration is mostly not transfer time and it
// reports "ok" whether or not RSYNC_ENABLED let a single byte move. "Last completed run" read
// as a sync result on a page called Rsync; the transfer figure is in the status card above.
return `<div class="vv-ry-card" style="grid-column:span 4;">
<div class="vv-ry-sec">Window orchestrator — last run</div>
<div style="font-size:10px;color:#3a3a3a;margin:-2px 0 6px;">
The job that contains the rsync step, not the transfer itself
</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 = _statusCol(ls.status);
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 (_statusOk(r.status)) 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 = _statusOk(r.status) ? '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 _logPollTimer = null;
let _logElapsed = 0;
let _logTicker = null;
function _syncLogCard() {
return `<div class="vv-ry-card" style="grid-column:span 5;display:flex;flex-direction:column;min-height:0;">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:6px;">
<div class="vv-ry-sec" style="margin-bottom:0;">Sync Activity</div>
<span id="vv-ry-log-hdr" style="font-size:10px;color:#3a3a3a;"></span>
</div>
<div id="vv-ry-log-live"></div>
<div class="vv-ry-slog-scrl" id="vv-ry-log-lines"
style="font-family:monospace;font-size:10px;color:#888;white-space:pre-wrap;word-break:break-all;">
<span style="color:#333;">Loading…</span>
</div>
</div>`;
}
function _fetchLog() {
fetch('/plugins/varaverk/api/rsync.php?action=rsync_log')
.then(r => r.json())
.then(d => {
const linesEl = document.getElementById('vv-ry-log-lines');
const liveEl = document.getElementById('vv-ry-log-live');
const hdrEl = document.getElementById('vv-ry-log-hdr');
if (!linesEl) return;
if (d.live && d.profile) {
_logElapsed = d.elapsed || 0;
if (!_logTicker) {
_logTicker = setInterval(() => {
_logElapsed++;
const el = document.getElementById('vv-ry-log-elapsed');
if (el) el.textContent = _dur(_logElapsed);
}, 1000);
}
if (liveEl) liveEl.innerHTML =
`<div style="display:flex;align-items:center;gap:8px;background:#0a1a0a;border-radius:3px;padding:4px 8px;margin-bottom:4px;">
<span class="vv-ry-pill run">LIVE</span>
<span style="font-size:11px;color:#ccc;">${_esc(d.profile)}</span>
<span id="vv-ry-log-elapsed" style="margin-left:auto;font-size:11px;color:#4a9eff;font-family:monospace;">${_dur(d.elapsed || 0)}</span>
</div>`;
if (hdrEl) hdrEl.textContent = '';
} else {
if (_logTicker) { clearInterval(_logTicker); _logTicker = null; }
if (liveEl) liveEl.innerHTML = '';
if (hdrEl) hdrEl.textContent = d.profile ? 'last: ' + d.profile : '';
}
const atBottom = linesEl.scrollHeight - linesEl.scrollTop <= linesEl.clientHeight + 10;
linesEl.textContent = (d.lines || []).join('\n') || (d.profile ? '(no output captured)' : 'No sync history');
if (atBottom) linesEl.scrollTop = linesEl.scrollHeight;
})
.catch(() => {});
}
function _startLogPoll() {
_fetchLog();
if (!_logPollTimer) _logPollTimer = setInterval(_fetchLog, 2000);
}
// ── Settings section ──────────────────────────────────────────────────────────
function _toggle(name, currentVal, label) {
const id = 'vv-ry-tog-' + name;
return `<label class="vv-ry-toggle" title="${name}">
<div class="vv-cf-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('MONTHLY_RSYNC_ENABLED', w.monthly, 'Monthly')}
${_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();
html += _settingsSection(data);
document.getElementById('vv-ry-grid').innerHTML = html;
_startLogPoll();
// 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 URLSearchParams();
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}',${vvRyEscAttr(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}',${vvRyEscAttr(JSON.stringify(dir))})" title="${vvRyEscAttr(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 URLSearchParams();
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 URLSearchParams();
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 URLSearchParams();
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'; });
}
async function vvRpDelete() {
const name = document.getElementById('vv-rp-name').value.trim();
if (!name) return;
if (!await vvConfirm(`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 URLSearchParams();
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 URLSearchParams();
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();
vvMsLoadRecent(); // refreshed on open, so another tab or device is reflected here
}
}
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');
vvMsUpdatePreview(); // the destination just changed, so the command shown must too
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 = '';
});
}
// The whole command, with the real paths in it, not "…src host:dst".
//
// Two reasons it has to resolve rather than gesture. The first is the trailing slash: this card
// already warns that "/" means contents and no "/" means the folder, and a hint under a text box
// is not where anyone looks at the moment they press Run — in the command it is unmissable. The
// second is --delete, which is one checkbox among nine and is the only one that removes data at
// the far end. Seeing it sit next to the actual destination is the difference between reading a
// flag and understanding a consequence.
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 : '';
const local = document.getElementById('vv-ms-local')?.value.trim() || '';
const rpath = document.getElementById('vv-ms-rpath')?.value.trim() || '';
const user = document.getElementById('vv-ms-user')?.value || 'root';
const hostEl = document.getElementById('vv-ms-host');
const hostTxt = hostEl && hostEl.selectedIndex >= 0
? (hostEl.options[hostEl.selectedIndex].textContent || '').trim() : '';
const tgt = document.getElementById('vv-ms-target-preview');
if (tgt) {
tgt.textContent = (local || '<source>') + ' ' + user + '@' + (hostTxt || '<host>') + ':' + (rpath || '<dest>');
}
// Destructive styling follows the checkbox, so the warning cannot be left on screen after the
// flag is cleared — a stale danger marker is how a real one stops being read.
const del = document.querySelector('#vv-ms-card input[data-flag="--delete"]');
const dry = document.getElementById('vv-ms-dryrun');
const warn = document.getElementById('vv-ms-delete-warn');
if (warn) {
const armed = del && del.checked && !(dry && dry.checked);
warn.style.display = armed ? '' : 'none';
if (armed) {
warn.textContent = 'deletes anything at ' + (rpath || 'the destination')
+ ' that is not in ' + (local || 'the source');
}
}
}
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}(${vvRyEscAttr(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}(${vvRyEscAttr(JSON.stringify(d))})" title="${vvRyEscAttr(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 = '';
});
}
async 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) { vvAlert('Enter a local source path.'); return; }
if (!slot) { vvAlert('Select a remote server.'); return; }
if (!rpath) { vvAlert('Enter a remote destination path.'); return; }
// The only confirm on this card, and only for the one flag that destroys data at the far end.
// Guarding every run would train the reflex that dismisses this one — a prompt that appears on
// every safe action is not read by the time an unsafe one arrives.
//
// Skipped under --dry-run, because that is exactly the rehearsal this is asking for, and
// refusing to let someone rehearse the dangerous case cheaply is the wrong lesson to teach.
if (flags.includes('--delete') && !isDry) {
const ok = await vvConfirm(
'This deletes anything in ' + rpath + ' on ' + slot + ' that is not in ' + local + '.\n\n'
+ (local.endsWith('/')
? 'The source ends in "/", so its CONTENTS are compared against the destination.'
: 'The source has no trailing "/", so the FOLDER ITSELF is copied into the destination — '
+ 'a common cause of deleting the wrong level.')
+ '\n\nRun a --dry-run first if you have not.',
{ title: 'Run with --delete?', confirmText: 'Run it', type: 'warning' });
if (!ok) 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 URLSearchParams();
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;
vvMsLoadRecent(); // the run just recorded itself server-side; show it
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);
}
// ── Recent manual syncs ──────────────────────────────────────────────────────
// Server-side, not localStorage: the same install is driven from two desktop screens and a phone,
// and a recall list that only exists in the browser that made it is missing exactly when wanted.
const _MS_API = '/plugins/varaverk/api/manual_sync.php';
let _vvMsRecent = [];
function _msRecAge(ts) {
const s = Math.max(0, Math.floor(Date.now() / 1000) - (ts || 0));
if (s < 3600) return Math.max(1, Math.round(s / 60)) + 'm';
if (s < 86400) return Math.round(s / 3600) + 'h';
return Math.round(s / 86400) + 'd';
}
function vvMsLoadRecent() {
fetch(_MS_API + '?action=recent')
.then(r => r.json())
.then(d => { _vvMsRecent = (d && d.rows) || []; _msRenderRecent(); })
.catch(() => {});
}
function _msRenderRecent() {
const wrap = document.getElementById('vv-ms-recent-wrap');
const box = document.getElementById('vv-ms-recent');
if (!wrap || !box) return;
if (!_vvMsRecent.length) { wrap.style.display = 'none'; box.innerHTML = ''; return; }
wrap.style.display = '';
box.innerHTML = _vvMsRecent.map(r => {
// Falls back to the transfer itself when unnamed, so a row always says what it does. A blank
// label on a one-click action that copies files is not a thing to ship.
const label = r.name || (String(r.local || '').split('/').filter(Boolean).pop() || 'sync');
const path = (r.local || '') + ' → ' + (r.slot || '') + ':' + (r.rpath || '');
const del = (r.flags || []).includes('--delete');
return `<div class="vv-ms-rec" data-id="${vvEscAttr(r.id)}" title="${vvEscAttr(path)}">
<span class="vv-ms-rec-nm">${vvEscHtml(label)}</span>
<span class="vv-ms-rec-pt">${vvEscHtml(path)}</span>
${del ? '<span class="vv-ms-rec-del">--delete</span>' : ''}
<span class="vv-ms-rec-age">${_msRecAge(r.ts)}</span>
<button class="vv-ms-rec-b ${r.sticky ? 'on' : ''}" data-act="sticky"
title="${r.sticky ? 'Unpin — may roll off' : 'Pin — never rolls off'}">${r.sticky ? '★' : '☆'}</button>
<button class="vv-ms-rec-b" data-act="rename" title="Rename">✎</button>
<button class="vv-ms-rec-b" data-act="delete" title="Forget this entry">×</button>
</div>`;
}).join('');
}
// One delegated listener: the list is replaced wholesale on every change, so per-row handlers
// would be rebound constantly and silently missed by anything added later.
document.addEventListener('click', async ev => {
const row = ev.target.closest('.vv-ms-rec');
if (!row) return;
const id = row.dataset.id;
const rec = _vvMsRecent.find(r => r.id === id);
if (!rec) return;
const btn = ev.target.closest('.vv-ms-rec-b');
if (!btn) { _msApplyRecent(rec); return; }
ev.stopPropagation();
const act = btn.dataset.act;
const body = { action: 'recent_update', id: id, op: act };
if (act === 'rename') {
const name = await vvPrompt('Name for this sync', rec.name || '');
if (name === null) return;
body.name = name;
} else if (act === 'sticky') {
body.value = rec.sticky ? '0' : '1';
} else if (act === 'delete') {
if (!await vvConfirm('Forget this saved sync? It only removes the entry, nothing is deleted on either host.')) return;
}
fetch(_MS_API, { method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
body: new URLSearchParams(body) })
.then(r => r.json())
.then(() => vvMsLoadRecent())
.catch(() => {});
});
// Fills the form and stops. Deliberately does not run: one click away from a transfer that may
// carry --delete is not a saving worth having, and the point of loading it is to look at it.
function _msApplyRecent(r) {
const set = (id, v) => { const el = document.getElementById(id); if (el) el.value = v; };
set('vv-ms-local', r.local || '');
set('vv-ms-rpath', r.rpath || '');
set('vv-ms-bw', r.bw || 0);
const host = document.getElementById('vv-ms-host');
if (host) { host.value = r.slot || ''; if (typeof vvMsHostChanged === 'function') vvMsHostChanged(); }
const user = document.getElementById('vv-ms-user');
if (user && r.user) user.value = r.user;
const key = document.getElementById('vv-ms-usekey');
if (key) key.checked = r.use_key !== false;
const want = new Set(r.flags || []);
document.querySelectorAll('#vv-ms-card input[data-flag]').forEach(el => {
el.checked = want.has(el.dataset.flag);
});
vvMsUpdatePreview();
document.getElementById('vv-ms-local')?.focus();
}
// ── Assistant ────────────────────────────────────────────────────────────────
// Scope is fixed to the tab rather than following a selection. Unlike the Scheduler, nothing here
// is "open" in the sense of one log or one script — the operator is looking at the whole picture
// and asking about part of it, and the worker resolves bare references against the tab name.
if (document.getElementById('vv-ry-ai-chat')) {
VvAiChat({
prefix: 'vv-ry-ai',
profile: 'varaverk',
scopeLabel: 'Rsync',
scope: () => 'Rsync',
resumeProfile: 'varaverk',
empty: 'Ask about a profile, a window, or why a sync did or did not run.',
});
}
</script>