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