Files
lpra/internal/server/web/static/filepane.js
T

450 lines
28 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// File panes of the workspace: a folder of a machine - or of an SSH host
// through one - as a list to go through, with uploads, new folders and files,
// zip and delete. Each pane keeps its own folder, list and selection; the
// dialogs (viewer, properties, move) are shared and live in fileviews.js.
// A pane's data source does the work: MachineSource here, SSHSource for
// files over SSH.
(function () {
'use strict';
const FV = window.FileViews;
const {toast, join, parent, fmtSize, fmtTime, esc} = FV;
const X = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg>';
// Monochrome inline icons (inherit button color via currentColor).
const SVG = '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">';
const ICON_DL = SVG + '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>';
const ICON_MV = SVG + '<polyline points="5 9 2 12 5 15"/><polyline points="9 5 12 2 15 5"/><polyline points="15 19 12 22 9 19"/><polyline points="19 9 22 12 19 15"/><line x1="2" y1="12" x2="22" y2="12"/><line x1="12" y1="2" x2="12" y2="22"/></svg>';
const ICON_UNZIP = SVG + '<rect x="2" y="3" width="20" height="5" rx="1"/><path d="M4 8v11a2 2 0 0 0 2 2h2"/><path d="M20 8v11a2 2 0 0 1-2 2h-2"/><path d="m9 15 3-3 3 3"/><path d="M12 12v9"/></svg>';
const ICON_ZIP = SVG + '<path d="M21 8v12a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V8"/><rect x="2" y="3" width="20" height="5" rx="1"/><path d="M10 12h4"/></svg>';
const ICON_DEL = SVG + '<path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>';
// File icons: one colour, the line style of the buttons; a few kinds by
// the name's ending.
const FI = '<svg class="fi" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">';
const FICONS = {
dir: FI + '<path d="M3 7a2 2 0 0 1 2-2h4l2 2.5h8a2 2 0 0 1 2 2V17a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>',
file: FI + '<path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5"/></svg>',
text: FI + '<path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5M9 13h6M9 17h4"/></svg>',
code: FI + '<path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5M10 12l-2 2.5 2 2.5M14 12l2 2.5-2 2.5"/></svg>',
img: FI + '<rect x="3" y="4" width="18" height="16" rx="2"/><circle cx="9" cy="10" r="1.6"/><path d="m21 16-5-5-8 9"/></svg>',
arc: FI + '<path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5M11 4v2M11 8v2M11 12v2"/><rect x="9.5" y="15" width="3" height="3" rx=".6"/></svg>',
media:FI + '<rect x="3" y="5" width="18" height="14" rx="2"/><path d="m10 9 5 3-5 3z"/></svg>',
dirlink: FI + '<path d="M3 7a2 2 0 0 1 2-2h4l2 2.5h8a2 2 0 0 1 2 2V17a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><path d="M10 15.5l4-4M11 11.5h3v3"/></svg>',
link: FI + '<path d="M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1 1"/><path d="M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1"/></svg>'
};
function fileIcon(e) {
let k = 'file';
if (e.is_dir) k = e.link ? 'dirlink' : 'dir'; // a link to a folder opens like one
else if (/^L/.test(e.mode || '')) k = 'link';
else if (/\.(png|jpe?g|gif|webp|bmp|ico|svg|avif|heic)$/i.test(e.name)) k = 'img';
else if (/\.(zip|tar|gz|tgz|bz2|xz|zst|7z|rar|deb|rpm)$/i.test(e.name)) k = 'arc';
else if (/\.(mp4|m4v|webm|mov|mkv|avi|mp3|wav|ogg|flac|m4a|aac|opus)$/i.test(e.name)) k = 'media';
else if (/\.(js|ts|go|py|rb|rs|c|h|cpp|java|kt|php|sh|bash|zsh|json|ya?ml|toml|html?|css|sql|lua|pl|swift|vue|jsx|tsx|ini|xml)$/i.test(e.name)) k = 'code';
else if (/\.(txt|md|log|csv|rst|cfg|conf|ovpn|env|properties)$|^(readme|license|makefile|dockerfile)/i.test(e.name)) k = 'text';
return '<span class="ic' + (k === 'dir' || k === 'dirlink' ? ' dir' : '') + '"' + (e.link ? ' title="ссылка"' : '') + '>' + FICONS[k] + '</span>';
}
// A machine's files: the panel's file API of that machine.
class MachineSource {
constructor(agent) { this.agent = agent; this.caps = {zip: true, unzip: true, sqlite: true}; }
url(what, path) { return '/api/panel/agents/' + encodeURIComponent(this.agent.id) + '/fs/' + what + '?path=' + encodeURIComponent(path); }
op(name, body) {
return fetch('/api/panel/agents/' + encodeURIComponent(this.agent.id) + '/fs/' + name, {
method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body || {}), credentials: 'same-origin'
}).then(r => r.status === 404 ? {ok: false, error: 'нет доступа к машине'} : r.json()).catch(() => ({ok: false, error: 'ошибка сети'}));
}
home() { return '~' + (this.agent.user || ''); }
close() {}
}
// An SSH host's files, through the machine that holds the connection.
class SSHSource {
// caps: zip and unzip where the host has the commands.
constructor(agent, conn, home, caps) { this.agent = agent; this.conn = conn; this.homeDir = home || '/'; this.caps = Object.assign({zip: false, unzip: false, sqlite: false}, caps); }
base() { return '/api/panel/agents/' + encodeURIComponent(this.agent.id) + '/sshfs/' + encodeURIComponent(this.conn) + '/'; }
url(what, path) { return this.base() + what + '?path=' + encodeURIComponent(path); }
op(name, body) {
return fetch(this.base() + name, {
method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body || {}), credentials: 'same-origin'
}).then(r => r.status === 404 ? {ok: false, error: 'нет доступа к машине'} : r.json()).catch(() => ({ok: false, error: 'ошибка сети'}));
}
home() { return this.homeDir; }
// The pane closing lets the machine drop the connection at once.
close() { fetch(this.base() + 'close', {method: 'POST', keepalive: true, credentials: 'same-origin'}).catch(() => {}); }
}
const UP_CAP = 512 * 1024 * 1024;
const SORT_DEF = {by: 'name', dir: 1};
const byName = (a, b) => a.name.localeCompare(b.name, undefined, {numeric: true, sensitivity: 'base'}) || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
class FilePane {
// o: {kind: 'agent'|'ssh', agent, src} - src made by the caller for SSH.
constructor(o) {
this.agent = o.agent;
this.src = o.src || new MachineSource(o.agent);
this.kind = o.kind === 'ssh' ? 'sshfiles' : 'files';
this.ssh = o.ssh || null;
this.source = o.kind === 'ssh'
? {kind: 'ssh', id: o.agent.id + '|' + o.ssh.user + '@' + o.ssh.host + ':' + (o.ssh.port || 22), label: o.ssh.user + '@' + o.ssh.host}
: {kind: 'agent', id: o.agent.id, label: o.agent.name};
this.cwd = '/';
this.entries = [];
this.selected = new Set();
this.uploads = [];
this.upActive = false;
this.busyN = 0;
this.state = 'wait';
this.text = 'загрузка…';
this.el = document.createElement('div');
this.el.className = 'pane fpane';
this.el.innerHTML =
'<div class="pane-head"><span class="dot wait"></span>' + (this.ssh ? '<span class="badge-ssh">SSH</span>' : '') +
'<span class="pane-title"></span><button type="button" class="pane-x" title="Закрыть" aria-label="Закрыть">' + X + '</button></div>' +
'<div class="fpane-body">' +
'<div class="fm-bar">' +
' <button class="btn ghost small icon sq fp-up" type="button" title="Вверх" aria-label="Вверх"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19V5M5 12l7-7 7 7"/></svg></button>' +
' <div class="fm-pathbox"><input class="fm-path fp-path" value="/" spellcheck="false" autocapitalize="off" autocomplete="off" aria-label="Папка">' +
' <button type="button" class="btn ghost small icon sq fp-dirprops" title="Свойства папки" aria-label="Свойства папки"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M21 4h-7M10 4H3M21 12h-9M8 12H3M21 20h-5M12 20H3M14 2v4M8 10v4M16 18v4"/></svg></button></div>' +
' <button type="button" class="busy fp-busy" title=""><span class="spinner"></span>' +
'<svg class="fp-ring" width="18" height="18" viewBox="0 0 20 20" aria-hidden="true"><circle class="bg" cx="10" cy="10" r="8"/><circle class="fg" cx="10" cy="10" r="8" pathLength="100"/></svg>' +
'<span class="fp-uppct"></span></button>' +
' <button class="btn ghost small icon sq fp-refresh" type="button" title="Обновить" aria-label="Обновить"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-3-6.7L21 8"/><path d="M21 3v5h-5"/></svg></button>' +
'</div>' +
'<div class="fm-bar">' +
' <button class="btn small ico fp-mkdir" type="button"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/><path d="M12 11v6M9 14h6"/></svg>Папка</button>' +
' <button class="btn small ico fp-newfile" type="button"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6M12 12v6M9 15h6"/></svg>Файл</button>' +
' <label class="btn small ico" style="cursor:pointer"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v12M6 10l6 6 6-6M5 21h14"/></svg>Загрузить<input type="file" class="fp-upl" multiple hidden></label>' +
// What is done to the ticked rows: two small icons on the right,
// their words on hover, the count beside them.
' <span class="spacer"></span>' +
' <span class="fp-sel"><span class="muted fp-selinfo"></span>' +
' <button class="btn ghost small icon sq fp-zip" type="button" disabled title="В zip" aria-label="В zip">' + ICON_ZIP + '</button>' +
' <button class="btn danger small icon sq fp-delsel" type="button" disabled title="Удалить выбранное" aria-label="Удалить выбранное">' + ICON_DEL + '</button></span>' +
'</div>' +
'<div class="card fp-drop" style="padding:0"><table class="fm"><thead><tr>' +
' <th style="width:28px"><input type="checkbox" class="fp-all" aria-label="Выбрать все"></th>' +
' <th class="srt" data-sort="name">Имя</th><th class="sz c-sz srt" data-sort="size">Размер</th>' +
' <th class="sz c-mt srt" data-sort="mtime">Изменён</th><th class="c-act"></th>' +
'</tr></thead><tbody class="fp-list"></tbody></table></div>' +
'<p class="muted fp-msg" hidden></p>' +
'</div>';
this.q('.fp-zip').hidden = !this.src.caps.zip;
this.bind();
// Narrow panes (a split tab) fold like the phone layout.
// A mid-width pane gives the time's room to the names.
if (window.ResizeObserver) new ResizeObserver(() => {
const w = this.el.clientWidth;
this.el.classList.toggle('narrow', w < 520);
this.el.classList.toggle('mid', w < 760);
this.fitSel();
}).observe(this.el);
this.start();
}
q(sel) { return this.el.querySelector(sel); }
// ---- the pane interface of the workspace ----
title() { return (this.ssh ? this.ssh.host : this.agent.name) + ' · ' + (this.cwd === '/' ? '/' : this.cwd.replace(/\/+$/, '').split('/').pop()); }
tooltip() { return (this.ssh ? this.source.label : this.agent.name) + ':' + this.cwd; }
live() { return false; }
fit() {}
focus() { this.q('.fp-path').focus({preventScroll: true}); }
close() { this.src.close(); this.el.remove(); }
selectedPaths() { return [...this.selected].map(n => join(this.cwd, n)); }
// dropTarget: a folder row under the point, else this folder.
dropTarget(x, y) {
const el = document.elementFromPoint(x, y);
const tr = el && el.closest && el.closest('tr[data-dir]');
if (tr && this.el.contains(tr)) return {kind: 'folder', source: this.source, dir: join(this.cwd, tr.dataset.dir), pane: this, row: tr};
return {kind: 'files', source: this.source, dir: this.cwd, pane: this};
}
setState(state, text) {
this.state = state; this.text = text;
this.q('.pane-head .dot').className = 'dot ' + state;
this.q('.pane-title').textContent = this.title();
this.q('.pane-title').title = this.tooltip();
if (window.Workspace) Workspace.renderPane(this);
}
setBusy(on) {
this.busyN = Math.max(0, this.busyN + (on ? 1 : -1));
this.q('.fp-busy').classList.toggle('on', this.busyN > 0);
}
async api(op, body) {
this.setBusy(true);
try { return await this.src.op(op, body); } finally { this.setBusy(false); }
}
// message: a line over the list - why it is empty - with a button to
// do something about it.
message(text, label, fn) {
const m = this.q('.fp-msg');
m.hidden = !text;
m.textContent = text || '';
if (label) {
const b = document.createElement('button');
b.type = 'button'; b.className = 'btn ghost small'; b.textContent = label;
b.onclick = fn;
m.append(' ', b);
}
}
// trouble: the machine offline, the access gone or the SSH connection
// lost - the pane says so and offers the way back.
trouble(err) {
// A machine that just went away first times out, then is offline.
this.offline = /офлайн|таймаут ожидания агента/.test(err);
if (this.offline) this.message((/офлайн/.test(err) ? 'Машина офлайн' : 'Машина не отвечает') + '. Панель обновится сама, когда машина вернётся.', 'Повторить', () => this.reload());
else if (/нет доступа/.test(err)) this.message('Нет доступа к машине.');
else if (this.ssh && /подключение (к хосту потеряно|закрыто)/.test(err)) this.message(err, 'Переподключиться', () => Workspace.openSSH(this.agent, 'files', this));
else return false;
this.entries = []; this.selected.clear(); this.render();
this.setState('err', err);
return true;
}
async start() {
// Start in the home folder of the machine's user (or the SSH home);
// a listing that fails there falls back to the root.
const r = await this.api('list', {path: this.src.home()});
if (r.ok) this.show(r.data); else if (!this.trouble(r.error || '')) this.go('/');
}
show(d) {
this.offline = false; this.loaded = true;
this.cwd = d.path; this.entries = d.entries || []; this.selected.clear();
this.q('.fp-path').value = this.cwd;
this.message('');
this.render();
// The status says whether it is connected, as a terminal's does; the
// folder is in the pane itself.
this.setState('ok', this.ssh ? 'SSH: ' + this.source.label + ':' + (this.ssh.port || 22) : 'подключено');
}
async go(path) {
const r = await this.api('list', {path});
if (!r.ok) {
if (!this.trouble(r.error || '')) toast(r.error || 'ошибка', true);
return false;
}
this.show(r.data);
return true;
}
// Never listed yet (offline from the start): from the home folder.
reload() { return this.loaded ? this.go(this.cwd) : this.start(); }
// Sorting: by name, size or time, folders always ahead of files. Kept in
// this browser for each folder of each place.
sortKey() { return 'lpra.sort:' + this.source.id + ':' + this.cwd; }
getSort() {
try { const v = JSON.parse(localStorage.getItem(this.sortKey())); if (v && ['name', 'size', 'mtime'].includes(v.by)) return v; } catch (e) { /* default */ }
return SORT_DEF;
}
setSort(v) {
try {
if (v.by === SORT_DEF.by && v.dir === SORT_DEF.dir) localStorage.removeItem(this.sortKey());
else localStorage.setItem(this.sortKey(), JSON.stringify(v));
} catch (e) { /* not kept */ }
}
sorted() {
const s = this.getSort();
const key = s.by === 'size' ? e => e.size || 0 : s.by === 'mtime' ? e => e.mtime || 0 : null;
return this.entries.slice().sort((a, b) => {
if (a.is_dir !== b.is_dir) return a.is_dir ? -1 : 1;
const d = key ? key(a) - key(b) : 0;
return (d || byName(a, b)) * s.dir;
});
}
render() {
const tb = this.q('.fp-list');
tb.innerHTML = '';
this.updateSel();
for (const e of this.sorted()) {
const tr = document.createElement('tr');
tr.draggable = false;
tr.addEventListener('dragstart', ev => { ev.preventDefault(); ev.stopPropagation(); });
if (e.is_dir) tr.dataset.dir = e.name;
tr.dataset.name = e.name;
const isZip = this.src.caps.unzip && !e.is_dir && /\.zip$/i.test(e.name);
tr.innerHTML =
'<td><input type="checkbox" class="ck" aria-label="Выбрать"></td>' +
'<td class="nm">' + fileIcon(e) + esc(e.name) + '</td>' +
'<td class="sz c-sz">' + (e.is_dir ? '' : fmtSize(e.size)) + '</td>' +
'<td class="sz mt">' + fmtTime(e.mtime) + '</td>' +
'<td><div class="act">' +
(isZip ? '<button class="btn ghost small icon" data-act="unzip" title="Распаковать сюда">' + ICON_UNZIP + '</button>' : '') +
(e.is_dir ? '' : '<a class="btn ghost small icon" title="Скачать" download>' + ICON_DL + '</a>') +
'<button class="btn ghost small icon" data-act="ren" title="Переместить или копировать">' + ICON_MV + '</button>' +
'<button class="btn danger small icon" data-act="del" title="Удалить">' + ICON_DEL + '</button>' +
'</div></td>';
const dl = tr.querySelector('a[download]');
if (dl) { dl.draggable = false; dl.href = this.src.url('download', join(this.cwd, e.name)); }
tr.querySelector('.nm').onclick = () => e.is_dir ? this.go(join(this.cwd, e.name)) : FV.view(this, e);
tr.querySelector('.ck').checked = this.selected.has(e.name);
tr.querySelector('.ck').onchange = ev => { ev.target.checked ? this.selected.add(e.name) : this.selected.delete(e.name); this.updateSel(); };
tr.querySelector('[data-act="del"]').onclick = () => FV.del(this, e);
tr.querySelector('[data-act="ren"]').onclick = () => FV.move(this, e);
const uz = tr.querySelector('[data-act="unzip"]');
if (uz) uz.onclick = () => this.unzip(e);
if (window.FileDnD) tr.addEventListener('pointerdown', ev => FileDnD.start(ev, this, tr));
tb.appendChild(tr);
}
}
// fitSel: the selection's buttons on the right of the blue ones; moved to
// a line of their own, they start on the left.
fitSel() {
const sel = this.q('.fp-sel');
sel.classList.remove('wrapped');
sel.classList.toggle('wrapped', sel.offsetTop > this.q('.fp-mkdir').offsetTop + 4);
}
updateSel() {
const n = this.selected.size;
// A narrow pane (a phone) has room for the number alone.
this.q('.fp-selinfo').textContent = n ? (this.el.classList.contains('narrow') ? n : 'Выбрано: ' + n) : '';
this.q('.fp-selinfo').title = n ? 'Выбрано: ' + n : '';
this.fitSel();
this.q('.fp-zip').disabled = !n;
this.q('.fp-delsel').disabled = !n;
}
async unzip(e) {
const r = await this.api('unzip', {path: join(this.cwd, e.name), dest: this.cwd});
r.ok ? (toast('распаковано'), this.reload()) : toast(r.error || 'ошибка', true);
}
bind() {
const q = s => this.q(s);
q('.pane-x').onclick = ev => { ev.stopPropagation(); Workspace.closePane(this); };
q('.pane-head').addEventListener('pointerdown', ev => { if (Workspace.desk && !ev.target.closest('.pane-x')) Workspace.startDrag(ev, 'pane', this); });
q('.pane-head').addEventListener('contextmenu', ev => { if (window.MachineDialog) MachineDialog.menu(ev, this.agent); });
this.el.addEventListener('pointerdown', () => Workspace.setActivePane(this), true);
q('.fp-up').onclick = () => this.go(parent(this.cwd));
q('.fp-refresh').onclick = () => this.reload();
q('.fp-dirprops').onclick = () => FV.props(this, this.cwd);
q('.fp-path').addEventListener('keydown', e => {
if (e.key === 'Enter') { e.preventDefault(); this.go((q('.fp-path').value || '').trim() || '/'); }
});
this.el.querySelectorAll('table.fm th.srt').forEach(th => th.onclick = () => {
const s = this.getSort(), by = th.dataset.sort;
// A new column starts the way it is most often wanted: names A to Z,
// sizes and times biggest and newest first.
this.setSort(s.by === by ? {by, dir: -s.dir} : {by, dir: by === 'name' ? 1 : -1});
this.render();
});
q('.fp-mkdir').onclick = async () => {
const n = await UI.prompt('Имя новой папки:'); if (!n) return;
const r = await this.api('mkdir', {path: join(this.cwd, n)});
r.ok ? (toast('создано'), this.reload()) : toast(r.error || 'ошибка', true);
};
q('.fp-newfile').onclick = async () => {
const n = await UI.prompt('Имя нового файла:'); if (!n) return;
const r = await this.api('write', {path: join(this.cwd, n), data: ''});
r.ok ? (toast('создано'), this.reload()) : toast(r.error || 'ошибка', true);
};
q('.fp-delsel').onclick = async () => {
if (!await UI.confirm('Удалить выбранное (' + this.selected.size + ')?', {ok: 'Удалить', danger: true})) return;
for (const n of this.selected) {
const e = this.entries.find(x => x.name === n);
await this.api(e && e.is_dir ? 'deltree' : 'delete', {path: join(this.cwd, n)});
}
toast('удалено'); this.reload();
};
q('.fp-zip').onclick = async () => {
const name = await UI.prompt('Имя архива:', 'archive.zip'); if (!name) return;
const r = await this.api('zip', {paths: this.selectedPaths(), dest: join(this.cwd, name)});
r.ok ? (toast('архив создан'), this.reload()) : toast(r.error || 'ошибка', true);
};
q('.fp-all').onchange = ev => {
this.selected.clear();
if (ev.target.checked) this.entries.forEach(e => this.selected.add(e.name));
this.el.querySelectorAll('.fp-list .ck').forEach(c => { c.checked = ev.target.checked; });
this.updateSel();
};
q('.fp-busy').onclick = () => { if (this.uploads.length) FV.uploads(this); };
q('.fp-upl').onchange = ev => { this.upload(ev.target.files); ev.target.value = ''; };
const drop = q('.fp-drop');
// Files from the computer dropped on the list go up here.
const fromOS = e => e.dataTransfer && [...e.dataTransfer.types].includes('Files');
['dragenter', 'dragover'].forEach(x => drop.addEventListener(x, e => { if (!fromOS(e)) return; e.preventDefault(); drop.classList.add('drag'); }));
drop.addEventListener('dragleave', () => drop.classList.remove('drag'));
drop.addEventListener('drop', e => {
if (!fromOS(e)) return;
e.preventDefault();
drop.classList.remove('drag');
if (e.dataTransfer.files.length) this.upload(e.dataTransfer.files);
});
}
// Uploads: one file at a time, each with its progress. What has gone up
// shows as a percentage by the spinner; a click on it lists the files.
renderUploads() {
const ups = this.uploads;
const pend = ups.filter(u => u.state === 'wait' || u.state === 'send' || u.state === 'write');
const counted = ups.filter(u => !u.skip);
const total = counted.reduce((n, u) => n + u.size, 0) || 1;
const sent = counted.reduce((n, u) => n + (u.state === 'wait' ? 0 : u.state === 'send' ? u.size * u.frac : u.size), 0);
const busy = this.q('.fp-busy'), pct = this.q('.fp-uppct');
busy.classList.toggle('up', ups.length > 0);
busy.title = ups.length ? 'Показать загружаемые файлы' : '';
if (pend.length) pct.textContent = Math.floor(100 * sent / total) + '%' + (counted.length > 1 ? ' · ' + (counted.length - pend.length) + '/' + counted.length : '');
// While files go up, the round mark fills as they do.
busy.classList.toggle('uploading', pend.length > 0);
busy.style.setProperty('--up', String(pend.length ? 100 * sent / total : 0));
pct.classList.toggle('on', pend.length > 0);
FV.uploadsChanged(this);
}
async upload(files) {
clearTimeout(this.upDoneTimer);
// A new batch starts a fresh list once the last one is over.
if (!this.upActive) this.uploads.length = 0;
const dir = this.cwd;
const batch = [...files].map(f => f.size > UP_CAP
? {file: f, name: f.name, size: f.size, dir, state: 'fail', frac: 0, error: 'больше 512 МБ', skip: true}
: {file: f, name: f.name, size: f.size, dir, state: 'wait', frac: 0, error: ''});
this.uploads.push(...batch);
this.renderUploads();
if (this.upActive) return;
this.upActive = true;
this.setBusy(true);
let failed = 0;
for (let u; (u = this.uploads.find(x => x.state === 'wait')); ) {
u.state = 'send'; this.renderUploads();
const r = await FV.sendFile(this.src, join(u.dir, u.name), u.file, frac => {
u.frac = frac;
if (frac >= 1 && u.state === 'send') u.state = 'write';
this.renderUploads();
});
if (r.ok) u.state = 'done';
else { u.state = 'fail'; u.error = r.error || 'ошибка'; failed++; }
this.renderUploads();
if (u.dir === this.cwd) this.reload();
}
this.setBusy(false);
this.upActive = false;
failed += this.uploads.filter(u => u.skip).length;
toast(failed ? 'не загружено: ' + failed : 'загружено', !!failed);
this.renderUploads();
// The list stays a while for a look, then the indicator goes quiet.
this.upDoneTimer = setTimeout(() => { if (!this.upActive) { this.uploads.length = 0; this.renderUploads(); } }, failed ? 30000 : 8000);
}
}
// Panes of a machine gone offline look every 15 s whether it is back.
setInterval(() => {
const off = Workspace.panes().filter(p => p.offline);
if (!off.length || document.hidden) return;
fetch('/api/panel/agents/presence', {cache: 'no-store', credentials: 'same-origin'}).then(r => r.json()).then(all => {
off.forEach(p => { if (all.some(a => a.id === p.agent.id && a.online)) p.reload(); });
}).catch(() => {});
}, 15000);
window.FilePane = FilePane;
window.MachineSource = MachineSource;
Workspace.paneTypes.files = agent => new FilePane({kind: 'agent', agent});
Workspace.paneTypes.sshfiles = (agent, o) => new FilePane({kind: 'ssh', agent, ssh: o.ssh, src: new SSHSource(agent, o.conn, o.home, o.caps)});
// sshFiles connects agent to the SSH host t, then opens its files in a
// pane; the password or key goes to the machine once and is not kept here.
Workspace.sshFiles = (agent, t, into) => fetch('/api/panel/agents/' + encodeURIComponent(agent.id) + '/sshfs/open', {
method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ssh: t}), credentials: 'same-origin'
}).then(r => r.status === 404 ? {ok: false, error: 'нет доступа к машине'} : r.json()).then(r => {
const caps = r.ok ? {zip: !!r.data.zip, unzip: !!r.data.unzip} : null;
if (r.ok && into) { into.src.conn = r.data.id; Object.assign(into.src.caps, caps); into.q('.fp-zip').hidden = !caps.zip; into.reload(); }
else if (r.ok) Workspace.open('sshfiles', agent, {conn: r.data.id, home: r.data.home, caps, ssh: {host: t.host, port: t.port, user: t.user}});
return r;
}).catch(() => ({ok: false, error: 'ошибка сети'}));
})();