feat: send or move files and folders to another machine or an SSH host from the move dialog, streamed as tar with progress, speed and cancel; same conflict rules as a local move
This commit is contained in:
1 parent
1a38c2ac18
commit
18d6684c55
7 files changed
+1274
-28
No files matched your search
@@ -107,6 +107,41 @@
|
||||
.seg button + button { border-left:1px solid var(--border); }
|
||||
.seg button.on { background:#12202f; color:var(--text); box-shadow:inset 0 -2px 0 var(--accent); }
|
||||
.seg button:hover { background:#151a21; color:var(--text); }
|
||||
.mv-top { display:flex; align-items:flex-end; gap:8px; }
|
||||
.mv-top .mv-net { height:36px; width:36px; flex:none; color:var(--muted); transition:background .18s, color .18s, border-color .18s; }
|
||||
.mv-top .mv-net:hover { color:var(--text); }
|
||||
.mv-top .mv-net.on { color:var(--accent); border-color:var(--accent); background:#12202f; }
|
||||
/* The other machine: slides open under the action. */
|
||||
.mv-remote { display:grid; grid-template-rows:0fr; opacity:0; transition:grid-template-rows .25s ease, opacity .2s ease; }
|
||||
.mv-remote > div { overflow:hidden; min-height:0; }
|
||||
.mv-remote.on { grid-template-rows:1fr; opacity:1; }
|
||||
.mv-remote.on > div { overflow:visible; }
|
||||
.mv-ssh { display:grid; gap:8px; margin-top:10px; padding:10px 12px; border:1px solid var(--border); border-radius:9px; background:#0f1318;
|
||||
animation:mv-in .2s ease; }
|
||||
.mv-ssh[hidden], .mv-a[hidden] { display:none; }
|
||||
@keyframes mv-in { from { opacity:0; transform:translateY(-4px); } }
|
||||
.mv-ssh label { margin:0; display:grid; gap:4px; font-size:12px; color:var(--muted); }
|
||||
.mv-ssh input[type=number] { width:100%; height:36px; padding:6px 10px; }
|
||||
.mv-row { display:flex; gap:10px; align-items:end; }
|
||||
.mv-row .grow { flex:1; min-width:0; }
|
||||
.mv-row .port { width:92px; flex:none; }
|
||||
.mv-ssh .seg-auth { margin-top:2px; display:flex; }
|
||||
.mv-ssh .seg-auth button { flex:1; padding:6px 8px; font-size:13px; }
|
||||
@media (max-width:520px) { .mv-ssh .seg-auth button { font-size:12px; padding:6px 4px; white-space:nowrap; } }
|
||||
.mv-a { display:grid; gap:8px; animation:mv-in .18s ease; }
|
||||
.mv-a textarea { width:100%; box-sizing:border-box; font:12px/1.4 ui-monospace,Menlo,monospace; resize:vertical; min-height:80px;
|
||||
background:var(--bg); color:var(--text); border:1px solid var(--border); border-radius:8px; padding:8px 10px; }
|
||||
.mv-a textarea:focus { border-color:var(--accent); outline:none; }
|
||||
p.mv-a { margin:0; font-size:13px; }
|
||||
#mv-keyname { font-size:12px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; align-self:center; }
|
||||
.mv-prog { margin-top:14px; animation:mv-in .2s ease; }
|
||||
.mv-prog[hidden] { display:none; }
|
||||
.mv-bar { height:6px; border-radius:3px; background:#1a2029; overflow:hidden; }
|
||||
.mv-bar i { display:block; height:100%; width:0; background:var(--accent); border-radius:3px; transition:width .5s ease; }
|
||||
.mv-bar.wait i { width:30% !important; animation:mv-slide 1.1s ease-in-out infinite; }
|
||||
@keyframes mv-slide { from { transform:translateX(-100%); } to { transform:translateX(340%); } }
|
||||
.mv-ptext { display:flex; align-items:center; justify-content:space-between; gap:10px; margin-top:8px; font-size:13px; color:var(--muted); }
|
||||
.mv.busy input, .mv.busy select, .mv.busy textarea, .mv.busy .seg, .mv.busy .mv-net { pointer-events:none; opacity:.6; }
|
||||
.mv-dirbox { position:relative; }
|
||||
.mv-dirbox input { font-family:ui-monospace,Menlo,monospace; font-size:13px; }
|
||||
.mv-sug { position:absolute; left:0; right:0; top:calc(100% + 4px); z-index:5; max-height:220px; overflow:auto;
|
||||
@@ -342,10 +377,32 @@
|
||||
</div>
|
||||
<div class="dlg-b mv">
|
||||
<div class="pp-path" id="mv-src"></div>
|
||||
<div class="seg" id="mv-op" role="radiogroup">
|
||||
<button type="button" data-op="move" role="radio">Переместить</button>
|
||||
<button type="button" data-op="copy" role="radio">Копировать</button>
|
||||
<div class="mv-top">
|
||||
<div class="seg" id="mv-op" role="radiogroup">
|
||||
<button type="button" data-op="move" role="radio">Переместить</button>
|
||||
<button type="button" data-op="copy" role="radio">Копировать</button>
|
||||
</div>
|
||||
<button type="button" class="btn ghost sq mv-net" id="mv-net" title="На другую машину или по SSH" aria-label="На другую машину или по SSH" aria-pressed="false"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="2" width="6" height="6" rx="1"/><rect x="2" y="16" width="6" height="6" rx="1"/><rect x="16" y="16" width="6" height="6" rx="1"/><path d="M12 8v4M5 16v-2a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v2"/></svg></button>
|
||||
</div>
|
||||
<div class="mv-remote" id="mv-remote"><div>
|
||||
<label for="mv-to">На машину</label>
|
||||
<select id="mv-to"></select>
|
||||
<div class="mv-ssh" id="mv-ssh" hidden>
|
||||
<div class="mv-row"><label class="grow">Хост<input type="text" id="mv-host" placeholder="example.com" autocomplete="off" spellcheck="false" autocapitalize="off"></label>
|
||||
<label class="port">Порт<input type="number" id="mv-port" min="1" max="65535" value="22"></label></div>
|
||||
<label>Пользователь<input type="text" id="mv-user" placeholder="root" autocomplete="off" spellcheck="false" autocapitalize="off"></label>
|
||||
<div class="seg seg-auth" id="mv-auth">
|
||||
<button type="button" data-auth="password" class="on">Пароль</button><button type="button" data-auth="key">Ключ</button><button type="button" data-auth="agent">Ключи агента</button>
|
||||
</div>
|
||||
<div class="mv-a" data-for="password"><input type="password" id="mv-pw" placeholder="пароль" autocomplete="off"></div>
|
||||
<div class="mv-a" data-for="key" hidden>
|
||||
<textarea id="mv-key" rows="4" spellcheck="false" placeholder="-----BEGIN OPENSSH PRIVATE KEY----- …вставьте ключ или загрузите файл"></textarea>
|
||||
<div class="mv-row"><button type="button" class="btn ghost small" id="mv-keyload">Загрузить файл…</button><span class="muted" id="mv-keyname"></span><input type="file" id="mv-keyfile" hidden></div>
|
||||
<input type="password" id="mv-pass" placeholder="фраза ключа, если есть" autocomplete="off">
|
||||
</div>
|
||||
<p class="mv-a muted" data-for="agent" hidden>Агент войдёт своими ключами из ~/.ssh без фразы.</p>
|
||||
</div>
|
||||
</div></div>
|
||||
<label for="mv-dir">Куда (папка)</label>
|
||||
<div class="mv-dirbox">
|
||||
<input type="text" id="mv-dir" autocomplete="off" spellcheck="false" autocapitalize="off">
|
||||
@@ -359,6 +416,10 @@
|
||||
<option value="replace">заменить (папки слить, файлы заменить)</option>
|
||||
<option value="skip">оставить существующее (папки слить)</option>
|
||||
</select>
|
||||
<div class="mv-prog" id="mv-prog" hidden>
|
||||
<div class="mv-bar"><i id="mv-bar"></i></div>
|
||||
<div class="mv-ptext"><span id="mv-ptext">подготовка…</span><button type="button" class="btn ghost small" id="mv-stop">Отменить</button></div>
|
||||
</div>
|
||||
<div class="pp-act"><button type="button" id="mv-go">Переместить</button></div>
|
||||
</div>
|
||||
</dialog>
|
||||
@@ -1139,18 +1200,24 @@
|
||||
}
|
||||
|
||||
// ---------- Move or copy ----------
|
||||
const M = { src: '', op: 'move', sug: [], sel: -1, seq: 0 };
|
||||
const dirCache = new Map(); // folder -> {at, names} of its subfolders
|
||||
// remote: to another machine (to: its id) or an SSH host (to: 'ssh').
|
||||
const M = { src: '', op: 'move', sug: [], sel: -1, seq: 0, remote: false, to: '', auth: 'password', job: null };
|
||||
const dirCache = new Map(); // machine + folder -> {at, names} of its subfolders
|
||||
// The machine the folder is on: this one, or the one it goes to.
|
||||
const target = () => !M.remote ? agentID : M.to === 'ssh' ? '' : M.to;
|
||||
// Lists a folder for the suggestions, quietly: no spinner, kept a little.
|
||||
async function subdirs(path) {
|
||||
const c = dirCache.get(path);
|
||||
const on = target();
|
||||
if (!on) return [];
|
||||
const key = on + '\n' + path;
|
||||
const c = dirCache.get(key);
|
||||
if (c && Date.now() - c.at < 30000) return c.names;
|
||||
try {
|
||||
const r = await fetch('/api/panel/agents/' + encodeURIComponent(agentID) + '/fs/list', {
|
||||
const r = await fetch('/api/panel/agents/' + encodeURIComponent(on) + '/fs/list', {
|
||||
method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({path})
|
||||
}).then(r => r.json());
|
||||
const names = r.ok ? (r.data.entries || []).filter(e => e.is_dir).map(e => e.name).sort(byNameStr) : [];
|
||||
dirCache.set(path, {at: Date.now(), names});
|
||||
dirCache.set(key, {at: Date.now(), names});
|
||||
return names;
|
||||
} catch (e) { return []; }
|
||||
}
|
||||
@@ -1158,8 +1225,139 @@
|
||||
function setOp(op) {
|
||||
M.op = op;
|
||||
document.querySelectorAll('#mv-op button').forEach(b => { b.classList.toggle('on', b.dataset.op === op); b.setAttribute('aria-checked', b.dataset.op === op); });
|
||||
$('mv-go').textContent = op === 'move' ? 'Переместить' : 'Копировать';
|
||||
goLabel();
|
||||
}
|
||||
function goLabel() {
|
||||
$('mv-go').textContent = (M.op === 'move' ? 'Переместить' : 'Копировать') + (M.remote ? ' туда' : '');
|
||||
}
|
||||
// ---- to another machine, or by SSH ----
|
||||
function lastShip() { try { return JSON.parse(localStorage.getItem('lpra.ship.last') || '{}'); } catch (e) { return {}; } }
|
||||
function setRemote(on) {
|
||||
M.remote = on;
|
||||
$('mv-net').classList.toggle('on', on);
|
||||
$('mv-net').setAttribute('aria-pressed', on);
|
||||
$('mv-remote').classList.toggle('on', on);
|
||||
goLabel();
|
||||
if (on) loadTargets();
|
||||
$('mv-sug').hidden = true;
|
||||
}
|
||||
function setTo(v) {
|
||||
M.to = v;
|
||||
$('mv-ssh').hidden = v !== 'ssh';
|
||||
dirCache.clear();
|
||||
}
|
||||
function setAuth(a) {
|
||||
M.auth = a;
|
||||
document.querySelectorAll('#mv-auth button').forEach(b => b.classList.toggle('on', b.dataset.auth === a));
|
||||
document.querySelectorAll('.mv-a').forEach(el => { el.hidden = el.dataset.for !== a; });
|
||||
}
|
||||
async function loadTargets() {
|
||||
const sel = $('mv-to'), last = lastShip();
|
||||
sel.innerHTML = '<option value="" disabled>загрузка…</option>';
|
||||
let all = [];
|
||||
try { all = await fetch('/api/panel/agents/presence', {cache: 'no-store'}).then(r => r.json()); } catch (e) { /* SSH only */ }
|
||||
sel.innerHTML = '';
|
||||
all.filter(a => a.id !== agentID).forEach(a => {
|
||||
const o = document.createElement('option');
|
||||
o.value = a.id; o.disabled = !a.online;
|
||||
o.textContent = a.name + ' · ' + a.user + '@' + a.hostname + (a.online ? '' : ' (не в сети)');
|
||||
sel.appendChild(o);
|
||||
});
|
||||
const o = document.createElement('option');
|
||||
o.value = 'ssh'; o.textContent = 'SSH-хост…';
|
||||
sel.appendChild(o);
|
||||
const ok = v => [...sel.options].some(x => x.value === v && !x.disabled);
|
||||
const first = [...sel.options].find(x => !x.disabled);
|
||||
sel.value = ok(last.to) ? last.to : first ? first.value : 'ssh';
|
||||
setTo(sel.value);
|
||||
if (last.ssh) { $('mv-host').value = last.ssh.host || ''; $('mv-port').value = last.ssh.port || 22; $('mv-user').value = last.ssh.user || ''; setAuth(last.ssh.auth || 'password'); }
|
||||
}
|
||||
$('mv-net').onclick = () => { if (!M.job) setRemote(!M.remote); };
|
||||
$('mv-to').onchange = ev => { setTo(ev.target.value); suggest(); };
|
||||
document.querySelectorAll('#mv-auth button').forEach(b => b.onclick = () => setAuth(b.dataset.auth));
|
||||
$('mv-keyload').onclick = () => $('mv-keyfile').click();
|
||||
$('mv-keyfile').onchange = () => {
|
||||
const f = $('mv-keyfile').files[0];
|
||||
if (!f) return;
|
||||
if (f.size > 32768) { toast('файл великоват для ключа', true); return; }
|
||||
f.text().then(t => { $('mv-key').value = t.trim() + '\n'; $('mv-keyname').textContent = f.name; });
|
||||
$('mv-keyfile').value = '';
|
||||
};
|
||||
function sshTarget() {
|
||||
const t = {host: $('mv-host').value.trim(), port: Number($('mv-port').value) || 22, user: $('mv-user').value.trim()};
|
||||
if (!t.host || !t.user) return {error: 'укажите хост и пользователя'};
|
||||
if (M.auth === 'password') {
|
||||
if (!$('mv-pw').value) return {error: 'введите пароль'};
|
||||
t.password = $('mv-pw').value;
|
||||
} else if (M.auth === 'key') {
|
||||
if (!/PRIVATE KEY/.test($('mv-key').value)) return {error: 'вставьте закрытый ключ или загрузите файл'};
|
||||
t.key = $('mv-key').value;
|
||||
if ($('mv-pass').value) t.passphrase = $('mv-pass').value;
|
||||
}
|
||||
return {t};
|
||||
}
|
||||
function busy(on) {
|
||||
document.querySelector('#mvdlg .mv').classList.toggle('busy', on);
|
||||
$('mv-go').disabled = on;
|
||||
$('mv-prog').hidden = !on;
|
||||
}
|
||||
// ship sends to the other machine and follows the job to its end.
|
||||
async function ship(dir, name, conflict, move) {
|
||||
const body = {src: M.src, name, dir, conflict, move};
|
||||
const keep = {to: M.to};
|
||||
if (M.to === 'ssh') {
|
||||
const r = sshTarget();
|
||||
if (r.error) { toast(r.error, true); return; }
|
||||
body.ssh = r.t;
|
||||
keep.ssh = {host: r.t.host, port: r.t.port, user: r.t.user, auth: M.auth};
|
||||
} else {
|
||||
if (!M.to) { toast('выберите машину', true); return; }
|
||||
body.to = M.to;
|
||||
keep.ssh = lastShip().ssh;
|
||||
}
|
||||
try { localStorage.setItem('lpra.ship.last', JSON.stringify(keep)); } catch (e) { /* not kept */ }
|
||||
busy(true);
|
||||
$('mv-bar').parentNode.classList.add('wait');
|
||||
$('mv-bar').style.width = '0';
|
||||
$('mv-ptext').textContent = 'подготовка…';
|
||||
const r = await api('ship', body);
|
||||
$('mv-pw').value = ''; $('mv-pass').value = '';
|
||||
if (!r.ok) { busy(false); toast(r.error || 'ошибка', true); return; }
|
||||
const job = M.job = r.data.job;
|
||||
let v = 0, res = null;
|
||||
const t0 = Date.now(), seen = [];
|
||||
while (!res) {
|
||||
let j;
|
||||
try { j = await fetch('/api/panel/fs/job/' + job + '?v=' + v, {cache: 'no-store'}).then(x => x.json()); }
|
||||
catch (e) { await new Promise(ok => setTimeout(ok, 1500)); continue; }
|
||||
v = j.v || 0;
|
||||
const p = j.progress || {};
|
||||
if (p.total > 0 || p.done > 0) {
|
||||
$('mv-bar').parentNode.classList.remove('wait');
|
||||
$('mv-bar').style.width = (p.total ? Math.min(100, p.done / p.total * 100) : 0).toFixed(1) + '%';
|
||||
// Speed over the last few seconds.
|
||||
seen.push([Date.now(), p.done]);
|
||||
while (seen.length > 2 && Date.now() - seen[0][0] > 4000) seen.shift();
|
||||
const dt = (seen[seen.length - 1][0] - seen[0][0]) / 1000, rate = dt > 0.5 ? (seen[seen.length - 1][1] - seen[0][1]) / dt : 0;
|
||||
$('mv-ptext').textContent = fmtSize(p.done) + ' из ' + fmtSize(p.total) + ' · файлов: ' + p.files + (rate ? ' · ' + fmtSize(Math.round(rate)) + '/с' : '');
|
||||
} else if (p.note) {
|
||||
$('mv-ptext').textContent = p.note === 'подсчёт' ? 'подсчёт…' : p.note + '…';
|
||||
}
|
||||
if (j.done) res = j.result;
|
||||
}
|
||||
M.job = null;
|
||||
if (!res.ok) { busy(false); toast(res.error || 'ошибка', true); return; }
|
||||
$('mv-bar').style.width = '100%';
|
||||
const d = res.data || {}, secs = Math.max(1, Math.round((Date.now() - t0) / 1000));
|
||||
const what = move ? 'перенесено' : 'отправлено';
|
||||
toast(what + (d.done != null ? ': ' + d.done : d.files != null ? ': ' + d.files : '') + (d.skipped ? ', оставлено как было: ' + d.skipped : '') + ' · ' + secs + ' с');
|
||||
setTimeout(() => { busy(false); if ($('mvdlg').open) $('mvdlg').close(); }, 400);
|
||||
if (move) load(cwd);
|
||||
}
|
||||
$('mv-stop').onclick = () => {
|
||||
if (M.job) fetch('/api/panel/fs/job/' + M.job + '/cancel', {method: 'POST'});
|
||||
$('mv-ptext').textContent = 'отмена…';
|
||||
};
|
||||
document.querySelectorAll('#mv-op button').forEach(b => b.onclick = () => setOp(b.dataset.op));
|
||||
// Suggestions: the subfolders of what is typed, matching its last part.
|
||||
async function suggest() {
|
||||
@@ -1212,6 +1410,7 @@
|
||||
M.src = join(cwd, e.name);
|
||||
$('mv-src').textContent = M.src + (e.is_dir ? '/' : '');
|
||||
setOp('move');
|
||||
if (!M.job) { busy(false); setRemote(false); }
|
||||
$('mv-dir').value = cwd === '/' ? '/' : cwd + '/';
|
||||
$('mv-name').value = e.name;
|
||||
$('mv-conflict').value = 'error';
|
||||
@@ -1227,6 +1426,7 @@
|
||||
if (!name || name.includes('/')) { toast('имя - без «/»', true); return; }
|
||||
const dst = (dir === '/' ? '' : dir) + '/' + name;
|
||||
const conflict = $('mv-conflict').value, move = M.op === 'move';
|
||||
if (M.remote) { if (!M.job) ship(dir, name, conflict, move); return; }
|
||||
const btn = $('mv-go');
|
||||
btn.disabled = true;
|
||||
let r = await api('copy', {src: M.src, dst, move, conflict});
|
||||
|
||||
Reference in new issue
Block a user