diff --git a/README.md b/README.md index b33e1d9..a10056e 100644 --- a/README.md +++ b/README.md @@ -12,9 +12,14 @@ ``` ## Возможности -- **Терминал** - вкладки на нескольких машинах сразу, до четырёх терминалов в одной вкладке - (перетаскиванием, с изменяемыми размерами), переименование вкладок. На телефоне - панель - клавиш (Esc, Ctrl, Alt, стрелки и т.д.). +- **Одна страница** - Машины, WorkSpace (терминалы и файлы), Настройки; переходы без перезагрузки. + Вкладки WorkSpace живут, пока открыта вкладка браузера: уход на другие страницы их не + закрывает, перезагрузка сбрасывает (браузер сначала спросит). «Порты», «Статус» и «Изменить» + машины открываются окном поверх текущей страницы - из списка машин или правым кликом + по заголовку вкладки или панели. +- **WorkSpace** - вкладки на нескольких машинах сразу, до четырёх панелей в одной вкладке - + терминал или файловый менеджер (перетаскиванием, с изменяемыми размерами), переименование + вкладок. На телефоне - панель клавиш (Esc, Ctrl, Alt, стрелки и т.д.). - **SSH-терминал** - в меню «+»: агент открывает SSH-соединение к другому хосту (по паролю, ключу - можно загрузить файл - или своими ключами из `~/.ssh`), шелл открывается во вкладке, а шаги подключения видны в статусе. Ключ хоста запоминается при первом входе @@ -22,6 +27,15 @@ - **Файлы** - просмотр, редактор с подсветкой, hex-редактор, предпросмотр картинок, видео, PDF, баз SQLite с правкой (значение по клику, добавление и удаление строк, вкладка SQL; агент меняет базу через сам SQLite, так что работающая база не ломается); загрузка и скачивание до 512 МБ кусками по 4 МБ (проходит через Cloudflare); свойства (chmod/chown), zip/unzip. +- **Файлы по SSH** - в меню «+» › «Файловый менеджер» › «SSH-хост…»: машина подключается к хосту + и работает с файлами командами по SSH, SFTP не нужен. Есть всё, кроме редактора SQLite; + zip и распаковка - если на хосте установлены `zip` и `unzip`. Оборвалось подключение - + панель предложит переподключиться. +- **Перетаскивание файлов** - строку (или все отмеченные) в другую файловую панель или в папку + в ней: окно «Переместить / Копировать» у места броска, между машинами и SSH-хостами - с + прогрессом. В терминал той же машины или того же SSH-хоста - вставляется путь в кавычках, + в терминал другой - нельзя, с подсказкой почему. Задержать над заголовком вкладки - она + откроется. Только на компьютере. - **Перемещение и копирование** - внутри машины или, кнопкой «сеть» в том же окне, **на другую машину или на SSH-хост**: папка уходит потоком tar без промежуточных файлов, с прогрессом, скоростью и отменой. «Переместить» удаляет исходник после успешной отправки. Правила для diff --git a/VERSION.txt b/VERSION.txt index d917d3e..0ea3a94 100644 --- a/VERSION.txt +++ b/VERSION.txt @@ -1 +1 @@ -0.1.2 +0.2.0 diff --git a/internal/agent/sshfs.go b/internal/agent/sshfs.go index 3abf8aa..4fa5e9e 100644 --- a/internal/agent/sshfs.go +++ b/internal/agent/sshfs.go @@ -115,7 +115,8 @@ func (a *Agent) sshfsOpen(ctx context.Context, t proto.SSHTarget) (any, error) { return nil, err } c := &sshfsConn{client: client, target: t, last: time.Now()} - out, _ := sshRun(client, "stat -c %F / >/dev/null 2>&1 && echo GNU; stat -f %HT / >/dev/null 2>&1 && echo BSD; echo \"$HOME\"") + out, _ := sshRun(client, "stat -c %F / >/dev/null 2>&1 && echo GNU; stat -f %HT / >/dev/null 2>&1 && echo BSD; "+ + "command -v zip >/dev/null 2>&1 && echo ZIP; command -v unzip >/dev/null 2>&1 && echo UNZIP; echo \"$HOME\"") lines := strings.Split(strings.TrimSpace(out), "\n") switch { case strings.Contains(out, "GNU"): @@ -132,7 +133,18 @@ func (a *Agent) sshfsOpen(ctx context.Context, t proto.SSHTarget) (any, error) { id := randHex(12) sshfsConns.Store(id, c) sshfsJanitor.Do(func() { go sshfsSweep() }) - return map[string]string{"id": id, "home": c.home}, nil + // zip and unzip, where the host has them, for the pane's buttons. + return map[string]any{"id": id, "home": c.home, + "zip": hasLine(lines, "ZIP"), "unzip": hasLine(lines, "UNZIP")}, nil +} + +func hasLine(lines []string, w string) bool { + for _, l := range lines { + if strings.TrimSpace(l) == w { + return true + } + } + return false } // sshfsSweep closes connections nobody used for sshfsIdle. @@ -318,6 +330,20 @@ func (a *Agent) sshfsOp(c *sshfsConn, op string, raw json.RawMessage) (any, erro var p proto.FSCopyArgs _ = json.Unmarshal(raw, &p) return nil, a.sshfsCopy(c, p) + case "zip": + var p proto.FSZipArgs + _ = json.Unmarshal(raw, &p) + cmd, err := zipCommand(p.Paths, p.Dest) + if err != nil { + return nil, err + } + _, err = a.sshfsRun(c, cmd, nil) + return map[string]string{"dest": p.Dest}, err + case "unzip": + var p proto.FSUnzipArgs + _ = json.Unmarshal(raw, &p) + _, err := a.sshfsRun(c, unzipCommand(p.Path, p.Dest), nil) + return map[string]string{"dest": p.Dest}, err case "stat": var p proto.FSPathArgs _ = json.Unmarshal(raw, &p) @@ -349,6 +375,34 @@ func (a *Agent) sshfsOp(c *sshfsConn, op string, raw json.RawMessage) (any, erro return nil, errors.New("неизвестная операция") } +// zipCommand packs paths - items of one folder - into the archive dest, from +// that folder, under their own names. Paths are absolute and names start +// with "./", so none reads as an option. +func zipCommand(paths []string, dest string) (string, error) { + if len(paths) == 0 { + return "", errors.New("не выбраны файлы") + } + dir := path.Dir(path.Clean(paths[0])) + names := make([]string, len(paths)) + for i, p := range paths { + p = path.Clean(p) + if path.Dir(p) != dir { + return "", errors.New("в архив - только из одной папки") + } + names[i] = shq("./" + path.Base(p)) + } + return "cd -- " + shq(dir) + " && zip -r -q -y " + shq(path.Clean(dest)) + " " + strings.Join(names, " "), nil +} + +// unzipCommand unpacks the archive p into dest (its own folder if empty), +// replacing what is there, as on a machine. +func unzipCommand(p, dest string) string { + if dest == "" { + dest = path.Dir(path.Clean(p)) + } + return "unzip -o -q " + shq(path.Clean(p)) + " -d " + shq(path.Clean(dest)) +} + // writeCommand writes stdin to p through a file beside it, so a broken // transfer leaves the old file as it was. func writeCommand(p string) string { diff --git a/internal/agent/sshfs_test.go b/internal/agent/sshfs_test.go index 58cd04e..f269012 100644 --- a/internal/agent/sshfs_test.go +++ b/internal/agent/sshfs_test.go @@ -106,3 +106,24 @@ func TestRenameTarDropsOtherEntries(t *testing.T) { t.Fatalf("entries: %v", got) } } + +// zip on an SSH host: from the items' folder, by their own names, quoted. +func TestZipCommand(t *testing.T) { + cmd, err := zipCommand([]string{"/srv/a b", "/srv/it's"}, "/tmp/x.zip") + if err != nil { + t.Fatal(err) + } + want := `cd -- '/srv' && zip -r -q -y '/tmp/x.zip' './a b' './it'\''s'` + if cmd != want { + t.Fatalf("got %s\nwant %s", cmd, want) + } + if _, err := zipCommand([]string{"/a/x", "/b/y"}, "/t.zip"); err == nil { + t.Fatal("items of two folders: want an error") + } + if _, err := zipCommand(nil, "/t.zip"); err == nil { + t.Fatal("no items: want an error") + } + if got := unzipCommand("/srv/a.zip", ""); got != `unzip -o -q '/srv/a.zip' -d '/srv'` { + t.Fatalf("unzip: %s", got) + } +} diff --git a/internal/server/sshfs.go b/internal/server/sshfs.go index 3c0bebc..ecb8f62 100644 --- a/internal/server/sshfs.go +++ b/internal/server/sshfs.go @@ -52,7 +52,7 @@ func (s *Server) handleSSHFSOpen(w http.ResponseWriter, r *http.Request) { } // sshfsChanges are the operations that change files, for the audit log. -var sshfsChanges = map[string]bool{"write": true, "mkdir": true, "delete": true, "deltree": true, "move": true, "copy": true, "chmod": true} +var sshfsChanges = map[string]bool{"write": true, "mkdir": true, "delete": true, "deltree": true, "move": true, "copy": true, "chmod": true, "zip": true, "unzip": true} // POST /api/panel/agents/{id}/sshfs/{conn}/{op} - an operation on the host; // the body is in the shape of the machine's own fs.{op}. "close" drops the diff --git a/internal/server/web/static/app.css b/internal/server/web/static/app.css index 9232277..149ffa0 100644 --- a/internal/server/web/static/app.css +++ b/internal/server/web/static/app.css @@ -420,7 +420,8 @@ body.touch.multi .tabs { padding-top:6px; max-height:38px; } body.touch .tab { height:28px; font-size:12px; padding:0 2px 0 8px; gap:6px; } body.touch .tab-add { width:30px; height:28px; } body.touch .tabview { inset:4px; } -body.touch .keys { +body.touch:not(.term-active) .keys { display:none; } +body.touch.term-active .keys { display:grid; grid-template-columns:repeat(7, 1fr); gap:4px; flex:none; padding:4px 4px calc(4px + env(safe-area-inset-bottom)); background:var(--panel); border-top:1px solid var(--border); } diff --git a/internal/server/web/static/filepane.js b/internal/server/web/static/filepane.js index 024bd90..271897b 100644 --- a/internal/server/web/static/filepane.js +++ b/internal/server/web/static/filepane.js @@ -44,7 +44,7 @@ // A machine's files: the panel's file API of that machine. class MachineSource { - constructor(agent) { this.agent = agent; this.caps = {zip: true, sqlite: true}; } + 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, { @@ -57,7 +57,8 @@ // An SSH host's files, through the machine that holds the connection. class SSHSource { - constructor(agent, conn, home) { this.agent = agent; this.conn = conn; this.homeDir = home || '/'; this.caps = {zip: false, sqlite: false}; } + // 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) { @@ -123,7 +124,7 @@ '
' + '' + ''; - if (!this.src.caps.zip) this.q('.fp-zip').hidden = true; + 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. @@ -167,19 +168,41 @@ this.setBusy(true); try { return await this.src.op(op, body); } finally { this.setBusy(false); } } - message(text) { + // 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 this.go('/'); + 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(''); @@ -189,15 +212,15 @@ async go(path) { const r = await this.api('list', {path}); if (!r.ok) { - toast(r.error || 'ошибка', true); - if (/офлайн|нет доступа/.test(r.error || '')) { this.message(r.error); this.setState('err', r.error); } + if (!this.trouble(r.error || '')) toast(r.error || 'ошибка', true); this.q('.fp-path').value = this.cwd; return false; } this.show(r.data); return true; } - reload() { return this.go(this.cwd); } + // 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. @@ -230,7 +253,7 @@ const tr = document.createElement('tr'); if (e.is_dir) tr.dataset.dir = e.name; tr.dataset.name = e.name; - const isZip = this.src.caps.zip && !e.is_dir && /\.zip$/i.test(e.name); + const isZip = this.src.caps.unzip && !e.is_dir && /\.zip$/i.test(e.name); tr.innerHTML = '