Remember the last ten manual syncs so a repeat is one click
Retyping two paths and nine checkboxes correctly every time is where the mistakes come from, and "one-off" described how a transfer is scheduled rather than how often it is run. Pinned entries are exempt from the rotation — the command used twice a year is both the most worth keeping and the first that ten ordinary runs evict. Stored server-side, so the list is there from any screen, and loading one fills the form and stops rather than running it.
This commit is contained in:
@@ -8,10 +8,17 @@
|
|||||||
// OPERATIONAL MODEL
|
// OPERATIONAL MODEL
|
||||||
// Entirely outside the profile system. The scheduled tiers sync configured shares with
|
// Entirely outside the profile system. The scheduled tiers sync configured shares with
|
||||||
// configured profiles; this is for the one-off move that does not belong in a conf file —
|
// configured profiles; this is for the one-off move that does not belong in a conf file —
|
||||||
// seeding a new host, recovering a share, copying something once. Nothing here is recorded,
|
// seeding a new host, recovering a share, copying something once. Nothing here is scheduled.
|
||||||
// scheduled, or repeated.
|
|
||||||
//
|
//
|
||||||
// Six actions on one URL, in the order the panel uses them: hosts, browse, browse_local,
|
// The last ten are remembered, which the header used to deny. "One-off" turned out to describe
|
||||||
|
// how a transfer is scheduled, not how often it is run: seeding a host and recovering a share
|
||||||
|
// are done repeatedly, and retyping two paths and nine checkboxes correctly each time is where
|
||||||
|
// the mistakes come from. Pinned entries are exempt from the rotation, because the command used
|
||||||
|
// twice a year is both the most valuable to keep and the first that ten ordinary runs evict.
|
||||||
|
// Recording is a record of intent only — nothing re-runs itself, and loading an entry fills the
|
||||||
|
// form and stops.
|
||||||
|
//
|
||||||
|
// Eight actions on one URL, in the order the panel uses them: recent, hosts, browse, browse_local,
|
||||||
// run, poll, stop. run returns a token immediately and the transfer continues detached; poll
|
// run, poll, stop. run returns a token immediately and the transfer continues detached; poll
|
||||||
// reads its log until a sentinel appears; stop kills it.
|
// reads its log until a sentinel appears; stop kills it.
|
||||||
//
|
//
|
||||||
@@ -105,6 +112,9 @@
|
|||||||
// [user=root] [bw_limit=<KB/s>] [use_key=0|1] [flags=<rsync flags>]
|
// [user=root] [bw_limit=<KB/s>] [use_key=0|1] [flags=<rsync flags>]
|
||||||
// GET ?action=poll&token=<hex16> output so far, and whether it finished
|
// GET ?action=poll&token=<hex16> output so far, and whether it finished
|
||||||
// POST action=stop token=<hex16> cancel a running transfer
|
// POST action=stop token=<hex16> cancel a running transfer
|
||||||
|
// GET ?action=recent the remembered syncs, pinned first then newest
|
||||||
|
// POST action=recent_update id=<hex12> op=rename|sticky|delete
|
||||||
|
// [name=<label>] [value=0|1] rename, pin/unpin, or forget one entry
|
||||||
//
|
//
|
||||||
// RESPONSE
|
// RESPONSE
|
||||||
// hosts {"ok":true,"hosts":[{slot,id,hostname,online,ip}],"has_key":bool,"ssh_key":"…"}
|
// hosts {"ok":true,"hosts":[{slot,id,hostname,online,ip}],"has_key":bool,"ssh_key":"…"}
|
||||||
@@ -319,6 +329,41 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && $action === 'run') {
|
|||||||
. " rm -f " . escapeshellarg($pidFile);
|
. " rm -f " . escapeshellarg($pidFile);
|
||||||
shell_exec('nohup bash -c ' . escapeshellarg($inner) . ' &>/dev/null &');
|
shell_exec('nohup bash -c ' . escapeshellarg($inner) . ' &>/dev/null &');
|
||||||
|
|
||||||
|
// Recorded at launch, not at completion. What is worth recalling is the command that was
|
||||||
|
// assembled — a transfer that failed is often precisely the one to run again, and a poll that
|
||||||
|
// never returns because the tab was closed would otherwise lose it entirely.
|
||||||
|
//
|
||||||
|
// Flags are stored without --bwlimit: it was appended above from its own field, and keeping it
|
||||||
|
// in the list would restore it into the flag checkboxes where there is no such checkbox.
|
||||||
|
$recent = vv_ms_recent_load();
|
||||||
|
$key = vv_ms_recent_key($local, $slot, $remotePath);
|
||||||
|
$entry = [
|
||||||
|
'id' => $key,
|
||||||
|
'ts' => time(),
|
||||||
|
'local' => $local,
|
||||||
|
'slot' => $slot,
|
||||||
|
'user' => $user,
|
||||||
|
'rpath' => $remotePath,
|
||||||
|
'flags' => array_values(array_filter($flagList, fn($f) => !str_starts_with($f, '--bwlimit='))),
|
||||||
|
'bw' => $bwLimit,
|
||||||
|
'use_key' => $useKey,
|
||||||
|
'name' => '',
|
||||||
|
'sticky' => false,
|
||||||
|
];
|
||||||
|
$found = false;
|
||||||
|
foreach ($recent as $i => $r) {
|
||||||
|
if (($r['id'] ?? '') !== $key) continue;
|
||||||
|
// The same copy run again: refresh everything except what the operator chose about it.
|
||||||
|
// A name and a pin are decisions about the entry, not properties of the last run.
|
||||||
|
$entry['name'] = $r['name'] ?? '';
|
||||||
|
$entry['sticky'] = !empty($r['sticky']);
|
||||||
|
$recent[$i] = $entry;
|
||||||
|
$found = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (!$found) $recent[] = $entry;
|
||||||
|
vv_ms_recent_save($recent);
|
||||||
|
|
||||||
echo json_encode(['ok' => true, 'token' => $token]);
|
echo json_encode(['ok' => true, 'token' => $token]);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
@@ -373,4 +418,91 @@ if ($action === 'poll') {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── recent ────────────────────────────────────────────────────────────────────
|
||||||
|
// The last handful of manual syncs, so a repeat is one click rather than retyping two paths and
|
||||||
|
// nine checkboxes correctly. Kept on the server rather than in localStorage: this operator drives
|
||||||
|
// the same install from two desktop screens and a phone, and a recall list that only exists in
|
||||||
|
// the browser that made it is a recall list that is missing whenever it is wanted.
|
||||||
|
//
|
||||||
|
// Rotation is by recency with a hard cap, except for pinned rows, which never rotate out and do
|
||||||
|
// not count against the cap. That is the whole reason pinning exists — a seeding command used
|
||||||
|
// twice a year is exactly the one worth keeping and exactly the one ten ordinary runs would push
|
||||||
|
// off the end.
|
||||||
|
//
|
||||||
|
// Identity is the transfer itself — source, host, destination — not the flags. Re-running the same
|
||||||
|
// copy with --delete added is the same entry with different options, and keeping both would fill
|
||||||
|
// the list with near-duplicates that differ in the one place nobody reads.
|
||||||
|
function vv_ms_recent_path(): string {
|
||||||
|
return rtrim((string)(vv_conf_vars()['STATE_DIR'] ?? STATE_DIR), '/') . '/manual_sync_recent.json';
|
||||||
|
}
|
||||||
|
|
||||||
|
function vv_ms_recent_load(): array {
|
||||||
|
$raw = @file_get_contents(vv_ms_recent_path());
|
||||||
|
if ($raw === false) return [];
|
||||||
|
$d = json_decode($raw, true);
|
||||||
|
return is_array($d) ? $d : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function vv_ms_recent_save(array $rows): bool {
|
||||||
|
// Pinned first so the cap can never evict one, then newest, then trimmed.
|
||||||
|
usort($rows, function ($a, $b) {
|
||||||
|
$p = (int)!empty($b['sticky']) <=> (int)!empty($a['sticky']);
|
||||||
|
return $p !== 0 ? $p : ((int)($b['ts'] ?? 0) <=> (int)($a['ts'] ?? 0));
|
||||||
|
});
|
||||||
|
$kept = [];
|
||||||
|
$loose = 0;
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
if (!empty($r['sticky'])) { $kept[] = $r; continue; }
|
||||||
|
if ($loose >= 10) continue;
|
||||||
|
$loose++;
|
||||||
|
$kept[] = $r;
|
||||||
|
}
|
||||||
|
$path = vv_ms_recent_path();
|
||||||
|
$tmp = $path . '.tmp';
|
||||||
|
if (@file_put_contents($tmp, json_encode($kept, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)) === false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return @rename($tmp, $path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Identity of a transfer, for de-duplicating repeats of the same copy.
|
||||||
|
function vv_ms_recent_key(string $local, string $slot, string $rpath): string {
|
||||||
|
return substr(hash('sha256', $local . '|' . $slot . '|' . $rpath), 0, 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'recent') {
|
||||||
|
echo json_encode(['ok' => true, 'rows' => array_values(vv_ms_recent_load())]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST' && $action === 'recent_update') {
|
||||||
|
$id = preg_replace('/[^a-f0-9]/', '', trim($_POST['id'] ?? ''));
|
||||||
|
$op = trim($_POST['op'] ?? '');
|
||||||
|
if ($id === '') { echo json_encode(['ok' => false, 'error' => 'Missing id']); exit; }
|
||||||
|
|
||||||
|
$rows = vv_ms_recent_load();
|
||||||
|
$hit = false;
|
||||||
|
foreach ($rows as $i => $r) {
|
||||||
|
if (($r['id'] ?? '') !== $id) continue;
|
||||||
|
$hit = true;
|
||||||
|
if ($op === 'rename') {
|
||||||
|
// Plain text only, and short. It is a label in a list, and anything richer is markup
|
||||||
|
// waiting to be rendered somewhere that forgot to escape it.
|
||||||
|
$name = trim((string)($_POST['name'] ?? ''));
|
||||||
|
$name = preg_replace('/[^\p{L}\p{N} ._\-\/→>]/u', '', $name);
|
||||||
|
$rows[$i]['name'] = mb_substr($name, 0, 60);
|
||||||
|
} elseif ($op === 'sticky') {
|
||||||
|
$rows[$i]['sticky'] = ($_POST['value'] ?? '0') === '1';
|
||||||
|
} elseif ($op === 'delete') {
|
||||||
|
unset($rows[$i]);
|
||||||
|
} else {
|
||||||
|
echo json_encode(['ok' => false, 'error' => 'Unknown op']); exit;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (!$hit) { echo json_encode(['ok' => false, 'error' => 'No such entry']); exit; }
|
||||||
|
echo json_encode(['ok' => vv_ms_recent_save(array_values($rows))]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
echo json_encode(['ok' => false, 'error' => 'Unknown action']);
|
echo json_encode(['ok' => false, 'error' => 'Unknown action']);
|
||||||
|
|||||||
@@ -147,6 +147,22 @@ require_once dirname(__DIR__) . '/include/ai_chat.php';
|
|||||||
.vv-ms-dir:hover { background:#0f1f0f;color:#4caf50; }
|
.vv-ms-dir:hover { background:#0f1f0f;color:#4caf50; }
|
||||||
.vv-ms-dir.parent { color:#444;font-style:italic; }
|
.vv-ms-dir.parent { color:#444;font-style:italic; }
|
||||||
.vv-ms-dir.parent:hover { color:#888; }
|
.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-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 { display:flex;align-items:center;gap:5px;cursor:pointer; }
|
||||||
.vv-ms-chk input { accent-color:#4caf50;width:13px;height:13px; }
|
.vv-ms-chk input { accent-color:#4caf50;width:13px;height:13px; }
|
||||||
@@ -179,6 +195,19 @@ require_once dirname(__DIR__) . '/include/ai_chat.php';
|
|||||||
</div>
|
</div>
|
||||||
<div id="vv-ms-body" style="display:none;">
|
<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 -->
|
<!-- Two-column browse layout -->
|
||||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:10px;">
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:10px;">
|
||||||
|
|
||||||
@@ -1629,6 +1658,7 @@ function vvMsToggle() {
|
|||||||
if (!open) {
|
if (!open) {
|
||||||
if (!_vvMsHosts.length) _vvMsLoadHosts();
|
if (!_vvMsHosts.length) _vvMsLoadHosts();
|
||||||
else vvMsUpdatePreview();
|
else vvMsUpdatePreview();
|
||||||
|
vvMsLoadRecent(); // refreshed on open, so another tab or device is reflected here
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1933,6 +1963,7 @@ async function vvMsRun() {
|
|||||||
}
|
}
|
||||||
stat.textContent = 'Running…';
|
stat.textContent = 'Running…';
|
||||||
stopBtn.dataset.token = d.token;
|
stopBtn.dataset.token = d.token;
|
||||||
|
vvMsLoadRecent(); // the run just recorded itself server-side; show it
|
||||||
stopBtn.style.display = '';
|
stopBtn.style.display = '';
|
||||||
_vvMsStartPoll(d.token, btn, stat, out, badge, stopBtn);
|
_vvMsStartPoll(d.token, btn, stat, out, badge, stopBtn);
|
||||||
})
|
})
|
||||||
@@ -1980,6 +2011,108 @@ function _vvMsStartPoll(token, btn, stat, out, badge, stopBtn) {
|
|||||||
}, 1500);
|
}, 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 ────────────────────────────────────────────────────────────────
|
// ── Assistant ────────────────────────────────────────────────────────────────
|
||||||
// Scope is fixed to the tab rather than following a selection. Unlike the Scheduler, nothing here
|
// 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
|
// is "open" in the sense of one log or one script — the operator is looking at the whole picture
|
||||||
|
|||||||
Reference in New Issue
Block a user