// 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 = ''; // Monochrome inline icons (inherit button color via currentColor). const SVG = ''; const ICON_DL = SVG + ''; const ICON_MV = SVG + ''; const ICON_UNZIP = SVG + ''; const ICON_ZIP = SVG + ''; const ICON_DEL = SVG + ''; // File icons: one colour, the line style of the buttons; a few kinds by // the name's ending. const FI = ''; const FICONS = { dir: FI + '', file: FI + '', text: FI + '', code: FI + '', img: FI + '', arc: FI + '', media:FI + '', dirlink: FI + '', link: FI + '' }; 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 '' + FICONS[k] + ''; } // 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 = '
' + (this.ssh ? 'SSH' : '') + '
' + '
' + '
' + ' ' + '
' + '
' + ' ' + ' ' + '
' + '
' + ' ' + ' ' + ' ' + // What is done to the ticked rows: two small icons on the right, // their words on hover, the count beside them. ' ' + ' ' + ' ' + ' ' + '
' + '
' + ' ' + ' ' + ' ' + '
ИмяРазмерИзменён
' + '' + '
'; 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 = '' + '' + fileIcon(e) + esc(e.name) + '' + '' + (e.is_dir ? '' : fmtSize(e.size)) + '' + '' + fmtTime(e.mtime) + '' + '
' + (isZip ? '' : '') + (e.is_dir ? '' : '' + ICON_DL + '') + '' + '' + '
'; 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: 'ошибка сети'})); })();