The grid said repeat(5,1fr) against six windows, so Fallback sat alone on a second row. The count now travels with the cards as --vv-win-n, so adding a window widens the row instead of quietly starting another one. Grid rather than flex keeps every card exactly the same width, and it steps 6→3→2 rather than dropping straight to two — the middle step is the one a 15" panel lands on.
2141 lines
110 KiB
PHP
2141 lines
110 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
|
||
require_once dirname(__DIR__) . '/include/confui.php';
|
||
require_once dirname(__DIR__) . '/include/ai_chat.php';
|
||
?>
|
||
<style>
|
||
.vv-ry-card { background:#161616;border:1px solid #2a2a2a;border-radius:6px;padding:10px 12px;min-width:0; }
|
||
.vv-ry-sec { font-size:10px;font-weight:bold;color:#444;letter-spacing:.07em;text-transform:uppercase;margin-bottom:6px; }
|
||
.vv-ry-row { display:flex;justify-content:space-between;align-items:baseline;gap:6px;margin:2px 0; }
|
||
.vv-ry-lbl { font-size:11px;color:#444;white-space:nowrap; }
|
||
.vv-ry-val { font-size:12px;color:#bbb;text-align:right; }
|
||
.vv-ry-sep { border:none;border-top:1px solid #1e1e1e;margin:7px 0; }
|
||
.vv-ry-pill { display:inline-block;font-size:10px;padding:1px 7px;border-radius:3px;font-weight:bold;letter-spacing:.04em; }
|
||
.vv-ry-pill.on { background:#0d1f0d;color:#4caf50;border:1px solid #1a3a1a; }
|
||
.vv-ry-pill.off { background:#1e1e1e;color:#555;border:1px solid #2a2a2a; }
|
||
.vv-ry-pill.warn { background:#1f1500;color:#ffb74d;border:1px solid #3a2800; }
|
||
.vv-ry-pill.err { background:#200d0d;color:#ef5350;border:1px solid #3a1a1a; }
|
||
.vv-ry-pill.run { background:#0a1a2a;color:#4a9eff;border:1px solid #1a3a5a; }
|
||
.vv-ry-dot { display:inline-block;width:7px;height:7px;border-radius:50%;flex-shrink:0;margin-right:5px;margin-bottom:-1px; }
|
||
|
||
/* Window cards row */
|
||
/* One row, however many windows there are. The count comes from _windowsRow() as --vv-win-n
|
||
rather than being written here: this said repeat(5,1fr) against six windows, so Fallback sat
|
||
alone on a second row — and the next window added would have done the same thing again.
|
||
Grid, not flex, so every card is exactly the same width. */
|
||
.vv-ry-wins { grid-column:1/-1;display:grid;
|
||
grid-template-columns:repeat(var(--vv-win-n,6),1fr);gap:10px; }
|
||
/* Degrades in stages rather than dropping straight to two — six across needs roughly 1100px,
|
||
and the dashboard runs on 15" panels where the middle step is the one that gets used. */
|
||
@media (max-width:1400px) { .vv-ry-wins { grid-template-columns:repeat(3,1fr); } }
|
||
@media (max-width:900px) { .vv-ry-wins { grid-template-columns:repeat(2,1fr); } }
|
||
|
||
.vv-ry-win { background:#161616;border:1px solid #2a2a2a;border-radius:6px;padding:10px 12px; }
|
||
.vv-ry-win.enabled { border-color:#1a3a1a; }
|
||
.vv-ry-win.disabled { opacity:.55; }
|
||
.vv-ry-win-lbl { font-size:10px;font-weight:bold;letter-spacing:.08em;text-transform:uppercase;color:#555;margin-bottom:6px; }
|
||
.vv-ry-win-name{ font-size:15px;font-weight:bold;color:#888;margin-bottom:6px; }
|
||
.vv-ry-win-stat{ font-size:11px;color:#3a3a3a;margin-top:4px; }
|
||
.vv-ry-win-dur { font-size:10px;color:#3a3a3a; }
|
||
|
||
/* Bandwidth section */
|
||
.vv-ry-bw { grid-column:1/-1;display:grid;grid-template-columns:5fr 3fr;gap:10px; }
|
||
@media (max-width:900px) { .vv-ry-bw { grid-template-columns:1fr; } }
|
||
.vv-ry-prof-row{ display:flex;align-items:center;gap:8px;margin:3px 0; }
|
||
.vv-ry-prof-nm { font-size:11px;color:#888;width:120px;flex-shrink:0; }
|
||
.vv-ry-prof-bar{ flex:1;height:5px;background:#1e1e1e;border-radius:2px;overflow:hidden; }
|
||
.vv-ry-prof-fill{ height:100%;border-radius:2px;transition:width .3s; }
|
||
.vv-ry-prof-cnt{ font-size:10px;color:#555;width:60px;text-align:right;flex-shrink:0; }
|
||
.vv-ry-run-row { display:grid;grid-template-columns:80px 90px 1fr 55px 48px;gap:4px;align-items:center;padding:2px 0;border-bottom:1px solid #1a1a1a; }
|
||
.vv-ry-run-row:last-child { border-bottom:none; }
|
||
.vv-ry-runs-scroll { max-height:200px;overflow-y:auto;scrollbar-width:none;-ms-overflow-style:none; }
|
||
.vv-ry-runs-scroll::-webkit-scrollbar { display:none; }
|
||
.vv-ry-run-dt { font-size:10px;color:#3a3a3a; }
|
||
.vv-ry-run-tm { font-size:10px;color:#333; }
|
||
.vv-ry-run-pr { font-size:11px;color:#888;white-space:nowrap;overflow:hidden;text-overflow:ellipsis; }
|
||
.vv-ry-run-dur { font-size:10px;color:#555;text-align:right; }
|
||
.vv-ry-run-st { text-align:right; }
|
||
|
||
/* Sync log card */
|
||
.vv-ry-slog-row { display:grid;grid-template-columns:38px 1fr 70px 50px 46px;gap:6px;align-items:center;
|
||
padding:3px 0;border-bottom:1px solid #111; }
|
||
.vv-ry-slog-row:last-child { border-bottom:none; }
|
||
.vv-ry-slog-name { font-size:11px;color:#888;overflow:hidden;text-overflow:ellipsis;white-space:nowrap; }
|
||
.vv-ry-slog-dt { font-size:10px;color:#3a3a3a; }
|
||
.vv-ry-slog-dur { font-size:10px;color:#555;text-align:right; }
|
||
.vv-ry-slog-byt { font-size:10px;color:#555;text-align:right; }
|
||
.vv-ry-slog-scrl { overflow-y:auto;flex:1;min-height:0; }
|
||
|
||
/* Settings */
|
||
.vv-ry-set-grid { display:grid;grid-template-columns:repeat(2,1fr);gap:8px;margin-top:6px; }
|
||
.vv-ry-toggle { display:inline-flex;align-items:center;gap:8px;cursor:pointer;user-select:none; }
|
||
.vv-ry-toggle-lbl { font-size:11px;color:#666; }
|
||
.vv-ry-set-row { display:flex;align-items:center;gap:10px;margin:4px 0; }
|
||
.vv-ry-set-lbl { font-size:11px;color:#555;width:130px;flex-shrink:0; }
|
||
.vv-ry-set-inp { background:#0d0d0d;border:1px solid #2a2a2a;border-radius:3px;color:#bbb;font-size:12px;padding:3px 7px;width:80px;outline:none; }
|
||
.vv-ry-set-inp:focus { border-color:#444; }
|
||
.vv-ry-set-hint { font-size:10px;color:#3a3a3a; }
|
||
.vv-ry-save-btn { background:#1a2a1a;border:1px solid #2d4a2d;color:#4caf50;font-size:11px;padding:4px 14px;border-radius:3px;cursor:pointer;transition:background .15s; }
|
||
.vv-ry-save-btn:hover { background:#223a22; }
|
||
.vv-ry-save-btn:disabled { opacity:.4;cursor:default; }
|
||
.vv-ry-feedback { font-size:11px;margin-left:8px; }
|
||
|
||
/* ── Info layer ──────────────────────────────────────────────────── */
|
||
.vv-ry-info-block { border:1px solid #1a1a1a;border-radius:4px;margin-bottom:6px;overflow:hidden; }
|
||
.vv-ry-info-hdr { padding:7px 12px;background:#111;cursor:pointer;font-size:11px;font-weight:600;
|
||
color:#666;display:flex;align-items:center;gap:6px;user-select:none; }
|
||
.vv-ry-info-hdr:hover { color:#999;background:#141414; }
|
||
.vv-ry-info-chev { font-size:10px;color:#333;flex-shrink:0; }
|
||
.vv-ry-info-body { padding:10px 14px;background:#0d0d0d; }
|
||
.vv-ry-info-cols { display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:8px 20px; }
|
||
.vv-ry-info-cols > div { font-size:11px;color:#555;line-height:1.5; }
|
||
.vv-ry-info-cols strong { color:#777; }
|
||
.vv-ry-info-cols code { font-size:10px;color:#4a7a4a;background:#080808;padding:1px 4px;border-radius:2px;font-family:monospace; }
|
||
|
||
/* ── Profile editor flag checkboxes ─────────────────────────────── */
|
||
.vv-rp-flag { display:inline-flex;align-items:center;gap:4px;cursor:pointer;
|
||
padding:3px 8px;border:1px solid #1e1e1e;border-radius:3px;background:#0d0d0d;
|
||
font-size:10px;color:#555;user-select:none;transition:border-color .1s; }
|
||
.vv-rp-flag:hover { border-color:#2a3a2a;color:#888; }
|
||
.vv-rp-flag input[type=checkbox] { accent-color:#4caf50;width:11px;height:11px;cursor:pointer; }
|
||
.vv-rp-flag input:checked ~ span { color:#4caf50; }
|
||
.vv-rp-flag:has(input:checked) { border-color:#1a3a1a;background:#080f08; }
|
||
.vv-rp-flag em { font-style:normal;color:#2e4e2e; }
|
||
|
||
/* ── Manual Sync card ──────────────────────────────────────────────── */
|
||
.vv-ms-row { display:flex;gap:10px;margin-bottom:8px;align-items:flex-start;flex-wrap:wrap; }
|
||
.vv-ms-col { display:flex;flex-direction:column;gap:4px;flex:1 1 200px;min-width:0; }
|
||
.vv-ms-lbl { font-size:9px;font-weight:700;color:#444;text-transform:uppercase;letter-spacing:.07em; }
|
||
.vv-ms-inp { background:#0d0d0d;border:1px solid #2a2a2a;border-radius:3px;color:#bbb;
|
||
font-size:12px;padding:5px 8px;width:100%;box-sizing:border-box;font-family:monospace;outline:none; }
|
||
.vv-ms-inp:focus { border-color:#3a5a3a; }
|
||
.vv-ms-sel { background:#0d0d0d;border:1px solid #2a2a2a;border-radius:3px;color:#bbb;
|
||
font-size:12px;padding:5px 8px;width:100%;box-sizing:border-box;outline:none;cursor:pointer; }
|
||
.vv-ms-sel:focus { border-color:#3a5a3a; }
|
||
.vv-ms-dirs { background:#080808;border:1px solid #1e1e1e;border-radius:3px;
|
||
max-height:160px;overflow-y:auto;margin-top:2px; }
|
||
.vv-ms-dir { padding:4px 10px;font-size:11px;color:#555;font-family:monospace;cursor:pointer;
|
||
white-space:nowrap;overflow:hidden;text-overflow:ellipsis;border-bottom:1px solid #111; }
|
||
.vv-ms-dir:hover { background:#0f1f0f;color:#4caf50; }
|
||
.vv-ms-dir.parent { color:#444;font-style:italic; }
|
||
.vv-ms-dir.parent:hover { color:#888; }
|
||
/* Recent-sync rows. The whole row loads the entry; the controls on the right are the exceptions,
|
||
which is why they stop the click rather than the row opting in. */
|
||
.vv-ms-rec { display:flex;align-items:center;gap:8px;padding:4px 8px;border-radius:3px;
|
||
background:#0d0d0d;border:1px solid #1a1a1a;margin-bottom:3px;cursor:pointer; }
|
||
.vv-ms-rec:hover { background:#101a10;border-color:#2d4a2d; }
|
||
.vv-ms-rec-nm { font-size:11px;color:#bbb;flex-shrink:0;max-width:38%;overflow:hidden;
|
||
text-overflow:ellipsis;white-space:nowrap; }
|
||
.vv-ms-rec-pt { font-size:10px;color:#3a5a7a;font-family:monospace;flex:1;min-width:0;
|
||
overflow:hidden;text-overflow:ellipsis;white-space:nowrap; }
|
||
.vv-ms-rec-age { font-size:9px;color:#333;flex-shrink:0; }
|
||
.vv-ms-rec-del { font-size:9px;color:#7a3030;flex-shrink:0; }
|
||
.vv-ms-rec-b { background:none;border:none;color:#3a3a3a;cursor:pointer;font-size:11px;
|
||
padding:0 3px;flex-shrink:0;line-height:1; }
|
||
.vv-ms-rec-b:hover { color:#bbb; }
|
||
.vv-ms-rec-b.on { color:#ffb74d; }
|
||
|
||
.vv-ms-optrow { display:flex;align-items:center;gap:16px;flex-wrap:wrap;margin-bottom:8px; }
|
||
.vv-ms-chk { display:flex;align-items:center;gap:5px;cursor:pointer; }
|
||
.vv-ms-chk input { accent-color:#4caf50;width:13px;height:13px; }
|
||
.vv-ms-chk span { font-size:11px;color:#555; }
|
||
.vv-ms-run { background:#1a2a1a;border:1px solid #2d4a2d;color:#4caf50;font-size:12px;
|
||
font-weight:600;padding:6px 18px;border-radius:3px;cursor:pointer;letter-spacing:.03em; }
|
||
.vv-ms-run:hover { background:#223a22; }
|
||
.vv-ms-run:disabled { opacity:.4;cursor:default; }
|
||
.vv-ms-out { background:#080808;border:1px solid #1a1a1a;border-radius:3px;padding:8px 10px;
|
||
font-family:monospace;font-size:10px;color:#555;white-space:pre-wrap;word-break:break-all;
|
||
max-height:220px;overflow-y:auto;margin-top:8px;display:none; }
|
||
.vv-ms-badge { font-size:9px;padding:1px 7px;border-radius:3px;font-weight:700; }
|
||
@media (max-width:700px) { #vv-ms-body > div:first-child { grid-template-columns:1fr !important; } }
|
||
</style>
|
||
|
||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;padding:0 2px;">
|
||
<span style="font-size:13px;font-weight:bold;color:#888;text-transform:uppercase;letter-spacing:.06em;">Rsync</span>
|
||
<span style="font-size:11px;color:#3a3a3a;" id="vv-ry-ts"></span>
|
||
</div>
|
||
|
||
<!-- Manual Sync card — static, not re-rendered by the poll loop -->
|
||
<div class="vv-ry-card" id="vv-ms-card" style="margin-bottom:12px;">
|
||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;">
|
||
<div style="display:flex;align-items:center;gap:8px;">
|
||
<span style="font-size:12px;font-weight:700;color:#888;text-transform:uppercase;letter-spacing:.06em;">Manual Rsync</span>
|
||
<span id="vv-ms-badge" class="vv-ms-badge" style="display:none;"></span>
|
||
</div>
|
||
<button onclick="vvMsToggle()" style="font-size:10px;color:#4a6a4a;background:#0d1a0d;border:1px solid #1a3a1a;
|
||
border-radius:3px;padding:2px 10px;cursor:pointer;" id="vv-ms-tog">▾ Show</button>
|
||
</div>
|
||
<div id="vv-ms-body" style="display:none;">
|
||
|
||
<!-- ── Recent syncs ──────────────────────────────────────────────────────
|
||
First, not last. The reason to open this card is usually a copy that has been done
|
||
before, and putting the recall list under the form means scrolling past the very thing
|
||
you were trying to avoid filling in again. -->
|
||
<div id="vv-ms-recent-wrap" style="margin-bottom:10px;display:none;">
|
||
<div class="vv-ms-lbl" style="margin-bottom:5px;">
|
||
Recent — click to load
|
||
<span style="font-size:9px;font-weight:normal;color:#2a2a2a;margin-left:4px;">
|
||
pinned are kept, the rest roll off after ten</span>
|
||
</div>
|
||
<div id="vv-ms-recent"></div>
|
||
</div>
|
||
|
||
<!-- Two-column browse layout -->
|
||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:10px;">
|
||
|
||
<!-- ── Left: local source ── -->
|
||
<div>
|
||
<div class="vv-ms-lbl" style="margin-bottom:5px;">Local source</div>
|
||
<div style="display:flex;gap:5px;align-items:center;margin-bottom:4px;">
|
||
<input class="vv-ms-inp" id="vv-ms-local" type="text" placeholder="/mnt/user/…" oninput="vvMsUpdatePreview()"
|
||
style="flex:1;min-width:0;" onkeydown="if(event.key==='Enter')vvMsBrowseLocal()">
|
||
<button onclick="vvMsLocalNavUp()" title="Parent"
|
||
style="background:#111;border:1px solid #222;color:#555;border-radius:3px;
|
||
padding:4px 8px;cursor:pointer;font-size:12px;flex-shrink:0;">↑</button>
|
||
<button onclick="vvMsBrowseLocal()" id="vv-ms-lbrowse-btn"
|
||
style="background:#111;border:1px solid #222;color:#4a9eff;border-radius:3px;
|
||
padding:4px 9px;cursor:pointer;font-size:11px;white-space:nowrap;flex-shrink:0;">⟳ Browse</button>
|
||
</div>
|
||
<div id="vv-ms-local-dirs" class="vv-ms-dirs" style="display:none;"></div>
|
||
<span id="vv-ms-lbrowse-err" style="font-size:9px;color:#ef5350;display:none;margin-top:2px;display:block;"></span>
|
||
<span style="font-size:9px;color:#2a2a2a;margin-top:3px;display:block;">trailing / = sync contents · without / = sync the folder</span>
|
||
</div>
|
||
|
||
<!-- ── Right: remote destination ── -->
|
||
<div>
|
||
<div style="display:flex;gap:8px;margin-bottom:6px;flex-wrap:wrap;">
|
||
<div style="flex:1 1 140px;min-width:0;">
|
||
<div class="vv-ms-lbl" style="margin-bottom:3px;">Remote server</div>
|
||
<select class="vv-ms-sel" id="vv-ms-host" onchange="vvMsHostChanged()">
|
||
<option value="">Loading hosts…</option>
|
||
</select>
|
||
<span id="vv-ms-host-status" style="font-size:9px;color:#333;margin-top:2px;display:block;"></span>
|
||
</div>
|
||
<div style="flex:0 1 80px;">
|
||
<div class="vv-ms-lbl" style="margin-bottom:3px;">User</div>
|
||
<select class="vv-ms-sel" id="vv-ms-user" onchange="vvMsUpdatePreview()">
|
||
<option value="root">root</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div class="vv-ms-lbl" style="margin-bottom:5px;">Remote path</div>
|
||
<div style="display:flex;gap:5px;align-items:center;margin-bottom:4px;">
|
||
<input class="vv-ms-inp" id="vv-ms-rpath" type="text" placeholder="/mnt/user/…" oninput="vvMsUpdatePreview()"
|
||
style="flex:1;min-width:0;" onkeydown="if(event.key==='Enter')vvMsBrowse()">
|
||
<button onclick="vvMsNavUp()" title="Parent"
|
||
style="background:#111;border:1px solid #222;color:#555;border-radius:3px;
|
||
padding:4px 8px;cursor:pointer;font-size:12px;flex-shrink:0;">↑</button>
|
||
<button onclick="vvMsBrowse()" id="vv-ms-browse-btn"
|
||
style="background:#111;border:1px solid #222;color:#4a9eff;border-radius:3px;
|
||
padding:4px 9px;cursor:pointer;font-size:11px;white-space:nowrap;flex-shrink:0;">⟳ Browse</button>
|
||
</div>
|
||
<div id="vv-ms-dirs" class="vv-ms-dirs" style="display:none;"></div>
|
||
<span id="vv-ms-browse-err" style="font-size:9px;color:#ef5350;display:none;margin-top:2px;display:block;"></span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Rsync flags -->
|
||
<div style="border-top:1px solid #1a1a1a;padding-top:8px;margin-bottom:8px;">
|
||
<div class="vv-ms-lbl" style="margin-bottom:6px;">Rsync flags
|
||
<span style="font-size:9px;font-weight:normal;color:#2a2a2a;margin-left:4px;">does not inherit defaults — check every desired flag</span>
|
||
</div>
|
||
<div style="display:flex;gap:5px;flex-wrap:wrap;">
|
||
<label class="vv-rp-flag"><input type="checkbox" data-flag="-av" checked onchange="vvMsUpdatePreview()"><span>-av</span></label>
|
||
<label class="vv-rp-flag"><input type="checkbox" data-flag="--human-readable" checked onchange="vvMsUpdatePreview()"><span>--human-readable</span></label>
|
||
<label class="vv-rp-flag"><input type="checkbox" data-flag="--stats" checked onchange="vvMsUpdatePreview()"><span>--stats</span></label>
|
||
<span style="border-left:1px solid #1e1e1e;margin:0 3px;"></span>
|
||
<label class="vv-rp-flag"><input type="checkbox" data-flag="--delete" onchange="vvMsUpdatePreview()"><span>--delete</span></label>
|
||
<label class="vv-rp-flag"><input type="checkbox" data-flag="--inplace" onchange="vvMsUpdatePreview()"><span>--inplace</span></label>
|
||
<label class="vv-rp-flag"><input type="checkbox" data-flag="--no-whole-file" onchange="vvMsUpdatePreview()"><span>--no-whole-file</span></label>
|
||
<label class="vv-rp-flag"><input type="checkbox" data-flag="--checksum" onchange="vvMsUpdatePreview()"><span>--checksum</span></label>
|
||
<label class="vv-rp-flag"><input type="checkbox" data-flag="--compress" onchange="vvMsUpdatePreview()"><span>--compress</span></label>
|
||
<span style="border-left:1px solid #1e1e1e;margin:0 3px;"></span>
|
||
<label class="vv-rp-flag"><input type="checkbox" id="vv-ms-dryrun" data-flag="--dry-run" onchange="vvMsUpdatePreview()"><span>--dry-run</span></label>
|
||
</div>
|
||
<div style="margin-top:6px;background:#080808;border:1px solid #181818;border-radius:3px;
|
||
padding:4px 8px;font-family:monospace;font-size:10px;color:#3a5a3a;display:flex;gap:8px;align-items:baseline;">
|
||
<span style="font-size:9px;color:#2a2a2a;flex-shrink:0;">rsync</span>
|
||
<span id="vv-ms-flags-preview"></span>
|
||
<span id="vv-ms-bwlimit-preview" style="color:#2e4e2e;"></span>
|
||
</div>
|
||
<div style="margin-top:3px;background:#080808;border:1px solid #181818;border-radius:3px;
|
||
padding:4px 8px;font-family:monospace;font-size:10px;color:#4a6a8a;
|
||
word-break:break-all;" id="vv-ms-target-preview"></div>
|
||
<div id="vv-ms-delete-warn" style="display:none;margin-top:3px;background:#1a0d0d;
|
||
border:1px solid #3a1a1a;border-radius:3px;padding:4px 8px;font-size:10px;
|
||
color:#ef5350;"></div>
|
||
</div>
|
||
|
||
<!-- BW limit + SSH key -->
|
||
<div class="vv-ms-optrow" style="margin-bottom:8px;">
|
||
<div style="display:flex;align-items:center;gap:6px;">
|
||
<span style="font-size:11px;color:#444;">BW limit</span>
|
||
<input class="vv-ms-inp" id="vv-ms-bw" type="number" min="0" value="0"
|
||
style="width:72px;padding:3px 6px;" oninput="vvMsUpdatePreview()">
|
||
<span style="font-size:10px;color:#333;">KB/s (0 = unlimited)</span>
|
||
</div>
|
||
<label class="vv-ms-chk">
|
||
<input type="checkbox" id="vv-ms-usekey" checked>
|
||
<span>SSH key: <code id="vv-ms-key-info"
|
||
style="font-size:9px;color:#2a3a2a;margin-left:2px;font-family:monospace;">loading…</code></span>
|
||
</label>
|
||
</div>
|
||
|
||
<!-- Run + Show + Stop + status -->
|
||
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:4px;">
|
||
<button class="vv-ms-run" id="vv-ms-run-btn" onclick="vvMsRun()">▶ Run Rsync</button>
|
||
<button id="vv-ms-show-btn" onclick="vvMsToggleOutput()"
|
||
style="background:#111;border:1px solid #252525;color:#555;font-size:11px;
|
||
padding:5px 14px;border-radius:3px;cursor:pointer;">▾ Show Rsync</button>
|
||
<button id="vv-ms-stop-btn" onclick="vvMsStop()"
|
||
style="display:none;background:#2a1010;border:1px solid #5a2020;color:#ef5350;
|
||
font-size:11px;padding:5px 14px;border-radius:3px;cursor:pointer;font-weight:600;">■ Stop</button>
|
||
<span id="vv-ms-run-status" style="font-size:10px;color:#444;"></span>
|
||
</div>
|
||
|
||
<!-- Output (auto-opens on run) -->
|
||
<pre class="vv-ms-out" id="vv-ms-out" style="display:none;margin-top:8px;"></pre>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="vv-ry-grid" style="display:grid;grid-template-columns:repeat(8,1fr);gap:12px;margin-bottom:12px;"></div>
|
||
|
||
<?php if (vv_ai_ui_on()): ?>
|
||
<div class="vv-card" id="vv-ry-ai-card" style="margin-bottom:12px;">
|
||
<?php
|
||
// All three before the markup — the factory, the profile registry and the store are separate
|
||
// and none is implied by the others. Omitting them renders a chat that looks complete and dies
|
||
// on the first click with VvAiChat undefined.
|
||
vv_ai_profiles_script();
|
||
vv_ai_chat_store_script();
|
||
vv_ai_chat_assets();
|
||
// Scoped to the tab, so "why hasn't it run" and "what does this profile copy" resolve here
|
||
// rather than asking which of eleven profiles is meant. Varaverk assistant rather than General
|
||
// Chat for the same reason as the Watchdog tab: this is a page you open with a question about
|
||
// this installation, and the answer should come from its own docs and state with sources.
|
||
vv_ai_chat_markup('vv-ry-ai', [
|
||
'profile' => 'varaverk',
|
||
'compact' => true,
|
||
'title' => 'Assistant',
|
||
'scopeLabel' => 'Rsync',
|
||
'empty' => 'Ask about a profile, a window, or why a sync did or did not run.',
|
||
'placeholder' => 'Ask about what is on this page…',
|
||
]); ?>
|
||
</div>
|
||
<?php endif; ?>
|
||
|
||
<!-- ── Info / Profile-editor layer ───────────────────────────────────────── -->
|
||
<div id="vv-ry-layer">
|
||
|
||
<!-- Info view (default) -->
|
||
<div id="vv-ry-info-view">
|
||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:10px;flex-wrap:wrap;gap:8px;">
|
||
<span style="font-size:11px;font-weight:700;color:#666;text-transform:uppercase;letter-spacing:.07em;">Rsync Reference</span>
|
||
<button onclick="vvRpOpen()"
|
||
style="background:#0a1a2a;border:1px solid #1a3a5a;color:#4a9eff;font-size:11px;
|
||
padding:4px 14px;border-radius:3px;cursor:pointer;font-weight:600;">⚙ Profile Editor</button>
|
||
</div>
|
||
|
||
<!-- How to use this page -->
|
||
<div class="vv-ry-info-block">
|
||
<div class="vv-ry-info-hdr" onclick="vvRyInfoToggle(this)">
|
||
<span class="vv-ry-info-chev">▾</span> How to use this page
|
||
</div>
|
||
<div class="vv-ry-info-body">
|
||
<div class="vv-ry-info-cols">
|
||
<div><strong>Status card</strong> — shows whether the global rsync gate is open. Red = all syncs blocked regardless of tier toggles.</div>
|
||
<div><strong>Sync windows</strong> — per-tier enable/disable. A tier can be enabled here but still blocked by the global gate.</div>
|
||
<div><strong>Settings → Global gate</strong> — RSYNC_ENABLED in master.conf. Master switch; turn off during rebuilds or maintenance.</div>
|
||
<div><strong>Settings → Tier toggles</strong> — CRITICAL/DAILY/etc._RSYNC_ENABLED. Disable one tier without affecting others.</div>
|
||
<div><strong>Bandwidth history</strong> — last 30 days of per-profile rsync runs. Bar = relative time spent. Click recent runs for detail.</div>
|
||
<div><strong>Manual Rsync</strong> — one-off transfer to any partner host. Browse remote dirs via SSH. Runs in background, output streams below.</div>
|
||
<div><strong>Profile Editor</strong> — create and edit named profiles used by appdata syncs. Each profile controls flags, BW limit, container stop lists, and excludes.</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Sync windows explained -->
|
||
<div class="vv-ry-info-block">
|
||
<div class="vv-ry-info-hdr" onclick="vvRyInfoToggle(this)">
|
||
<span class="vv-ry-info-chev">▸</span> Sync windows
|
||
</div>
|
||
<div class="vv-ry-info-body" style="display:none;">
|
||
<div class="vv-ry-info-cols">
|
||
<div><strong>Critical (30 min)</strong> — appdata (arrs, auth, databases), partnership health check, play state sync. Designed to be fast — uses dirty-sync profiles where possible.</div>
|
||
<div><strong>Intermediate (4 hr)</strong> — arr sync between the nightly runs. Catches new indexer hits without waiting until midnight.</div>
|
||
<div><strong>Daily (nightly)</strong> — media shares, arr maintenance, media cleaner, docker updates, daily container restarts.</div>
|
||
<div><strong>Weekly</strong> — bulk media shares, update checks, weekly restarts, SMART health digest.</div>
|
||
<div><strong>Fallback</strong> — fires when a server comes back online after being the active fallback. Syncs any changes that happened during downtime.</div>
|
||
<div><strong>Two-tier toggle</strong> — global gate (RSYNC_ENABLED) blocks everything. Per-tier flags (e.g. DAILY_RSYNC_ENABLED) disable just that window. Both must be ON for syncs to run.</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Profile system -->
|
||
<div class="vv-ry-info-block">
|
||
<div class="vv-ry-info-hdr" onclick="vvRyInfoToggle(this)">
|
||
<span class="vv-ry-info-chev">▸</span> Profile system
|
||
</div>
|
||
<div class="vv-ry-info-body" style="display:none;">
|
||
<div class="vv-ry-info-cols">
|
||
<div><strong>What is a profile?</strong> — A named set of rsync options applied to a specific appdata directory. Appdata shares match a profile by their directory basename (lowercased).</div>
|
||
<div><strong>PROFILE_RSYNC_OPTS</strong> — Full rsync flag string for this profile. Does NOT inherit DEFAULT_RSYNC_OPTS — list every desired flag explicitly. Use <code>$BW_LIMIT</code> to reference the profile's bandwidth limit.</div>
|
||
<div><strong>PROFILE_BW_LIMIT</strong> — Bandwidth cap in KB/s. Overrides the global BW_LIMIT for this profile.</div>
|
||
<div><strong>PROFILE_RETRY_COUNT / SLEEP</strong> — How many times to retry on failure, and how long to wait between attempts.</div>
|
||
<div><strong>PROFILE_CRITICAL_CONTAINER_NAMES</strong> — Containers stopped on BOTH hosts before rsync. Local stops first (flush DBs), then remote. Only running containers get restarted — stopped ones stay stopped.</div>
|
||
<div><strong>PROFILE_DELAYED_CONTAINERS</strong> — Containers that need a delay after the others start (e.g. NextCloud waits for Postgres).</div>
|
||
<div><strong>PROFILE_CONTAINER_DELAY</strong> — Seconds to wait before starting delayed containers.</div>
|
||
<div><strong>PROFILE_EXCLUDE_DIRS</strong> — Space-separated patterns excluded from the rsync. Supports globs (e.g. <code>logs *.tmp *.db-wal</code>).</div>
|
||
<div><strong>PROFILE_REMOTE_RESTART_CONTAINERS</strong> — Used by dirty-sync profiles. Restarts these containers on the remote after sync so it picks up the new data.</div>
|
||
<div><strong>Clean vs dirty sync</strong> — Clean: stop containers, <code>--delete</code>, full mirror. Dirty: containers keep running, WAL files excluded, <code>--inplace --no-whole-file</code> for safe partial writes. Use dirty for frequent critical syncs.</div>
|
||
<div><strong>App layer</strong> — Scripts add their appdata directory to HOST*_CRITICAL_SYNC_SHARES or HOST*_DAILY_SYNC_SHARES using <code>/path/to/dir|profile-name</code> syntax. Creating the profile here makes it available for that reference.</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Rsync flags reference -->
|
||
<div class="vv-ry-info-block">
|
||
<div class="vv-ry-info-hdr" onclick="vvRyInfoToggle(this)">
|
||
<span class="vv-ry-info-chev">▸</span> Common flag combinations
|
||
</div>
|
||
<div class="vv-ry-info-body" style="display:none;">
|
||
<div class="vv-ry-info-cols">
|
||
<div><strong>Clean sync (weekly appdata)</strong><br><code>-av --human-readable --bwlimit=$BW_LIMIT --delete</code><br>Full mirror — removes files on destination that no longer exist at source. Safe when containers are stopped.</div>
|
||
<div><strong>Dirty sync (every 30 min)</strong><br><code>-av --human-readable --bwlimit=$BW_LIMIT --delete --inplace --no-whole-file</code><br>Containers stay running. <code>--inplace</code> writes directly to destination file (avoids temp-file rename issues). <code>--no-whole-file</code> uses delta transfer. Exclude WAL files to avoid corrupt state.</div>
|
||
<div><strong>Database sync (arrs)</strong><br><code>-av --info=progress2 --human-readable --bwlimit=$BW_LIMIT --delete --inplace</code><br>Stop containers first. <code>--info=progress2</code> gives cleaner progress output in logs. Lower BW limit to not starve other syncs running concurrently.</div>
|
||
<div><strong>Large media (weekly)</strong><br><code>-av --human-readable --bwlimit=$BW_LIMIT --delete --inplace --no-whole-file</code><br>High BW limit, inplace for large files, delete to keep mirror clean. No container stops — media files are read-only.</div>
|
||
<div><code>--delete</code> — remove files at destination that no longer exist at source. Essential for a true mirror.</div>
|
||
<div><code>--inplace</code> — write file data directly into destination file instead of creating a temp copy first. Faster for large files; required for some databases.</div>
|
||
<div><code>--no-whole-file</code> — force delta transfer even over fast LAN. Reduces data sent for files that change incrementally.</div>
|
||
<div><code>--checksum</code> — compare files by checksum rather than mtime+size. Slower but catches any corruption. Use for archive syncs, not frequent syncs.</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div><!-- /info view -->
|
||
|
||
<!-- Profile editor view (hidden until button click) -->
|
||
<div id="vv-rp-view" style="display:none;">
|
||
<div style="display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;">
|
||
<button onclick="vvRpClose()"
|
||
style="background:#111;border:1px solid #222;color:#555;font-size:11px;
|
||
padding:4px 12px;border-radius:3px;cursor:pointer;">← Rsync Info</button>
|
||
<span style="font-size:11px;font-weight:700;color:#666;text-transform:uppercase;letter-spacing:.07em;">Profile Editor</span>
|
||
<div style="margin-left:auto;display:flex;align-items:center;gap:6px;">
|
||
<select id="vv-rp-selector"
|
||
style="background:#111;border:1px solid #2a2a2a;color:#bbb;font-size:11px;
|
||
padding:3px 8px;border-radius:3px;outline:none;cursor:pointer;max-width:200px;"
|
||
onchange="vvRpOnSelect(this.value)">
|
||
<option value="">Loading…</option>
|
||
</select>
|
||
<button onclick="vvRpNew()"
|
||
style="background:#0a1a0a;border:1px solid #1a3a1a;color:#4caf50;
|
||
font-size:11px;padding:3px 12px;border-radius:3px;cursor:pointer;font-weight:600;">+ New</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Empty state -->
|
||
<div id="vv-rp-empty" class="vv-ry-card" style="color:#333;font-size:11px;padding:10px 14px;">
|
||
Select a profile above to edit, or click + New to create one.
|
||
</div>
|
||
|
||
<!-- Profile form -->
|
||
<div id="vv-rp-form" style="display:none;">
|
||
<div class="vv-ry-card">
|
||
|
||
<!-- Name row -->
|
||
<div style="display:flex;align-items:center;gap:10px;margin-bottom:12px;flex-wrap:wrap;">
|
||
<span style="font-size:10px;font-weight:700;color:#555;text-transform:uppercase;letter-spacing:.06em;width:110px;flex-shrink:0;">Profile name</span>
|
||
<input id="vv-rp-name" type="text"
|
||
style="background:#0d0d0d;border:1px solid #2a2a2a;border-radius:3px;color:#ccc;
|
||
font-size:12px;padding:4px 8px;font-family:monospace;outline:none;width:200px;"
|
||
placeholder="e.g. my-app">
|
||
<span id="vv-rp-name-hint" style="font-size:10px;color:#333;"></span>
|
||
</div>
|
||
<hr class="vv-ry-sep">
|
||
|
||
<!-- Rsync opts — checkboxes + preview -->
|
||
<div style="margin-bottom:10px;">
|
||
<div style="font-size:10px;font-weight:700;color:#555;text-transform:uppercase;letter-spacing:.06em;margin-bottom:8px;">
|
||
Rsync opts
|
||
<span style="font-size:9px;font-weight:normal;color:#2a2a2a;margin-left:6px;">does NOT inherit defaults — list every desired flag</span>
|
||
</div>
|
||
<div style="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:8px;" id="vv-rp-flag-group">
|
||
<label class="vv-rp-flag"><input type="checkbox" data-flag="-av" onchange="vvRpBuildOpts()"><span>-av</span></label>
|
||
<label class="vv-rp-flag"><input type="checkbox" data-flag="--human-readable" onchange="vvRpBuildOpts()"><span>--human-readable</span></label>
|
||
<label class="vv-rp-flag"><input type="checkbox" data-flag="--info=progress2" onchange="vvRpBuildOpts()"><span>--info=progress2</span></label>
|
||
<span style="border-left:1px solid #1e1e1e;margin:0 4px;"></span>
|
||
<label class="vv-rp-flag"><input type="checkbox" data-flag="--delete" onchange="vvRpBuildOpts()"><span>--delete</span></label>
|
||
<label class="vv-rp-flag"><input type="checkbox" data-flag="--inplace" onchange="vvRpBuildOpts()"><span>--inplace</span></label>
|
||
<label class="vv-rp-flag"><input type="checkbox" data-flag="--no-whole-file" onchange="vvRpBuildOpts()"><span>--no-whole-file</span></label>
|
||
<label class="vv-rp-flag"><input type="checkbox" data-flag="--checksum" onchange="vvRpBuildOpts()"><span>--checksum</span></label>
|
||
<span style="border-left:1px solid #1e1e1e;margin:0 4px;"></span>
|
||
<label class="vv-rp-flag"><input type="checkbox" data-flag="--bwlimit=$BW_LIMIT" onchange="vvRpBuildOpts()"><span>--bwlimit=<em>$BW_LIMIT</em></span></label>
|
||
<label class="vv-rp-flag"><input type="checkbox" data-flag="--compress" onchange="vvRpBuildOpts()"><span>--compress</span></label>
|
||
</div>
|
||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;">
|
||
<span style="font-size:10px;color:#444;flex-shrink:0;">Extra flags</span>
|
||
<input id="vv-rp-opts-extra" type="text"
|
||
style="background:#0d0d0d;border:1px solid #1e1e1e;border-radius:3px;color:#888;
|
||
font-size:11px;padding:3px 7px;font-family:monospace;outline:none;flex:1;"
|
||
placeholder="any additional flags"
|
||
oninput="vvRpBuildOpts()">
|
||
</div>
|
||
<div style="background:#080808;border:1px solid #181818;border-radius:3px;padding:5px 9px;
|
||
font-family:monospace;font-size:11px;color:#4a6a4a;display:flex;align-items:baseline;gap:8px;">
|
||
<span style="font-size:9px;color:#2a2a2a;flex-shrink:0;">rsync_opts =</span>
|
||
<span id="vv-rp-opts-preview" style="word-break:break-all;"></span>
|
||
</div>
|
||
<input type="hidden" id="vv-rp-rsync_opts">
|
||
</div>
|
||
<hr class="vv-ry-sep">
|
||
|
||
<!-- Numeric fields -->
|
||
<div style="display:flex;gap:12px;flex-wrap:wrap;margin-bottom:10px;">
|
||
<div>
|
||
<div style="font-size:9px;color:#444;margin-bottom:3px;">BW limit <span style="color:#2a2a2a;">KB/s</span></div>
|
||
<input class="vv-ry-set-inp" id="vv-rp-bw_limit" type="number" min="0" style="width:80px;">
|
||
</div>
|
||
<div>
|
||
<div style="font-size:9px;color:#444;margin-bottom:3px;">Retry count</div>
|
||
<input class="vv-ry-set-inp" id="vv-rp-retry_count" type="number" min="0" max="20" style="width:60px;">
|
||
</div>
|
||
<div>
|
||
<div style="font-size:9px;color:#444;margin-bottom:3px;">Retry sleep <span style="color:#2a2a2a;">s</span></div>
|
||
<input class="vv-ry-set-inp" id="vv-rp-sleep" type="number" min="0" style="width:70px;">
|
||
</div>
|
||
<div>
|
||
<div style="font-size:9px;color:#444;margin-bottom:3px;">Container delay <span style="color:#2a2a2a;">s</span></div>
|
||
<input class="vv-ry-set-inp" id="vv-rp-container_delay" type="number" min="0" style="width:70px;">
|
||
</div>
|
||
</div>
|
||
<hr class="vv-ry-sep">
|
||
|
||
<!-- Container / path fields -->
|
||
<div style="display:flex;flex-direction:column;gap:8px;">
|
||
<div>
|
||
<div style="font-size:9px;color:#444;margin-bottom:3px;">Stop containers <span style="color:#2a2a2a;">space-separated — stopped on both hosts before rsync, restarted after</span></div>
|
||
<input class="vv-ry-set-inp" id="vv-rp-critical_containers" type="text" style="width:100%;box-sizing:border-box;font-family:monospace;">
|
||
</div>
|
||
<div>
|
||
<div style="font-size:9px;color:#444;margin-bottom:3px;">Delayed containers <span style="color:#2a2a2a;">start after delay (e.g. app waits for its database)</span></div>
|
||
<input class="vv-ry-set-inp" id="vv-rp-delayed_containers" type="text" style="width:100%;box-sizing:border-box;font-family:monospace;">
|
||
</div>
|
||
<div>
|
||
<div style="font-size:9px;color:#444;margin-bottom:3px;">Exclude dirs <span style="color:#2a2a2a;">space-separated globs — e.g. <code>logs *.tmp *.db-wal *.db-shm</code></span></div>
|
||
<input class="vv-ry-set-inp" id="vv-rp-exclude_dirs" type="text" style="width:100%;box-sizing:border-box;font-family:monospace;">
|
||
</div>
|
||
<div>
|
||
<div style="font-size:9px;color:#444;margin-bottom:3px;">Remote restart <span style="color:#2a2a2a;">dirty-sync only — restarted on remote after sync so it picks up changes</span></div>
|
||
<input class="vv-ry-set-inp" id="vv-rp-remote_restart" type="text" style="width:100%;box-sizing:border-box;font-family:monospace;">
|
||
</div>
|
||
</div>
|
||
|
||
<div style="margin-top:14px;display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
|
||
<button class="vv-ry-save-btn" onclick="vvRpSave()">Save Profile</button>
|
||
<button id="vv-rp-del-btn" onclick="vvRpDelete()"
|
||
style="background:#1a0a0a;border:1px solid #3a1a1a;color:#ef5350;font-size:11px;
|
||
padding:4px 12px;border-radius:3px;cursor:pointer;display:none;">Delete</button>
|
||
<span id="vv-rp-fb" class="vv-ry-feedback"></span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div><!-- /profile view -->
|
||
|
||
</div><!-- /layer -->
|
||
|
||
|
||
<?php
|
||
// The conf sections this page is about, drawn by the shared renderer. They were reachable
|
||
// only from the Settings tab's catch-all, which is a long way to go for a setting named
|
||
// after the page you are already looking at.
|
||
vv_conf_ui_card('vv-cf-rsync', 'rsync', 'Rsync settings');
|
||
?>
|
||
<script>
|
||
// Shared state — globals so IIFE internals and onclick handlers can both reach them
|
||
var _vvRyWinOpen = new Set(); // which window panels are expanded
|
||
var _vvRyLastData = null; // last successful poll response
|
||
var _vvRyEditMode = false; // true = skip poll re-renders
|
||
var _vvRyWinEditing = null; // key of window currently in edit mode
|
||
var _vvRyWinEState = {}; // {key: {scripts:[{id,enabled}], shares:[{path,profile}]}}
|
||
var _vvRyScriptLib = null; // cached {Folder:[{id,label}]} — null = not yet loaded
|
||
var _vvRyProfiles = null; // cached profile name list
|
||
|
||
// View-mode panel inner HTML — shared by _windowsRow (inside IIFE) and vvRyWinCancel (outside)
|
||
function _vvRyViewPanelInner(key, cfg) {
|
||
const scripts = cfg.scripts || [];
|
||
const shares = cfg.shares || [];
|
||
let inner = '';
|
||
|
||
if (scripts.length) {
|
||
inner += `<div style="font-size:9px;font-weight:700;color:#2a2a2a;text-transform:uppercase;letter-spacing:.07em;margin-bottom:4px;">Scripts</div>`;
|
||
for (const s of scripts) {
|
||
const parts = s.split('/');
|
||
const folder = parts.length > 1 ? parts.slice(0, -1).join('/') : '';
|
||
const name = parts[parts.length - 1].replace(/\.sh$/, '');
|
||
inner += `<div style="font-size:10px;color:#555;padding:1px 0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">
|
||
${folder ? `<span style="color:#2a2a2a;">${folder}/</span>` : ''}<span>${name}</span>
|
||
</div>`;
|
||
}
|
||
}
|
||
|
||
if (shares.length) {
|
||
if (scripts.length) inner += `<div style="border-top:1px solid #161616;margin:6px 0;"></div>`;
|
||
inner += `<div style="font-size:9px;font-weight:700;color:#2a2a2a;text-transform:uppercase;letter-spacing:.07em;margin-bottom:4px;">Sync shares</div>`;
|
||
for (const sh of shares) {
|
||
const [rawPath, profile] = sh.split('|');
|
||
const basename = rawPath.trim().split('/').pop();
|
||
inner += `<div style="font-size:10px;color:#555;padding:1px 0;display:flex;align-items:center;gap:5px;min-width:0;">
|
||
<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;" title="${rawPath.trim()}">${basename}</span>
|
||
${profile ? `<span style="font-size:9px;color:#2a3a2a;flex-shrink:0;font-family:monospace;">${profile.trim()}</span>` : ''}
|
||
</div>`;
|
||
}
|
||
}
|
||
|
||
if (scripts.length || shares.length) {
|
||
inner += `<div style="margin-top:7px;padding-top:6px;border-top:1px solid #161616;">
|
||
<button onclick="event.stopPropagation();vvRyWinEdit('${key}')"
|
||
style="background:#0a1a2a;border:1px solid #1a3a5a;color:#4a9eff;font-size:10px;
|
||
padding:2px 10px;border-radius:3px;cursor:pointer;">⚙ Edit</button>
|
||
</div>`;
|
||
}
|
||
|
||
return inner;
|
||
}
|
||
|
||
(function() {
|
||
|
||
const WIN_META = {
|
||
critical: { label: 'Critical', cadence: '30 min' },
|
||
intermediate: { label: 'Intermediate', cadence: '4 hr' },
|
||
daily: { label: 'Daily', cadence: 'nightly' },
|
||
weekly: { label: 'Weekly', cadence: 'weekly' },
|
||
monthly: { label: 'Monthly', cadence: '30-day gate' },
|
||
fallback: { label: 'Fallback', cadence: 'on handback' },
|
||
};
|
||
|
||
const PROF_COLORS = [
|
||
'#4caf50','#4a9eff','#ffb74d','#ce93d8','#ef5350',
|
||
'#4dd0e1','#a5d6a7','#ff8a65','#90caf9','#f48fb1',
|
||
];
|
||
|
||
function _dur(s) {
|
||
if (!s) return '—';
|
||
const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), sec = s % 60;
|
||
if (h) return h + 'h ' + m + 'm';
|
||
if (m) return m + 'm ' + sec + 's';
|
||
return sec + 's';
|
||
}
|
||
|
||
// Escapes text going INSIDE a double-quoted HTML attribute. A bare " there ends the attribute
|
||
// early and silently destroys everything after it — an onclick built by string concatenation
|
||
// becomes a syntax error and the element simply stops responding, with nothing logged.
|
||
//
|
||
// Duplicated from scheduler.php rather than shared: js/varaverk.js is loaded after the page
|
||
// partials, so a page cannot rely on it during its own setup. Four lines in two places beats a
|
||
// load-order bug that only shows up on a slow load.
|
||
function vvRyEscAttr(s) {
|
||
return String(s).replace(/&/g,'&').replace(/"/g,'"')
|
||
.replace(/</g,'<').replace(/>/g,'>');
|
||
}
|
||
|
||
// ── Two status vocabularies, one colour ───────────────────────────────────────
|
||
// Transfers record success/failed; orchestrator run records record ok/warn/running. This page is
|
||
// fed both and compared everything against 'success', so every window that had run perfectly well
|
||
// rendered in the failure colour — four red "ok"s describing four clean runs.
|
||
//
|
||
// Written as an allow-list of the good states rather than a test for the bad ones: a status this
|
||
// does not recognise should read as a problem and be looked at, not be quietly coloured green.
|
||
function _statusCol(s) {
|
||
const v = String(s || '').toLowerCase();
|
||
if (v === 'success' || v === 'ok') return '#4caf50';
|
||
if (v === 'running' || v === 'warn') return '#ffb74d';
|
||
return '#ef5350';
|
||
}
|
||
function _statusOk(s) { return _statusCol(s) === '#4caf50'; }
|
||
|
||
// ── Status + active card ──────────────────────────────────────────────────────
|
||
function _statusCard(data) {
|
||
const en = data.enabled;
|
||
const dotC = en ? '#4caf50' : '#ef5350';
|
||
const active = data.active || [];
|
||
|
||
// The one figure on this page that answers "has anything actually been copied", as opposed to
|
||
// "did the window that contains rsync run". Those diverged the day the global gate closed and
|
||
// nothing said so — the windows kept reporting healthy runs because their orchestrators kept
|
||
// doing their other work. Aged deliberately: a transfer that is weeks old is the finding.
|
||
const xfer = data.last_transfer;
|
||
const xferAge = xfer && xfer.ts ? Math.floor(Date.now() / 1000) - xfer.ts : null;
|
||
const xferCol = xferAge == null ? '#555'
|
||
: xferAge > 86400 * 7 ? '#ef5350'
|
||
: xferAge > 86400 * 2 ? '#ffb74d' : '#4caf50';
|
||
const xferHtml = !xfer || !xfer.ts
|
||
? '<div style="color:#333;font-size:11px;margin-top:4px;">No transfer has ever been recorded</div>'
|
||
: `<div style="display:flex;align-items:baseline;gap:7px;margin-top:4px;">
|
||
<span style="font-size:12px;color:${xferCol};">${_relTime(xfer.ts)}</span>
|
||
<span style="font-size:11px;color:#555;">${vvEscHtml(xfer.profile || '')}</span>
|
||
<span style="font-size:10px;color:${_statusCol(xfer.status)};margin-left:auto;">${vvEscHtml(xfer.status || '')}</span>
|
||
</div>`;
|
||
|
||
let activeHtml;
|
||
if (active.length === 0) {
|
||
activeHtml = '<div style="color:#333;font-size:11px;margin-top:4px;">No transfers running</div>';
|
||
} else {
|
||
activeHtml = active.map(a =>
|
||
`<div style="display:flex;align-items:center;gap:7px;margin:3px 0;">
|
||
<span class="vv-ry-pill run">RUNNING</span>
|
||
<span style="font-size:12px;color:#bbb;">${a.profile}</span>
|
||
<span style="font-size:11px;color:#555;margin-left:auto;">${_dur(a.elapsed)}</span>
|
||
</div>`
|
||
).join('');
|
||
}
|
||
|
||
return `<div class="vv-ry-card" style="grid-column:span 4;">
|
||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
|
||
<span class="vv-ry-dot" style="background:${dotC}"></span>
|
||
<span style="font-size:13px;font-weight:bold;color:${en ? '#4caf50' : '#ef5350'};">
|
||
${en ? 'RSYNC ENABLED' : 'RSYNC DISABLED'}
|
||
</span>
|
||
<span class="vv-ry-pill ${en ? 'on' : 'err'}" style="margin-left:auto;">
|
||
${en ? 'global gate open' : 'all syncs blocked'}
|
||
</span>
|
||
</div>
|
||
<hr class="vv-ry-sep">
|
||
<div class="vv-ry-sec">Active transfers</div>
|
||
${activeHtml}
|
||
<hr class="vv-ry-sep">
|
||
<div class="vv-ry-sec">Last transfer</div>
|
||
${xferHtml}
|
||
</div>`;
|
||
}
|
||
|
||
// ── Last sync summary card ────────────────────────────────────────────────────
|
||
function _lastSyncCard(data) {
|
||
const ls = data.last_sync || {};
|
||
const wins = ['critical','intermediate','daily','weekly'];
|
||
const rows = wins.map(k => {
|
||
const e = ls[k];
|
||
if (!e || !e.ts) return `<div class="vv-ry-row">
|
||
<span class="vv-ry-lbl">${WIN_META[k]?.label || k}</span>
|
||
<span class="vv-ry-val" style="color:#333;">never</span>
|
||
</div>`;
|
||
const cls = 'color:' + _statusCol(e.status);
|
||
return `<div class="vv-ry-row">
|
||
<span class="vv-ry-lbl">${WIN_META[k]?.label || k}</span>
|
||
<span class="vv-ry-val">
|
||
<span style="${cls}">${e.status}</span>
|
||
<span style="color:#444;margin-left:6px;">${_relTime(e.ts)}</span>
|
||
${e.duration ? `<span style="color:#3a3a3a;margin-left:4px;">${_dur(e.duration)}</span>` : ''}
|
||
</span>
|
||
</div>`;
|
||
});
|
||
|
||
// Named for what it measures. These are the maintenance orchestrators that contain the rsync
|
||
// step, not the rsync step — daily_sync_maintenance also does the git pull, permissions, the
|
||
// cleaners, arr cleanup and docker updates, so its duration is mostly not transfer time and it
|
||
// reports "ok" whether or not RSYNC_ENABLED let a single byte move. "Last completed run" read
|
||
// as a sync result on a page called Rsync; the transfer figure is in the status card above.
|
||
return `<div class="vv-ry-card" style="grid-column:span 4;">
|
||
<div class="vv-ry-sec">Window orchestrator — last run</div>
|
||
<div style="font-size:10px;color:#3a3a3a;margin:-2px 0 6px;">
|
||
The job that contains the rsync step, not the transfer itself
|
||
</div>
|
||
${rows.join('')}
|
||
</div>`;
|
||
}
|
||
|
||
// ── Window cards row ──────────────────────────────────────────────────────────
|
||
function _windowsRow(data) {
|
||
const windows = data.windows || {};
|
||
const lastSync = data.last_sync || {};
|
||
const winArr = data.win_arrays || {};
|
||
|
||
const cards = Object.entries(WIN_META).map(([key, meta]) => {
|
||
const enabled = windows[key] !== false;
|
||
const ls = lastSync[key];
|
||
const cls = enabled ? 'enabled' : 'disabled';
|
||
const cfg = winArr[key] || {};
|
||
const scripts = cfg.scripts || [];
|
||
const shares = cfg.shares || [];
|
||
|
||
let statusHtml = '<div class="vv-ry-win-stat">No run history</div>';
|
||
if (ls && ls.ts) {
|
||
const okCol = _statusCol(ls.status);
|
||
statusHtml = `<div class="vv-ry-win-stat" style="color:${okCol};">${ls.status}</div>
|
||
<div class="vv-ry-win-dur">${_relTime(ls.ts)}${ls.duration ? ' · ' + _dur(ls.duration) : ''}</div>`;
|
||
}
|
||
|
||
const expandable = !!(scripts.length || shares.length);
|
||
const panelHtml = expandable
|
||
? `<div class="vv-ry-win-panel" style="display:none;margin-top:9px;padding-top:8px;border-top:1px solid #1e1e1e;">${_vvRyViewPanelInner(key, cfg)}</div>`
|
||
: '';
|
||
|
||
return `<div class="vv-ry-win ${cls}" data-win-key="${key}"
|
||
${expandable ? 'onclick="vvRyWinExpand(this)" style="cursor:pointer;"' : ''}>
|
||
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:4px;">
|
||
<div style="min-width:0;">
|
||
<div class="vv-ry-win-lbl">${meta.cadence}</div>
|
||
<div class="vv-ry-win-name">${meta.label}</div>
|
||
<span class="vv-ry-pill ${enabled ? 'on' : 'off'}">${enabled ? 'enabled' : 'disabled'}</span>
|
||
${statusHtml}
|
||
</div>
|
||
${expandable ? `<span class="vv-ry-win-chev" style="font-size:9px;color:#2a2a2a;flex-shrink:0;padding-top:2px;">▸</span>` : ''}
|
||
</div>
|
||
${panelHtml}
|
||
</div>`;
|
||
});
|
||
|
||
// The column count travels with the cards, so adding a window to WIN_META widens the row
|
||
// instead of quietly starting a second one.
|
||
return `<div class="vv-ry-wins" style="--vv-win-n:${cards.length}">${cards.join('')}</div>`;
|
||
}
|
||
|
||
// ── Bandwidth section ─────────────────────────────────────────────────────────
|
||
function _bwSection(data) {
|
||
const history = data.bw_history || [];
|
||
if (!history.length) {
|
||
return `<div class="vv-ry-card" style="grid-column:1/-1;">
|
||
<div class="vv-ry-sec">Bandwidth history</div>
|
||
<div style="color:#333;font-size:11px;padding:8px 0;">No data in last 30 days</div>
|
||
</div>`;
|
||
}
|
||
|
||
// Per-profile stats
|
||
const profileMap = {};
|
||
const profileOrder = [];
|
||
for (const r of history) {
|
||
if (!profileMap[r.profile]) {
|
||
profileMap[r.profile] = { runs: 0, success: 0, totalDur: 0, totalBytes: 0 };
|
||
profileOrder.push(r.profile);
|
||
}
|
||
const p = profileMap[r.profile];
|
||
p.runs++;
|
||
if (_statusOk(r.status)) p.success++;
|
||
p.totalDur += r.duration;
|
||
p.totalBytes += r.bytes;
|
||
}
|
||
|
||
const maxDur = Math.max(...Object.values(profileMap).map(p => p.totalDur), 1);
|
||
|
||
const profRows = profileOrder.map((name, i) => {
|
||
const p = profileMap[name];
|
||
const col = PROF_COLORS[i % PROF_COLORS.length];
|
||
const pct = Math.round((p.totalDur / maxDur) * 100);
|
||
const rate = Math.round((p.success / p.runs) * 100);
|
||
const byteStr = p.totalBytes > 0 ? _fmtBytes(p.totalBytes) : _dur(p.totalDur) + ' total';
|
||
return `<div class="vv-ry-prof-row">
|
||
<span class="vv-ry-prof-nm">${name}</span>
|
||
<div class="vv-ry-prof-bar"><div class="vv-ry-prof-fill" style="width:${pct}%;background:${col}"></div></div>
|
||
<span class="vv-ry-prof-cnt">${p.runs} runs · ${byteStr}</span>
|
||
</div>`;
|
||
});
|
||
|
||
// Recent runs (last 20, newest first)
|
||
const recent = [...history].reverse().slice(0, 20);
|
||
const runRows = recent.map(r => {
|
||
const stCls = _statusOk(r.status) ? 'on' : 'err';
|
||
const durStr = r.duration >= 3600
|
||
? Math.floor(r.duration / 3600) + 'h ' + Math.floor((r.duration % 3600) / 60) + 'm'
|
||
: r.duration >= 60
|
||
? Math.floor(r.duration / 60) + 'm'
|
||
: r.duration + 's';
|
||
return `<div class="vv-ry-run-row">
|
||
<span class="vv-ry-run-dt">${r.date}</span>
|
||
<span class="vv-ry-run-tm">${r.time}</span>
|
||
<span class="vv-ry-run-pr">${r.profile}</span>
|
||
<span class="vv-ry-run-dur">${durStr}</span>
|
||
<span class="vv-ry-run-st"><span class="vv-ry-pill ${stCls}" style="font-size:9px;">${r.status}</span></span>
|
||
</div>`;
|
||
});
|
||
|
||
return `<div class="vv-ry-bw" style="grid-column:1/-1;">
|
||
<div class="vv-ry-card">
|
||
<div class="vv-ry-sec">Profile activity — last 30 days</div>
|
||
${profRows.join('')}
|
||
</div>
|
||
<div class="vv-ry-card">
|
||
<div class="vv-ry-sec">Recent runs</div>
|
||
<div style="font-size:10px;color:#3a3a3a;margin-bottom:4px;display:grid;grid-template-columns:80px 90px 1fr 55px 48px;gap:4px;">
|
||
<span>Date</span><span>Time</span><span>Profile</span><span style="text-align:right;">Duration</span><span></span>
|
||
</div>
|
||
<div class="vv-ry-runs-scroll">${runRows.join('')}</div>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
// ── Sync log card ─────────────────────────────────────────────────────────────
|
||
function _esc(s) {
|
||
return (s||'').replace(/&/g,'&').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-cf-track ${currentVal ? 'on' : ''}" id="${id}"
|
||
onclick="vvRyToggle(this,'${name}')"></div>
|
||
<span class="vv-ry-toggle-lbl">${label}</span>
|
||
</label>`;
|
||
}
|
||
|
||
function _settingsSection(data) {
|
||
const w = data.windows || {};
|
||
const s = data.settings || {};
|
||
const en = data.enabled;
|
||
|
||
const mbit = s.bw_limit ? ' (~' + (s.bw_limit / 125).toFixed(0) + ' Mbit/s)' : '';
|
||
|
||
return `<div class="vv-ry-card" style="grid-column:6/-1;">
|
||
<div class="vv-ry-sec" style="margin-bottom:10px;">Settings</div>
|
||
|
||
<div style="margin-bottom:12px;">
|
||
<div style="font-size:10px;color:#444;letter-spacing:.05em;text-transform:uppercase;margin-bottom:6px;">Global gate</div>
|
||
${_toggle('RSYNC_ENABLED', en, 'RSYNC_ENABLED — blocks all syncs when off')}
|
||
</div>
|
||
|
||
<hr class="vv-ry-sep">
|
||
|
||
<div style="margin-bottom:12px;">
|
||
<div style="font-size:10px;color:#444;letter-spacing:.05em;text-transform:uppercase;margin-bottom:6px;">Sync windows</div>
|
||
<div class="vv-ry-set-grid">
|
||
${_toggle('CRITICAL_RSYNC_ENABLED', w.critical, 'Critical')}
|
||
${_toggle('INTERMEDIATE_RSYNC_ENABLED', w.intermediate, 'Intermediate')}
|
||
${_toggle('DAILY_RSYNC_ENABLED', w.daily, 'Daily')}
|
||
${_toggle('WEEKLY_RSYNC_ENABLED', w.weekly, 'Weekly')}
|
||
${_toggle('MONTHLY_RSYNC_ENABLED', w.monthly, 'Monthly')}
|
||
${_toggle('FALLBACK_RSYNC_ENABLED', w.fallback, 'Fallback')}
|
||
</div>
|
||
</div>
|
||
|
||
<hr class="vv-ry-sep">
|
||
|
||
<div>
|
||
<div style="font-size:10px;color:#444;letter-spacing:.05em;text-transform:uppercase;margin-bottom:8px;">Defaults</div>
|
||
<div class="vv-ry-set-row">
|
||
<span class="vv-ry-set-lbl">Bandwidth limit</span>
|
||
<input class="vv-ry-set-inp" id="vv-ry-bwlimit" type="number" min="0" value="${s.bw_limit || 0}">
|
||
<span class="vv-ry-set-hint">KB/s${mbit}</span>
|
||
</div>
|
||
<div class="vv-ry-set-row">
|
||
<span class="vv-ry-set-lbl">Retry count</span>
|
||
<input class="vv-ry-set-inp" id="vv-ry-retry" type="number" min="0" max="20" value="${s.retry_count ?? 3}">
|
||
<span class="vv-ry-set-hint">attempts</span>
|
||
</div>
|
||
<div class="vv-ry-set-row">
|
||
<span class="vv-ry-set-lbl">Retry delay</span>
|
||
<input class="vv-ry-set-inp" id="vv-ry-sleep" type="number" min="0" value="${s.sleep ?? 300}">
|
||
<span class="vv-ry-set-hint">seconds between attempts</span>
|
||
</div>
|
||
<div class="vv-ry-set-row">
|
||
<span class="vv-ry-set-lbl">BW warn threshold</span>
|
||
<input class="vv-ry-set-inp" id="vv-ry-bwwarn" type="number" min="0" value="${s.bw_warn_gb ?? 50}">
|
||
<span class="vv-ry-set-hint">GB — flag large transfers in weekly report</span>
|
||
</div>
|
||
<div style="margin-top:10px;display:flex;align-items:center;">
|
||
<button class="vv-ry-save-btn" id="vv-ry-save" onclick="vvRySaveDefaults()">Save</button>
|
||
<span class="vv-ry-feedback" id="vv-ry-save-fb"></span>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
// ── Main render ───────────────────────────────────────────────────────────────
|
||
function _render(data) {
|
||
_vvRyLastData = data;
|
||
if (_vvRyEditMode) return; // don't clobber the edit UI during a poll
|
||
|
||
let html = '';
|
||
html += _statusCard(data);
|
||
html += _lastSyncCard(data);
|
||
html += _windowsRow(data);
|
||
html += _bwSection(data);
|
||
html += _syncLogCard();
|
||
html += _settingsSection(data);
|
||
document.getElementById('vv-ry-grid').innerHTML = html;
|
||
|
||
_startLogPoll();
|
||
|
||
// Restore any window panels that were open before the re-render
|
||
_vvRyWinOpen.forEach(key => {
|
||
const card = document.querySelector(`.vv-ry-win[data-win-key="${key}"]`);
|
||
if (!card) return;
|
||
const panel = card.querySelector('.vv-ry-win-panel');
|
||
const chev = card.querySelector('.vv-ry-win-chev');
|
||
if (panel) panel.style.display = '';
|
||
if (chev) chev.textContent = '▾';
|
||
});
|
||
|
||
const ts = data.ts
|
||
? new Date(data.ts * 1000).toLocaleString([], {
|
||
month:'numeric', day:'numeric', year:'numeric',
|
||
hour:'2-digit', minute:'2-digit', second:'2-digit'})
|
||
: '';
|
||
document.getElementById('vv-ry-ts').textContent = ts ? 'Updated: ' + ts : '';
|
||
}
|
||
|
||
function vvRyLoad() {
|
||
fetch('/plugins/varaverk/api/rsync.php')
|
||
.then(r => r.json())
|
||
.then(_render)
|
||
.catch(() => {});
|
||
}
|
||
|
||
vvRyLoad();
|
||
setInterval(vvRyLoad, 30000);
|
||
|
||
})();
|
||
|
||
// ── Window card expand/collapse ───────────────────────────────────────────────
|
||
function vvRyWinExpand(card) {
|
||
const key = card.dataset.winKey;
|
||
const panel = card.querySelector('.vv-ry-win-panel');
|
||
const chev = card.querySelector('.vv-ry-win-chev');
|
||
if (!panel) return;
|
||
const open = panel.style.display !== 'none';
|
||
panel.style.display = open ? 'none' : '';
|
||
if (chev) chev.textContent = open ? '▸' : '▾';
|
||
if (key) open ? _vvRyWinOpen.delete(key) : _vvRyWinOpen.add(key);
|
||
}
|
||
|
||
// ── Window card edit ──────────────────────────────────────────────────────────
|
||
|
||
function vvRyWinEdit(key) {
|
||
const cfg = (_vvRyLastData?.win_arrays || {})[key] || {};
|
||
_vvRyWinEState[key] = {
|
||
scripts: (cfg.scripts || []).map(s => ({id: s, enabled: true})),
|
||
shares: (cfg.shares || []).map(s => {
|
||
const [p, pr] = s.split('|');
|
||
return {path: p.trim(), profile: (pr || '').trim()};
|
||
}),
|
||
};
|
||
_vvRyEditMode = true;
|
||
_vvRyWinEditing = key;
|
||
|
||
const go = () => _vvRyBuildEditPanel(key);
|
||
if (!_vvRyScriptLib) {
|
||
fetch('/plugins/varaverk/api/rsync_win_arrays.php?action=list_scripts&_=' + Date.now())
|
||
.then(r => r.json()).then(d => { if (d.ok) _vvRyScriptLib = d.groups; go(); }).catch(go);
|
||
} else { go(); }
|
||
}
|
||
|
||
function vvRyWinCancel(key) {
|
||
_vvRyEditMode = false;
|
||
_vvRyWinEditing = null;
|
||
const cfg = (_vvRyLastData?.win_arrays || {})[key] || {};
|
||
const panel = document.querySelector(`.vv-ry-win[data-win-key="${key}"] .vv-ry-win-panel`);
|
||
if (panel) { panel.innerHTML = _vvRyViewPanelInner(key, cfg); panel.style.display = ''; }
|
||
const chev = document.querySelector(`.vv-ry-win[data-win-key="${key}"] .vv-ry-win-chev`);
|
||
if (chev) chev.textContent = '▾';
|
||
}
|
||
|
||
function vvRyWinSave(key) {
|
||
const state = _vvRyWinEState[key];
|
||
if (!state) return;
|
||
const btn = document.getElementById('vv-ry-esave-' + key);
|
||
const fb = document.getElementById('vv-ry-efb-' + key);
|
||
if (btn) { btn.disabled = true; btn.textContent = 'Saving…'; }
|
||
if (fb) fb.textContent = '';
|
||
|
||
const fd = new URLSearchParams();
|
||
fd.append('action', 'save');
|
||
fd.append('win_key', key);
|
||
fd.append('scripts', JSON.stringify(state.scripts));
|
||
fd.append('shares', JSON.stringify(state.shares));
|
||
|
||
fetch('/plugins/varaverk/api/rsync_win_arrays.php', {method:'POST', body:fd})
|
||
.then(r => r.json())
|
||
.then(d => {
|
||
if (d.ok) {
|
||
// Update cached data so cancel/re-render shows the new values
|
||
if (_vvRyLastData) {
|
||
_vvRyLastData.win_arrays ??= {};
|
||
_vvRyLastData.win_arrays[key] = {
|
||
scripts: state.scripts.map(s => s.id),
|
||
shares: state.shares.map(s => s.profile ? `${s.path}|${s.profile}` : s.path),
|
||
};
|
||
}
|
||
vvRyWinCancel(key);
|
||
} else {
|
||
if (btn) { btn.disabled = false; btn.textContent = 'Save changes'; }
|
||
if (fb) { fb.style.color = '#ef5350'; fb.textContent = (d.errors || ['Save failed']).join(', '); }
|
||
}
|
||
})
|
||
.catch(e => {
|
||
if (btn) { btn.disabled = false; btn.textContent = 'Save changes'; }
|
||
if (fb) { fb.style.color = '#ef5350'; fb.textContent = 'Request failed'; }
|
||
});
|
||
}
|
||
|
||
// ── Edit panel builder ────────────────────────────────────────────────────────
|
||
function _vvRyBuildEditPanel(key) {
|
||
const panel = document.querySelector(`.vv-ry-win[data-win-key="${key}"] .vv-ry-win-panel`);
|
||
if (!panel) return;
|
||
panel.style.display = '';
|
||
const state = _vvRyWinEState[key] || {scripts:[], shares:[]};
|
||
const scripts = state.scripts;
|
||
const shares = state.shares;
|
||
let h = '';
|
||
|
||
// ── Scripts list ─────────────────────────────────────────────────────────────
|
||
h += `<div style="font-size:9px;font-weight:700;color:#2a2a2a;text-transform:uppercase;letter-spacing:.07em;margin-bottom:5px;">Scripts</div>`;
|
||
for (let i = 0; i < scripts.length; i++) {
|
||
const s = scripts[i];
|
||
const parts = s.id.split('/');
|
||
const dir = parts.length > 1 ? parts.slice(0,-1).join('/') : '';
|
||
const name = parts[parts.length-1].replace(/\.sh$/,'');
|
||
h += `<div style="display:flex;align-items:center;gap:3px;padding:2px 0;border-bottom:1px solid #111;">
|
||
<button onclick="event.stopPropagation();vvRyERemScript('${key}',${i})"
|
||
style="background:none;border:none;color:#4a1a1a;cursor:pointer;padding:0 3px;font-size:13px;line-height:1;flex-shrink:0;">×</button>
|
||
<div style="display:flex;flex-direction:column;flex-shrink:0;">
|
||
<button onclick="event.stopPropagation();vvRyEMvScript('${key}',${i},-1)" ${i===0?'disabled':''}
|
||
style="background:none;border:none;color:${i===0?'#1e1e1e':'#3a5a3a'};cursor:${i===0?'default':'pointer'};padding:0;font-size:8px;line-height:1.2;">▲</button>
|
||
<button onclick="event.stopPropagation();vvRyEMvScript('${key}',${i},1)" ${i===scripts.length-1?'disabled':''}
|
||
style="background:none;border:none;color:${i===scripts.length-1?'#1e1e1e':'#3a5a3a'};cursor:${i===scripts.length-1?'default':'pointer'};padding:0;font-size:8px;line-height:1.2;">▼</button>
|
||
</div>
|
||
<span style="font-size:10px;color:#666;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;">
|
||
${dir?`<span style="color:#2a2a2a;">${dir}/</span>`:''}${name}
|
||
</span>
|
||
</div>`;
|
||
}
|
||
// Script add row
|
||
const existIds = new Set(scripts.map(s => s.id));
|
||
h += `<div style="display:flex;gap:4px;margin-top:5px;align-items:center;" onclick="event.stopPropagation()">
|
||
<select id="vv-ry-esel-${key}"
|
||
style="background:#0d0d0d;border:1px solid #1e1e1e;border-radius:3px;color:#666;
|
||
font-size:10px;padding:3px 5px;flex:1;outline:none;min-width:0;">
|
||
<option value="">— add script —</option>`;
|
||
if (_vvRyScriptLib) {
|
||
for (const [folder, items] of Object.entries(_vvRyScriptLib)) {
|
||
const avail = items.filter(it => !existIds.has(it.id));
|
||
if (!avail.length) continue;
|
||
h += `<optgroup label="${folder}">`;
|
||
for (const it of avail) h += `<option value="${it.id}">${it.label}</option>`;
|
||
h += `</optgroup>`;
|
||
}
|
||
}
|
||
h += `</select>
|
||
<button onclick="event.stopPropagation();vvRyEAddScript('${key}')"
|
||
style="background:#0a1a0a;border:1px solid #1a3a1a;color:#4caf50;font-size:10px;
|
||
padding:3px 9px;border-radius:3px;cursor:pointer;flex-shrink:0;">+ Add</button>
|
||
</div>`;
|
||
|
||
// ── Shares list ───────────────────────────────────────────────────────────────
|
||
h += `<div style="border-top:1px solid #161616;margin:8px 0;"></div>
|
||
<div style="font-size:9px;font-weight:700;color:#2a2a2a;text-transform:uppercase;letter-spacing:.07em;margin-bottom:5px;">Sync shares</div>`;
|
||
for (let i = 0; i < shares.length; i++) {
|
||
const sh = shares[i];
|
||
const bn = sh.path.split('/').pop();
|
||
h += `<div style="display:flex;align-items:center;gap:3px;padding:2px 0;border-bottom:1px solid #111;">
|
||
<button onclick="event.stopPropagation();vvRyERemShare('${key}',${i})"
|
||
style="background:none;border:none;color:#4a1a1a;cursor:pointer;padding:0 3px;font-size:13px;line-height:1;flex-shrink:0;">×</button>
|
||
<div style="display:flex;flex-direction:column;flex-shrink:0;">
|
||
<button onclick="event.stopPropagation();vvRyEMvShare('${key}',${i},-1)" ${i===0?'disabled':''}
|
||
style="background:none;border:none;color:${i===0?'#1e1e1e':'#3a5a3a'};cursor:${i===0?'default':'pointer'};padding:0;font-size:8px;line-height:1.2;">▲</button>
|
||
<button onclick="event.stopPropagation();vvRyEMvShare('${key}',${i},1)" ${i===shares.length-1?'disabled':''}
|
||
style="background:none;border:none;color:${i===shares.length-1?'#1e1e1e':'#3a5a3a'};cursor:${i===shares.length-1?'default':'pointer'};padding:0;font-size:8px;line-height:1.2;">▼</button>
|
||
</div>
|
||
<span style="font-size:10px;color:#666;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${sh.path}">${bn}</span>
|
||
${sh.profile?`<span style="font-size:9px;color:#2a3a2a;flex-shrink:0;font-family:monospace;">${sh.profile}</span>`:''}
|
||
</div>`;
|
||
}
|
||
// Share add form
|
||
h += `<div style="margin-top:6px;" onclick="event.stopPropagation()">
|
||
<div style="display:flex;gap:4px;margin-bottom:3px;">
|
||
<input id="vv-ry-epath-${key}" type="text" placeholder="/mnt/user/…" autocomplete="off"
|
||
style="background:#0d0d0d;border:1px solid #1e1e1e;border-radius:3px;color:#888;
|
||
font-size:10px;padding:3px 6px;flex:1;min-width:0;font-family:monospace;outline:none;"
|
||
onkeydown="if(event.key==='Enter')vvRyEBrowse('${key}')">
|
||
<button onclick="vvRyEBrowse('${key}')"
|
||
style="background:#0a1a2a;border:1px solid #1a3a5a;color:#4a9eff;font-size:10px;
|
||
padding:3px 8px;border-radius:3px;cursor:pointer;flex-shrink:0;white-space:nowrap;">⟳ Browse</button>
|
||
</div>
|
||
<div id="vv-ry-ebrowse-${key}" class="vv-ms-dirs" style="display:none;max-height:120px;" data-last-path=""></div>
|
||
<div style="display:flex;gap:4px;margin-top:4px;align-items:center;">
|
||
<select id="vv-ry-eprof-${key}"
|
||
style="background:#0d0d0d;border:1px solid #1e1e1e;border-radius:3px;color:#666;
|
||
font-size:10px;padding:3px 5px;flex:1;min-width:0;outline:none;">
|
||
<option value="">no profile</option>
|
||
</select>
|
||
<button onclick="vvRyEAddShare('${key}')"
|
||
style="background:#0a1a0a;border:1px solid #1a3a1a;color:#4caf50;font-size:10px;
|
||
padding:3px 9px;border-radius:3px;cursor:pointer;flex-shrink:0;white-space:nowrap;">+ Add</button>
|
||
</div>
|
||
</div>`;
|
||
|
||
// ── Save / Cancel ─────────────────────────────────────────────────────────────
|
||
h += `<div style="display:flex;gap:6px;align-items:center;margin-top:10px;padding-top:8px;border-top:1px solid #1e1e1e;">
|
||
<button id="vv-ry-esave-${key}" onclick="event.stopPropagation();vvRyWinSave('${key}')"
|
||
style="background:#1a2a1a;border:1px solid #2d4a2d;color:#4caf50;font-size:11px;
|
||
padding:4px 14px;border-radius:3px;cursor:pointer;">Save changes</button>
|
||
<button onclick="event.stopPropagation();vvRyWinCancel('${key}')"
|
||
style="background:#111;border:1px solid #222;color:#555;font-size:11px;
|
||
padding:4px 12px;border-radius:3px;cursor:pointer;">Cancel</button>
|
||
<span id="vv-ry-efb-${key}" style="font-size:10px;color:#ef5350;"></span>
|
||
</div>`;
|
||
|
||
panel.innerHTML = h;
|
||
_vvRyLoadProfiles(key);
|
||
}
|
||
|
||
// ── Script mutations ──────────────────────────────────────────────────────────
|
||
function vvRyEMvScript(key, idx, dir) {
|
||
const s = _vvRyWinEState[key]?.scripts; if (!s) return;
|
||
const n = idx + dir; if (n < 0 || n >= s.length) return;
|
||
[s[idx], s[n]] = [s[n], s[idx]]; _vvRyBuildEditPanel(key);
|
||
}
|
||
function vvRyERemScript(key, idx) {
|
||
const s = _vvRyWinEState[key]?.scripts; if (!s) return;
|
||
s.splice(idx, 1); _vvRyBuildEditPanel(key);
|
||
}
|
||
function vvRyEAddScript(key) {
|
||
const sel = document.getElementById('vv-ry-esel-' + key);
|
||
if (!sel?.value) return;
|
||
const s = _vvRyWinEState[key]?.scripts; if (!s) return;
|
||
if (s.find(x => x.id === sel.value)) return;
|
||
s.push({id: sel.value, enabled: true}); _vvRyBuildEditPanel(key);
|
||
}
|
||
|
||
// ── Share mutations ───────────────────────────────────────────────────────────
|
||
function vvRyEMvShare(key, idx, dir) {
|
||
const s = _vvRyWinEState[key]?.shares; if (!s) return;
|
||
const n = idx + dir; if (n < 0 || n >= s.length) return;
|
||
[s[idx], s[n]] = [s[n], s[idx]]; _vvRyBuildEditPanel(key);
|
||
}
|
||
function vvRyERemShare(key, idx) {
|
||
const s = _vvRyWinEState[key]?.shares; if (!s) return;
|
||
s.splice(idx, 1); _vvRyBuildEditPanel(key);
|
||
}
|
||
function vvRyEAddShare(key) {
|
||
const path = document.getElementById('vv-ry-epath-' + key)?.value.trim();
|
||
const prof = document.getElementById('vv-ry-eprof-' + key)?.value.trim() || '';
|
||
if (!path || !path.startsWith('/')) return;
|
||
const s = _vvRyWinEState[key]?.shares; if (!s) return;
|
||
s.push({path, profile: prof});
|
||
document.getElementById('vv-ry-epath-' + key).value = '';
|
||
const dirsEl = document.getElementById('vv-ry-ebrowse-' + key);
|
||
if (dirsEl) dirsEl.style.display = 'none';
|
||
_vvRyBuildEditPanel(key);
|
||
}
|
||
|
||
// ── Share path browser ────────────────────────────────────────────────────────
|
||
function vvRyEBrowse(key) {
|
||
const pathEl = document.getElementById('vv-ry-epath-' + key);
|
||
const dirsEl = document.getElementById('vv-ry-ebrowse-' + key);
|
||
if (!pathEl || !dirsEl) return;
|
||
const path = pathEl.value.trim() || '/mnt/user';
|
||
|
||
if (dirsEl.dataset.lastPath === path && dirsEl.style.display !== 'none') {
|
||
dirsEl.style.display = 'none'; dirsEl.dataset.lastPath = ''; return;
|
||
}
|
||
dirsEl.innerHTML = '<div style="color:#444;font-size:10px;padding:4px;">Loading…</div>';
|
||
dirsEl.style.display = '';
|
||
|
||
fetch(`/plugins/varaverk/api/manual_sync.php?action=browse_local&path=${encodeURIComponent(path)}&_=${Date.now()}`)
|
||
.then(r => r.json())
|
||
.then(d => {
|
||
if (!d.ok) { dirsEl.innerHTML = `<div class="vv-ms-dir" style="color:#ef5350;cursor:default;">${d.error}</div>`; return; }
|
||
if (pathEl) pathEl.value = d.path;
|
||
dirsEl.dataset.lastPath = d.path;
|
||
let h = '';
|
||
if (d.parent) {
|
||
const pn = d.parent === '/' ? '/' : (d.parent.replace(/^.*\//,'') || d.parent) + '/';
|
||
h += `<div class="vv-ms-dir parent" onclick="vvRyENavTo('${key}',${vvRyEscAttr(JSON.stringify(d.parent))})">↑ ${pn}</div>`;
|
||
}
|
||
if (!d.dirs.length) {
|
||
h += '<div class="vv-ms-dir" style="color:#2a2a2a;cursor:default;">— empty —</div>';
|
||
} else {
|
||
for (const dir of d.dirs) {
|
||
const name = dir.replace(/^.*\//,'') || dir;
|
||
h += `<div class="vv-ms-dir" onclick="vvRyENavTo('${key}',${vvRyEscAttr(JSON.stringify(dir))})" title="${vvRyEscAttr(dir)}">▶ ${name}</div>`;
|
||
}
|
||
}
|
||
dirsEl.innerHTML = h;
|
||
})
|
||
.catch(() => { dirsEl.innerHTML = '<div class="vv-ms-dir" style="color:#ef5350;cursor:default;">Browse failed</div>'; });
|
||
}
|
||
|
||
function vvRyENavTo(key, path) {
|
||
const pathEl = document.getElementById('vv-ry-epath-' + key);
|
||
if (pathEl) pathEl.value = path;
|
||
vvRyEBrowse(key);
|
||
}
|
||
|
||
// ── Profile dropdown loader ───────────────────────────────────────────────────
|
||
function _vvRyLoadProfiles(key) {
|
||
const sel = document.getElementById('vv-ry-eprof-' + key);
|
||
if (!sel) return;
|
||
const populate = () => {
|
||
sel.innerHTML = '<option value="">no profile</option>' +
|
||
(_vvRyProfiles || []).map(p => `<option value="${p}">${p}</option>`).join('');
|
||
};
|
||
if (_vvRyProfiles !== null) { populate(); return; }
|
||
fetch('/plugins/varaverk/api/rsync_win_arrays.php?action=list_profiles&_=' + Date.now())
|
||
.then(r => r.json())
|
||
.then(d => { _vvRyProfiles = d.ok ? (d.profiles || []) : []; populate(); })
|
||
.catch(() => { _vvRyProfiles = []; populate(); });
|
||
}
|
||
|
||
// ── Toggle a boolean flag — called by inline onclick ──────────────────────────
|
||
function vvRyToggle(trackEl, name) {
|
||
const on = !trackEl.classList.contains('on');
|
||
trackEl.classList.toggle('on', on);
|
||
const fd = new URLSearchParams();
|
||
fd.append('name', name);
|
||
fd.append('enabled', on ? '1' : '0');
|
||
fetch('/plugins/varaverk/api/flag_toggle.php', { method: 'POST', body: fd })
|
||
.then(r => r.json())
|
||
.then(d => { if (!d.ok) { trackEl.classList.toggle('on', !on); } })
|
||
.catch(() => { trackEl.classList.toggle('on', !on); });
|
||
}
|
||
|
||
// ── Save scalar defaults ──────────────────────────────────────────────────────
|
||
function vvRySaveDefaults() {
|
||
const btn = document.getElementById('vv-ry-save');
|
||
const fb = document.getElementById('vv-ry-save-fb');
|
||
const changes = [
|
||
{ file: 'master.conf', key: 'BW_LIMIT', value: document.getElementById('vv-ry-bwlimit').value, type: 'scalar' },
|
||
{ file: 'master.conf', key: 'RETRY_COUNT', value: document.getElementById('vv-ry-retry').value, type: 'scalar' },
|
||
{ file: 'master.conf', key: 'SLEEP', value: document.getElementById('vv-ry-sleep').value, type: 'scalar' },
|
||
{ file: 'master.conf', key: 'BANDWIDTH_WARN_GB', value: document.getElementById('vv-ry-bwwarn').value, type: 'scalar' },
|
||
];
|
||
btn.disabled = true;
|
||
btn.textContent = 'Saving…';
|
||
fb.textContent = '';
|
||
const fd = new URLSearchParams();
|
||
fd.append('id', 'rsync');
|
||
fd.append('changes', JSON.stringify(changes));
|
||
fetch('/plugins/varaverk/api/confform.php', { method: 'POST', body: fd })
|
||
.then(r => r.json())
|
||
.then(d => {
|
||
btn.disabled = false;
|
||
btn.textContent = 'Save';
|
||
fb.style.color = d.ok ? '#4caf50' : '#ef5350';
|
||
fb.textContent = d.ok ? 'Saved ✓' : ('Error: ' + (d.error || 'unknown'));
|
||
if (d.ok) setTimeout(() => { fb.textContent = ''; }, 3000);
|
||
})
|
||
.catch(() => {
|
||
btn.disabled = false;
|
||
btn.textContent = 'Save';
|
||
fb.style.color = '#ef5350';
|
||
fb.textContent = 'Request failed';
|
||
});
|
||
}
|
||
|
||
// ── Profile Editor ────────────────────────────────────────────────────────────
|
||
|
||
let _vvRpProfiles = {};
|
||
let _vvRpSelected = null;
|
||
let _vvRpIsNew = false;
|
||
let _vvRpLoaded = false;
|
||
|
||
const RP_FIELDS = ['bw_limit','retry_count','sleep','container_delay',
|
||
'critical_containers','delayed_containers','exclude_dirs','remote_restart'];
|
||
|
||
// Known checkbox flags in order
|
||
const RP_FLAG_ORDER = ['-av','--human-readable','--info=progress2',
|
||
'--delete','--inplace','--no-whole-file','--checksum',
|
||
'--bwlimit=$BW_LIMIT','--compress'];
|
||
|
||
function vvRpOpen() {
|
||
document.getElementById('vv-ry-info-view').style.display = 'none';
|
||
document.getElementById('vv-rp-view').style.display = '';
|
||
if (!_vvRpLoaded) vvRpLoad();
|
||
}
|
||
function vvRpClose() {
|
||
document.getElementById('vv-rp-view').style.display = 'none';
|
||
document.getElementById('vv-ry-info-view').style.display = '';
|
||
}
|
||
|
||
function vvRyInfoToggle(hdr) {
|
||
const body = hdr.nextElementSibling;
|
||
const chev = hdr.querySelector('.vv-ry-info-chev');
|
||
const open = body.style.display !== 'none';
|
||
body.style.display = open ? 'none' : '';
|
||
if (chev) chev.textContent = open ? '▸' : '▾';
|
||
}
|
||
|
||
// Build opts string from checkboxes + extra, update preview + hidden input
|
||
function vvRpBuildOpts() {
|
||
const checked = Array.from(document.querySelectorAll('#vv-rp-flag-group input[type=checkbox]:checked'))
|
||
.map(c => c.dataset.flag);
|
||
const extra = (document.getElementById('vv-rp-opts-extra')?.value || '').trim();
|
||
const parts = [...checked, ...(extra ? [extra] : [])];
|
||
const val = parts.join(' ');
|
||
const preview = document.getElementById('vv-rp-opts-preview');
|
||
const hidden = document.getElementById('vv-rp-rsync_opts');
|
||
if (preview) preview.textContent = val || '(none)';
|
||
if (hidden) hidden.value = val;
|
||
}
|
||
|
||
// Parse opts string → set checkboxes + extra input
|
||
function _vvRpApplyOpts(opts) {
|
||
const tokens = (opts || '').trim().split(/\s+/).filter(Boolean);
|
||
const knownSet = new Set(RP_FLAG_ORDER);
|
||
const extra = [];
|
||
|
||
document.querySelectorAll('#vv-rp-flag-group input[type=checkbox]').forEach(cb => {
|
||
cb.checked = tokens.includes(cb.dataset.flag);
|
||
});
|
||
|
||
for (const t of tokens) {
|
||
if (!knownSet.has(t)) extra.push(t);
|
||
}
|
||
const extraEl = document.getElementById('vv-rp-opts-extra');
|
||
if (extraEl) extraEl.value = extra.join(' ');
|
||
vvRpBuildOpts();
|
||
}
|
||
|
||
function vvRpLoad() {
|
||
fetch('/plugins/varaverk/api/rsync_profiles.php?action=list&_=' + Date.now())
|
||
.then(r => r.json())
|
||
.then(d => {
|
||
if (!d.ok) return;
|
||
_vvRpProfiles = d.profiles || {};
|
||
_vvRpLoaded = true;
|
||
_vvRpPopulateSelector();
|
||
if (_vvRpSelected && _vvRpProfiles[_vvRpSelected]) {
|
||
_vvRpFillForm(_vvRpSelected);
|
||
} else if (_vvRpIsNew) {
|
||
_vvRpClearForm();
|
||
} else if (Object.keys(_vvRpProfiles).length) {
|
||
_vvRpSelected = Object.keys(_vvRpProfiles).sort()[0];
|
||
_vvRpFillForm(_vvRpSelected);
|
||
}
|
||
})
|
||
.catch(() => {});
|
||
}
|
||
|
||
function _vvRpPopulateSelector() {
|
||
const sel = document.getElementById('vv-rp-selector');
|
||
if (!sel) return;
|
||
const names = Object.keys(_vvRpProfiles).sort();
|
||
sel.innerHTML = (names.length ? '' : '<option value="">No profiles</option>')
|
||
+ names.map(n => `<option value="${n}"${n === _vvRpSelected ? ' selected' : ''}>${n}</option>`).join('');
|
||
}
|
||
|
||
function _vvRpFillForm(name) {
|
||
const p = _vvRpProfiles[name] || {};
|
||
document.getElementById('vv-rp-name').value = name;
|
||
document.getElementById('vv-rp-name').readOnly = true;
|
||
document.getElementById('vv-rp-name-hint').textContent = 'rename by creating a new profile with a different name';
|
||
_vvRpApplyOpts(p.rsync_opts || '');
|
||
RP_FIELDS.forEach(f => {
|
||
const el = document.getElementById('vv-rp-' + f);
|
||
if (el) el.value = p[f] ?? '';
|
||
});
|
||
document.getElementById('vv-rp-form').style.display = '';
|
||
document.getElementById('vv-rp-empty').style.display = 'none';
|
||
const del = document.getElementById('vv-rp-del-btn');
|
||
if (del) del.style.display = '';
|
||
const sel = document.getElementById('vv-rp-selector');
|
||
if (sel) sel.value = name;
|
||
}
|
||
|
||
function _vvRpClearForm() {
|
||
document.getElementById('vv-rp-name').value = '';
|
||
document.getElementById('vv-rp-name').readOnly = false;
|
||
document.getElementById('vv-rp-name-hint').textContent = 'new profile name';
|
||
// Default opts for new profile
|
||
_vvRpApplyOpts('-av --human-readable --bwlimit=$BW_LIMIT');
|
||
document.getElementById('vv-rp-bw_limit').value = '5000';
|
||
document.getElementById('vv-rp-retry_count').value = '3';
|
||
document.getElementById('vv-rp-sleep').value = '300';
|
||
document.getElementById('vv-rp-container_delay').value = '5';
|
||
RP_FIELDS.filter(f => !['bw_limit','retry_count','sleep','container_delay'].includes(f))
|
||
.forEach(f => { const el = document.getElementById('vv-rp-' + f); if (el) el.value = ''; });
|
||
document.getElementById('vv-rp-form').style.display = '';
|
||
document.getElementById('vv-rp-empty').style.display = 'none';
|
||
const del = document.getElementById('vv-rp-del-btn');
|
||
if (del) del.style.display = 'none';
|
||
const sel = document.getElementById('vv-rp-selector');
|
||
if (sel) sel.value = '';
|
||
}
|
||
|
||
function vvRpOnSelect(name) {
|
||
if (!name) return;
|
||
_vvRpSelected = name; _vvRpIsNew = false;
|
||
_vvRpFillForm(name);
|
||
document.getElementById('vv-rp-fb').textContent = '';
|
||
}
|
||
|
||
function vvRpNew() {
|
||
_vvRpSelected = null; _vvRpIsNew = true;
|
||
_vvRpClearForm();
|
||
document.getElementById('vv-rp-fb').textContent = '';
|
||
document.getElementById('vv-rp-name').focus();
|
||
}
|
||
|
||
function vvRpSave() {
|
||
const name = document.getElementById('vv-rp-name').value.trim();
|
||
if (!name) { document.getElementById('vv-rp-fb').style.color='#ef5350'; document.getElementById('vv-rp-fb').textContent='Enter a profile name'; return; }
|
||
|
||
const fb = document.getElementById('vv-rp-fb');
|
||
const btn = document.querySelector('#vv-rp-card .vv-ry-save-btn');
|
||
btn.disabled = true; btn.textContent = 'Saving…'; fb.textContent = '';
|
||
|
||
const fd = new URLSearchParams();
|
||
fd.append('action', 'save');
|
||
fd.append('name', name);
|
||
fd.append('rsync_opts', document.getElementById('vv-rp-rsync_opts')?.value?.trim() || '');
|
||
RP_FIELDS.forEach(f => {
|
||
const el = document.getElementById('vv-rp-' + f);
|
||
fd.append(f, el ? el.value.trim() : '');
|
||
});
|
||
|
||
fetch('/plugins/varaverk/api/rsync_profiles.php', { method: 'POST', body: fd })
|
||
.then(r => r.json())
|
||
.then(d => {
|
||
btn.disabled = false; btn.textContent = 'Save Profile';
|
||
if (d.ok) {
|
||
fb.style.color = '#4caf50';
|
||
fb.textContent = 'Saved ✓';
|
||
_vvRpSelected = name;
|
||
_vvRpIsNew = false;
|
||
vvRpLoad(); // refresh profile list
|
||
setTimeout(() => { fb.textContent = ''; }, 3000);
|
||
} else {
|
||
fb.style.color = '#ef5350';
|
||
fb.textContent = d.error || 'Save failed';
|
||
}
|
||
})
|
||
.catch(() => { btn.disabled = false; btn.textContent = 'Save Profile'; fb.style.color='#ef5350'; fb.textContent='Request failed'; });
|
||
}
|
||
|
||
async function vvRpDelete() {
|
||
const name = document.getElementById('vv-rp-name').value.trim();
|
||
if (!name) return;
|
||
if (!await vvConfirm(`Delete profile "${name}"?\n\nThis removes it from all PROFILE_* arrays in master.conf.`)) return;
|
||
|
||
const fb = document.getElementById('vv-rp-fb');
|
||
const del = document.getElementById('vv-rp-del-btn');
|
||
del.disabled = true; fb.textContent = '';
|
||
|
||
const fd = new URLSearchParams();
|
||
fd.append('action', 'delete');
|
||
fd.append('name', name);
|
||
|
||
fetch('/plugins/varaverk/api/rsync_profiles.php', { method: 'POST', body: fd })
|
||
.then(r => r.json())
|
||
.then(d => {
|
||
del.disabled = false;
|
||
if (d.ok) {
|
||
_vvRpSelected = null;
|
||
_vvRpIsNew = false;
|
||
document.getElementById('vv-rp-form').style.display = 'none';
|
||
document.getElementById('vv-rp-empty').style.display = '';
|
||
fb.style.color = '#4caf50'; fb.textContent = 'Deleted ✓';
|
||
vvRpLoad();
|
||
setTimeout(() => { fb.textContent = ''; }, 3000);
|
||
} else {
|
||
fb.style.color = '#ef5350'; fb.textContent = d.error || 'Delete failed';
|
||
}
|
||
})
|
||
.catch(() => { del.disabled = false; fb.style.color='#ef5350'; fb.textContent='Request failed'; });
|
||
}
|
||
|
||
// ── Manual Sync ───────────────────────────────────────────────────────────────
|
||
|
||
let _vvMsHosts = [];
|
||
let _vvMsParent = null;
|
||
let _vvMsPollId = null;
|
||
let _vvMsSshKey = ''; // key path from host*.conf
|
||
|
||
function vvMsToggleOutput() {
|
||
const out = document.getElementById('vv-ms-out');
|
||
const btn = document.getElementById('vv-ms-show-btn');
|
||
const open = out.style.display !== 'none';
|
||
out.style.display = open ? 'none' : '';
|
||
btn.textContent = open ? '▾ Show Rsync' : '▴ Hide Rsync';
|
||
}
|
||
|
||
function vvMsStop() {
|
||
const token = document.getElementById('vv-ms-stop-btn').dataset.token;
|
||
if (!token) return;
|
||
const btn = document.getElementById('vv-ms-stop-btn');
|
||
btn.textContent = '⟳ Stopping…'; btn.disabled = true;
|
||
const fd = new URLSearchParams();
|
||
fd.append('action', 'stop');
|
||
fd.append('token', token);
|
||
fetch('/plugins/varaverk/api/manual_sync.php', { method: 'POST', body: fd })
|
||
.then(r => r.json())
|
||
.then(() => {})
|
||
.catch(() => {})
|
||
.finally(() => { btn.textContent = '■ Stop'; btn.disabled = false; });
|
||
}
|
||
|
||
function vvMsToggle() {
|
||
const body = document.getElementById('vv-ms-body');
|
||
const btn = document.getElementById('vv-ms-tog');
|
||
const open = body.style.display !== 'none';
|
||
body.style.display = open ? 'none' : '';
|
||
btn.textContent = open ? '▾ Show' : '▴ Hide';
|
||
if (!open) {
|
||
if (!_vvMsHosts.length) _vvMsLoadHosts();
|
||
else vvMsUpdatePreview();
|
||
vvMsLoadRecent(); // refreshed on open, so another tab or device is reflected here
|
||
}
|
||
}
|
||
|
||
function _vvMsLoadHosts() {
|
||
const sel = document.getElementById('vv-ms-host');
|
||
sel.innerHTML = '<option value="">Loading…</option>';
|
||
fetch('/plugins/varaverk/api/manual_sync.php?action=hosts&_=' + Date.now())
|
||
.then(r => r.json())
|
||
.then(d => {
|
||
if (!d.ok) { sel.innerHTML = '<option value="">Error loading hosts</option>'; return; }
|
||
_vvMsHosts = d.hosts;
|
||
_vvMsSshKey = d.ssh_key || '';
|
||
const keyEl = document.getElementById('vv-ms-key-info');
|
||
if (keyEl) keyEl.textContent = _vvMsSshKey ? _vvMsSshKey.replace(/^.*\//, '') : '(none configured)';
|
||
vvMsUpdatePreview();
|
||
if (!d.hosts.length) {
|
||
sel.innerHTML = '<option value="">No remote hosts configured</option>';
|
||
return;
|
||
}
|
||
sel.innerHTML = d.hosts.map(h => {
|
||
const dot = h.online === null ? '◌' : h.online ? '●' : '○';
|
||
const col = h.online === null ? '#444' : h.online ? '#4caf50' : '#ef5350';
|
||
return `<option value="${h.slot}" data-col="${col}">${dot} ${h.id} — ${h.hostname}</option>`;
|
||
}).join('');
|
||
// Auto-select first and auto-browse
|
||
vvMsHostChanged();
|
||
})
|
||
.catch(() => { sel.innerHTML = '<option value="">Failed to load</option>'; });
|
||
}
|
||
|
||
function vvMsHostChanged() {
|
||
const sel = document.getElementById('vv-ms-host');
|
||
const slot = sel.value;
|
||
const host = _vvMsHosts.find(h => h.slot === slot);
|
||
const stat = document.getElementById('vv-ms-host-status');
|
||
|
||
vvMsUpdatePreview(); // the destination just changed, so the command shown must too
|
||
|
||
if (!host) { stat.textContent = ''; return; }
|
||
|
||
const onlineTxt = host.online === null ? 'status unknown'
|
||
: host.online ? '● online' + (host.ip ? ' · ' + host.ip : '')
|
||
: '○ offline';
|
||
const onlineCol = host.online === null ? '#333' : host.online ? '#4caf50' : '#ef5350';
|
||
stat.style.color = onlineCol;
|
||
stat.textContent = onlineTxt;
|
||
|
||
// Auto-browse /mnt/user on host change
|
||
const rpEl = document.getElementById('vv-ms-rpath');
|
||
if (!rpEl.value) rpEl.value = '/mnt/user';
|
||
vvMsBrowse();
|
||
}
|
||
|
||
function vvMsBrowse() {
|
||
const slot = document.getElementById('vv-ms-host').value;
|
||
const path = document.getElementById('vv-ms-rpath').value.trim() || '/mnt/user';
|
||
const browseBtn = document.getElementById('vv-ms-browse-btn');
|
||
const dirsEl = document.getElementById('vv-ms-dirs');
|
||
const errEl = document.getElementById('vv-ms-browse-err');
|
||
if (!slot) return;
|
||
|
||
// Toggle: already showing this path → collapse
|
||
if (dirsEl.style.display !== 'none' && browseBtn.dataset.lastPath === path) {
|
||
dirsEl.style.display = 'none';
|
||
errEl.style.display = 'none';
|
||
browseBtn.textContent = '⟳ Browse';
|
||
browseBtn.dataset.lastPath = '';
|
||
return;
|
||
}
|
||
|
||
browseBtn.textContent = '⟳';
|
||
browseBtn.disabled = true;
|
||
dirsEl.style.display = 'none';
|
||
errEl.style.display = 'none';
|
||
|
||
fetch(`/plugins/varaverk/api/manual_sync.php?action=browse&host=${encodeURIComponent(slot)}&path=${encodeURIComponent(path)}&_=${Date.now()}`)
|
||
.then(r => r.json())
|
||
.then(d => {
|
||
browseBtn.textContent = '⟳ Browse';
|
||
browseBtn.disabled = false;
|
||
if (!d.ok) {
|
||
errEl.textContent = d.error || 'Browse failed';
|
||
errEl.style.display = '';
|
||
return;
|
||
}
|
||
_vvMsParent = d.parent;
|
||
document.getElementById('vv-ms-rpath').value = d.path;
|
||
browseBtn.dataset.lastPath = d.path;
|
||
_vvMsRenderDirs('vv-ms-dirs', d.dirs, d.parent, 'vvMsNavTo');
|
||
})
|
||
.catch(e => {
|
||
browseBtn.textContent = '⟳ Browse';
|
||
browseBtn.disabled = false;
|
||
errEl.textContent = 'Request failed: ' + e;
|
||
errEl.style.display = '';
|
||
});
|
||
}
|
||
|
||
// The whole command, with the real paths in it, not "…src host:dst".
|
||
//
|
||
// Two reasons it has to resolve rather than gesture. The first is the trailing slash: this card
|
||
// already warns that "/" means contents and no "/" means the folder, and a hint under a text box
|
||
// is not where anyone looks at the moment they press Run — in the command it is unmissable. The
|
||
// second is --delete, which is one checkbox among nine and is the only one that removes data at
|
||
// the far end. Seeing it sit next to the actual destination is the difference between reading a
|
||
// flag and understanding a consequence.
|
||
function vvMsUpdatePreview() {
|
||
const flags = Array.from(document.querySelectorAll('#vv-ms-card input[data-flag]:checked'))
|
||
.map(el => el.dataset.flag).join(' ');
|
||
const bw = parseInt(document.getElementById('vv-ms-bw')?.value) || 0;
|
||
const prev = document.getElementById('vv-ms-flags-preview');
|
||
const bwPrev= document.getElementById('vv-ms-bwlimit-preview');
|
||
if (prev) prev.textContent = flags || '(none)';
|
||
if (bwPrev) bwPrev.textContent = bw > 0 ? '--bwlimit=' + bw : '';
|
||
|
||
const local = document.getElementById('vv-ms-local')?.value.trim() || '';
|
||
const rpath = document.getElementById('vv-ms-rpath')?.value.trim() || '';
|
||
const user = document.getElementById('vv-ms-user')?.value || 'root';
|
||
const hostEl = document.getElementById('vv-ms-host');
|
||
const hostTxt = hostEl && hostEl.selectedIndex >= 0
|
||
? (hostEl.options[hostEl.selectedIndex].textContent || '').trim() : '';
|
||
const tgt = document.getElementById('vv-ms-target-preview');
|
||
if (tgt) {
|
||
tgt.textContent = (local || '<source>') + ' ' + user + '@' + (hostTxt || '<host>') + ':' + (rpath || '<dest>');
|
||
}
|
||
|
||
// Destructive styling follows the checkbox, so the warning cannot be left on screen after the
|
||
// flag is cleared — a stale danger marker is how a real one stops being read.
|
||
const del = document.querySelector('#vv-ms-card input[data-flag="--delete"]');
|
||
const dry = document.getElementById('vv-ms-dryrun');
|
||
const warn = document.getElementById('vv-ms-delete-warn');
|
||
if (warn) {
|
||
const armed = del && del.checked && !(dry && dry.checked);
|
||
warn.style.display = armed ? '' : 'none';
|
||
if (armed) {
|
||
warn.textContent = 'deletes anything at ' + (rpath || 'the destination')
|
||
+ ' that is not in ' + (local || 'the source');
|
||
}
|
||
}
|
||
}
|
||
|
||
function _vvMsRenderDirs(dirsElId, dirs, parent, navFn) {
|
||
const dirsEl = document.getElementById(dirsElId);
|
||
if (!dirsEl) return;
|
||
let html = '';
|
||
|
||
if (parent !== null) {
|
||
const pname = parent === '/' ? '/' : (parent.replace(/^.*\//, '') || parent) + '/';
|
||
html += `<div class="vv-ms-dir parent" onclick="${navFn}(${vvRyEscAttr(JSON.stringify(parent))})">↑ ${pname}</div>`;
|
||
}
|
||
|
||
if (!dirs.length) {
|
||
html += '<div class="vv-ms-dir" style="color:#2a2a2a;cursor:default;">— no subdirectories —</div>';
|
||
} else {
|
||
for (const d of dirs) {
|
||
const name = d.replace(/^.*\//, '') || d;
|
||
html += `<div class="vv-ms-dir" onclick="${navFn}(${vvRyEscAttr(JSON.stringify(d))})" title="${vvRyEscAttr(d)}">▶ ${name}</div>`;
|
||
}
|
||
}
|
||
|
||
dirsEl.innerHTML = html;
|
||
dirsEl.style.display = '';
|
||
}
|
||
|
||
// ── Remote browse ──────────────────────────────────────────────────────────────
|
||
|
||
function vvMsNavTo(path) {
|
||
document.getElementById('vv-ms-rpath').value = path;
|
||
vvMsBrowse();
|
||
}
|
||
|
||
function vvMsNavUp() {
|
||
const cur = (document.getElementById('vv-ms-rpath').value || '/mnt/user').replace(/\/$/, '') || '/';
|
||
const up = cur === '/' ? '/' : (cur.substring(0, cur.lastIndexOf('/')) || '/');
|
||
document.getElementById('vv-ms-rpath').value = up;
|
||
vvMsBrowse();
|
||
}
|
||
|
||
// ── Local browse ───────────────────────────────────────────────────────────────
|
||
|
||
function vvMsLocalNavTo(path) {
|
||
document.getElementById('vv-ms-local').value = path;
|
||
vvMsBrowseLocal();
|
||
}
|
||
|
||
function vvMsLocalNavUp() {
|
||
const cur = (document.getElementById('vv-ms-local').value || '/mnt/user').replace(/\/$/, '') || '/';
|
||
const up = cur === '/' ? '/' : (cur.substring(0, cur.lastIndexOf('/')) || '/');
|
||
document.getElementById('vv-ms-local').value = up;
|
||
vvMsBrowseLocal();
|
||
}
|
||
|
||
function vvMsBrowseLocal() {
|
||
const path = document.getElementById('vv-ms-local').value.trim() || '/mnt/user';
|
||
const btn = document.getElementById('vv-ms-lbrowse-btn');
|
||
const dirsEl = document.getElementById('vv-ms-local-dirs');
|
||
const errEl = document.getElementById('vv-ms-lbrowse-err');
|
||
|
||
// Toggle: already showing this path → collapse
|
||
if (dirsEl.style.display !== 'none' && btn.dataset.lastPath === path) {
|
||
dirsEl.style.display = 'none';
|
||
errEl.style.display = 'none';
|
||
btn.textContent = '⟳ Browse';
|
||
btn.dataset.lastPath = '';
|
||
return;
|
||
}
|
||
|
||
btn.textContent = '⟳'; btn.disabled = true;
|
||
dirsEl.style.display = 'none';
|
||
errEl.style.display = 'none';
|
||
|
||
fetch(`/plugins/varaverk/api/manual_sync.php?action=browse_local&path=${encodeURIComponent(path)}&_=${Date.now()}`)
|
||
.then(r => r.json())
|
||
.then(d => {
|
||
btn.textContent = '⟳ Browse'; btn.disabled = false;
|
||
if (!d.ok) { errEl.textContent = d.error || 'Browse failed'; errEl.style.display = ''; return; }
|
||
document.getElementById('vv-ms-local').value = d.path;
|
||
btn.dataset.lastPath = d.path;
|
||
_vvMsRenderDirs('vv-ms-local-dirs', d.dirs, d.parent, 'vvMsLocalNavTo');
|
||
})
|
||
.catch(e => {
|
||
btn.textContent = '⟳ Browse'; btn.disabled = false;
|
||
errEl.textContent = 'Request failed: ' + e; errEl.style.display = '';
|
||
});
|
||
}
|
||
|
||
async function vvMsRun() {
|
||
const local = document.getElementById('vv-ms-local').value.trim();
|
||
const slot = document.getElementById('vv-ms-host').value;
|
||
const rpath = document.getElementById('vv-ms-rpath').value.trim();
|
||
const user = document.getElementById('vv-ms-user').value;
|
||
const bw = parseInt(document.getElementById('vv-ms-bw').value) || 0;
|
||
const useKey = document.getElementById('vv-ms-usekey').checked;
|
||
const isDry = document.getElementById('vv-ms-dryrun').checked;
|
||
// Build flags from all checked checkboxes in the card
|
||
const flags = Array.from(document.querySelectorAll('#vv-ms-card input[data-flag]:checked'))
|
||
.map(el => el.dataset.flag).join(' ');
|
||
|
||
if (!local) { vvAlert('Enter a local source path.'); return; }
|
||
if (!slot) { vvAlert('Select a remote server.'); return; }
|
||
if (!rpath) { vvAlert('Enter a remote destination path.'); return; }
|
||
|
||
// The only confirm on this card, and only for the one flag that destroys data at the far end.
|
||
// Guarding every run would train the reflex that dismisses this one — a prompt that appears on
|
||
// every safe action is not read by the time an unsafe one arrives.
|
||
//
|
||
// Skipped under --dry-run, because that is exactly the rehearsal this is asking for, and
|
||
// refusing to let someone rehearse the dangerous case cheaply is the wrong lesson to teach.
|
||
if (flags.includes('--delete') && !isDry) {
|
||
const ok = await vvConfirm(
|
||
'This deletes anything in ' + rpath + ' on ' + slot + ' that is not in ' + local + '.\n\n'
|
||
+ (local.endsWith('/')
|
||
? 'The source ends in "/", so its CONTENTS are compared against the destination.'
|
||
: 'The source has no trailing "/", so the FOLDER ITSELF is copied into the destination — '
|
||
+ 'a common cause of deleting the wrong level.')
|
||
+ '\n\nRun a --dry-run first if you have not.',
|
||
{ title: 'Run with --delete?', confirmText: 'Run it', type: 'warning' });
|
||
if (!ok) return;
|
||
}
|
||
|
||
const btn = document.getElementById('vv-ms-run-btn');
|
||
const stat = document.getElementById('vv-ms-run-status');
|
||
const out = document.getElementById('vv-ms-out');
|
||
const badge = document.getElementById('vv-ms-badge');
|
||
|
||
const stopBtn = document.getElementById('vv-ms-stop-btn');
|
||
const showBtn = document.getElementById('vv-ms-show-btn');
|
||
|
||
btn.disabled = true;
|
||
out.style.display = ''; // auto-open output
|
||
showBtn.textContent = '▴ Hide Rsync';
|
||
out.textContent = isDry ? '⟳ Dry run starting…' : '⟳ Rsync starting in background…';
|
||
stat.style.color = '#4a9eff';
|
||
stat.textContent = 'Starting…';
|
||
|
||
badge.style.display = '';
|
||
badge.textContent = isDry ? 'DRY RUN' : 'RUNNING';
|
||
badge.style.background = '#0a1a2a';
|
||
badge.style.color = '#4a9eff';
|
||
badge.style.border = '1px solid #1a3a5a';
|
||
|
||
const fd = new URLSearchParams();
|
||
fd.append('action', 'run');
|
||
fd.append('local', local);
|
||
fd.append('host', slot);
|
||
fd.append('remote_path', rpath);
|
||
fd.append('user', user);
|
||
fd.append('flags', flags);
|
||
fd.append('bw_limit', bw);
|
||
fd.append('use_key', useKey ? '1' : '0');
|
||
|
||
fetch('/plugins/varaverk/api/manual_sync.php', { method: 'POST', body: fd })
|
||
.then(r => r.json())
|
||
.then(d => {
|
||
if (!d.ok) {
|
||
btn.disabled = false;
|
||
out.textContent = '✗ ' + (d.error || 'Failed to start');
|
||
stat.style.color = '#ef5350';
|
||
stat.textContent = 'Error';
|
||
badge.style.display = 'none';
|
||
return;
|
||
}
|
||
stat.textContent = 'Running…';
|
||
stopBtn.dataset.token = d.token;
|
||
vvMsLoadRecent(); // the run just recorded itself server-side; show it
|
||
stopBtn.style.display = '';
|
||
_vvMsStartPoll(d.token, btn, stat, out, badge, stopBtn);
|
||
})
|
||
.catch(e => {
|
||
btn.disabled = false;
|
||
out.textContent = '✗ Request failed: ' + e;
|
||
stat.style.color = '#ef5350';
|
||
stat.textContent = 'Error';
|
||
badge.style.display = 'none';
|
||
});
|
||
}
|
||
|
||
function _vvMsStartPoll(token, btn, stat, out, badge, stopBtn) {
|
||
if (_vvMsPollId) clearInterval(_vvMsPollId);
|
||
let ticks = 0;
|
||
|
||
_vvMsPollId = setInterval(() => {
|
||
ticks++;
|
||
fetch(`/plugins/varaverk/api/manual_sync.php?action=poll&token=${token}&_=${Date.now()}`)
|
||
.then(r => r.json())
|
||
.then(d => {
|
||
if (!d.ok) return;
|
||
if (!d.started && ticks < 6) return;
|
||
if (d.output) {
|
||
out.textContent = d.output;
|
||
out.scrollTop = out.scrollHeight;
|
||
}
|
||
if (d.done || (!d.started && ticks >= 6)) {
|
||
clearInterval(_vvMsPollId);
|
||
_vvMsPollId = null;
|
||
btn.disabled = false;
|
||
if (stopBtn) { stopBtn.style.display = 'none'; stopBtn.dataset.token = ''; }
|
||
const cancelled = d.output && d.output.includes('Cancelled by user');
|
||
const ok = !cancelled && d.output && !d.output.includes('rsync error');
|
||
stat.style.color = cancelled ? '#ff9800' : ok ? '#4caf50' : '#ef5350';
|
||
stat.textContent = cancelled ? 'Cancelled' : ok ? 'Done ✓' : 'Finished with errors';
|
||
badge.textContent = cancelled ? 'CANCELLED' : ok ? 'DONE' : 'ERROR';
|
||
badge.style.background = cancelled ? '#1a1200' : ok ? '#0a1a0a' : '#200d0d';
|
||
badge.style.color = cancelled ? '#ff9800' : ok ? '#4caf50' : '#ef5350';
|
||
badge.style.border = cancelled ? '1px solid #3a2800' : ok ? '1px solid #1a3a1a' : '1px solid #3a1a1a';
|
||
setTimeout(() => { badge.style.display = 'none'; stat.textContent = ''; }, 6000);
|
||
}
|
||
})
|
||
.catch(() => {});
|
||
}, 1500);
|
||
}
|
||
|
||
// ── Recent manual syncs ──────────────────────────────────────────────────────
|
||
// Server-side, not localStorage: the same install is driven from two desktop screens and a phone,
|
||
// and a recall list that only exists in the browser that made it is missing exactly when wanted.
|
||
const _MS_API = '/plugins/varaverk/api/manual_sync.php';
|
||
let _vvMsRecent = [];
|
||
|
||
function _msRecAge(ts) {
|
||
const s = Math.max(0, Math.floor(Date.now() / 1000) - (ts || 0));
|
||
if (s < 3600) return Math.max(1, Math.round(s / 60)) + 'm';
|
||
if (s < 86400) return Math.round(s / 3600) + 'h';
|
||
return Math.round(s / 86400) + 'd';
|
||
}
|
||
|
||
function vvMsLoadRecent() {
|
||
fetch(_MS_API + '?action=recent')
|
||
.then(r => r.json())
|
||
.then(d => { _vvMsRecent = (d && d.rows) || []; _msRenderRecent(); })
|
||
.catch(() => {});
|
||
}
|
||
|
||
function _msRenderRecent() {
|
||
const wrap = document.getElementById('vv-ms-recent-wrap');
|
||
const box = document.getElementById('vv-ms-recent');
|
||
if (!wrap || !box) return;
|
||
if (!_vvMsRecent.length) { wrap.style.display = 'none'; box.innerHTML = ''; return; }
|
||
wrap.style.display = '';
|
||
|
||
box.innerHTML = _vvMsRecent.map(r => {
|
||
// Falls back to the transfer itself when unnamed, so a row always says what it does. A blank
|
||
// label on a one-click action that copies files is not a thing to ship.
|
||
const label = r.name || (String(r.local || '').split('/').filter(Boolean).pop() || 'sync');
|
||
const path = (r.local || '') + ' → ' + (r.slot || '') + ':' + (r.rpath || '');
|
||
const del = (r.flags || []).includes('--delete');
|
||
return `<div class="vv-ms-rec" data-id="${vvEscAttr(r.id)}" title="${vvEscAttr(path)}">
|
||
<span class="vv-ms-rec-nm">${vvEscHtml(label)}</span>
|
||
<span class="vv-ms-rec-pt">${vvEscHtml(path)}</span>
|
||
${del ? '<span class="vv-ms-rec-del">--delete</span>' : ''}
|
||
<span class="vv-ms-rec-age">${_msRecAge(r.ts)}</span>
|
||
<button class="vv-ms-rec-b ${r.sticky ? 'on' : ''}" data-act="sticky"
|
||
title="${r.sticky ? 'Unpin — may roll off' : 'Pin — never rolls off'}">${r.sticky ? '★' : '☆'}</button>
|
||
<button class="vv-ms-rec-b" data-act="rename" title="Rename">✎</button>
|
||
<button class="vv-ms-rec-b" data-act="delete" title="Forget this entry">×</button>
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
// One delegated listener: the list is replaced wholesale on every change, so per-row handlers
|
||
// would be rebound constantly and silently missed by anything added later.
|
||
document.addEventListener('click', async ev => {
|
||
const row = ev.target.closest('.vv-ms-rec');
|
||
if (!row) return;
|
||
const id = row.dataset.id;
|
||
const rec = _vvMsRecent.find(r => r.id === id);
|
||
if (!rec) return;
|
||
const btn = ev.target.closest('.vv-ms-rec-b');
|
||
|
||
if (!btn) { _msApplyRecent(rec); return; }
|
||
ev.stopPropagation();
|
||
const act = btn.dataset.act;
|
||
const body = { action: 'recent_update', id: id, op: act };
|
||
|
||
if (act === 'rename') {
|
||
const name = await vvPrompt('Name for this sync', rec.name || '');
|
||
if (name === null) return;
|
||
body.name = name;
|
||
} else if (act === 'sticky') {
|
||
body.value = rec.sticky ? '0' : '1';
|
||
} else if (act === 'delete') {
|
||
if (!await vvConfirm('Forget this saved sync? It only removes the entry, nothing is deleted on either host.')) return;
|
||
}
|
||
|
||
fetch(_MS_API, { method: 'POST',
|
||
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
|
||
body: new URLSearchParams(body) })
|
||
.then(r => r.json())
|
||
.then(() => vvMsLoadRecent())
|
||
.catch(() => {});
|
||
});
|
||
|
||
// Fills the form and stops. Deliberately does not run: one click away from a transfer that may
|
||
// carry --delete is not a saving worth having, and the point of loading it is to look at it.
|
||
function _msApplyRecent(r) {
|
||
const set = (id, v) => { const el = document.getElementById(id); if (el) el.value = v; };
|
||
set('vv-ms-local', r.local || '');
|
||
set('vv-ms-rpath', r.rpath || '');
|
||
set('vv-ms-bw', r.bw || 0);
|
||
|
||
const host = document.getElementById('vv-ms-host');
|
||
if (host) { host.value = r.slot || ''; if (typeof vvMsHostChanged === 'function') vvMsHostChanged(); }
|
||
const user = document.getElementById('vv-ms-user');
|
||
if (user && r.user) user.value = r.user;
|
||
const key = document.getElementById('vv-ms-usekey');
|
||
if (key) key.checked = r.use_key !== false;
|
||
|
||
const want = new Set(r.flags || []);
|
||
document.querySelectorAll('#vv-ms-card input[data-flag]').forEach(el => {
|
||
el.checked = want.has(el.dataset.flag);
|
||
});
|
||
vvMsUpdatePreview();
|
||
document.getElementById('vv-ms-local')?.focus();
|
||
}
|
||
|
||
// ── Assistant ────────────────────────────────────────────────────────────────
|
||
// Scope is fixed to the tab rather than following a selection. Unlike the Scheduler, nothing here
|
||
// is "open" in the sense of one log or one script — the operator is looking at the whole picture
|
||
// and asking about part of it, and the worker resolves bare references against the tab name.
|
||
if (document.getElementById('vv-ry-ai-chat')) {
|
||
VvAiChat({
|
||
prefix: 'vv-ry-ai',
|
||
profile: 'varaverk',
|
||
scopeLabel: 'Rsync',
|
||
scope: () => 'Rsync',
|
||
resumeProfile: 'varaverk',
|
||
empty: 'Ask about a profile, a window, or why a sync did or did not run.',
|
||
});
|
||
}
|
||
</script>
|