feat: file panes follow the machine going offline and back, reconnect a lost SSH host; zip and unzip over SSH where the host has them; phone keys only for terminals; version 0.2.0, README
This commit is contained in:
1 parent
d92696226e
commit
01dc45ecad
8 files changed
+164
-29
No files matched your search
@@ -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 @@
|
||||
'</tr></thead><tbody class="fp-list"></tbody></table></div>' +
|
||||
'<p class="muted fp-msg" hidden></p>' +
|
||||
'</div>';
|
||||
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 =
|
||||
'<td><input type="checkbox" class="ck" aria-label="Выбрать"></td>' +
|
||||
'<td class="nm">' + fileIcon(e) + esc(e.name) + '</td>' +
|
||||
@@ -380,16 +403,27 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 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)});
|
||||
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) => fetch('/api/panel/agents/' + encodeURIComponent(agent.id) + '/sshfs/open', {
|
||||
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 => {
|
||||
if (r.ok) Workspace.open('sshfiles', agent, {conn: r.data.id, home: r.data.home, ssh: {host: t.host, port: t.port, user: t.user}});
|
||||
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: 'ошибка сети'}));
|
||||
})();
|
||||
Reference in new issue
Block a user