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

861 lines
43 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.
// Terminal page: shells on one or more machines, in tabs. With one tab the
// page is a plain terminal and a faint "+" in the corner; a second tab brings
// the tab strip in. On a computer a tab can be dragged onto the current one
// to share it - up to four terminals, split side by side or one above the
// other - and a terminal dragged out of it back to the strip. Splits resize
// by their gutters; a tab is renamed by a double click on its title.
(function () {
'use strict';
const PAGE = window.TERM_PAGE;
const touch = matchMedia('(pointer: coarse)').matches;
const desk = !touch; // drag and drop, splits: computers only
if (touch) document.body.classList.add('touch');
const ua = (navigator.userAgentData && navigator.userAgentData.platform) || navigator.platform || navigator.userAgent;
if (/^Win/i.test(ua) || /Windows/.test(navigator.userAgent)) document.body.classList.add('win');
const $ = id => document.getElementById(id);
const statusEl = $('status'), area = $('area'), tablist = $('tablist'), addBtn = $('tabadd'),
floatAdd = $('floatadd'), addMenu = $('addmenu'), hint = $('hint'), mark = $('tabmark');
const base = '/api/panel/term/';
const GAP = 6;
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>';
function bytesToB64(bytes) {
let bin = '';
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
return btoa(bin);
}
function b64ToBytes(b64) {
const bin = atob(b64), u = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) u[i] = bin.charCodeAt(i);
return u;
}
// ---------- copy and paste by keys ----------
// Off a Mac, where Cmd does it, Ctrl+C copies when there is a selection and
// interrupts as ever when there is none; Ctrl+Shift+C always copies (and is
// kept from the browser, whose developer tools it would open); Ctrl+V and
// Ctrl+Shift+V paste. Keys go by position, so a Russian layout works too.
const mac = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
function copyText(text) {
if (navigator.clipboard && window.isSecureContext) return navigator.clipboard.writeText(text);
// Plain http: the old way, through a field that is there for a moment.
const ta = document.createElement('textarea'), back = document.activeElement;
ta.value = text;
ta.style.cssText = 'position:fixed;top:0;left:0;opacity:0';
document.body.appendChild(ta);
ta.select();
const ok = document.execCommand('copy');
ta.remove();
if (back && back.focus) back.focus();
return ok ? Promise.resolve() : Promise.reject(new Error('нет доступа к буферу'));
}
function clipKeys(s, ev) {
if (mac || !ev.ctrlKey || ev.altKey || ev.metaKey) return true;
const c = ev.code === 'KeyC', v = ev.code === 'KeyV';
if (!c && !v) return true;
if (c && !ev.shiftKey && !s.term.hasSelection()) return true; // ^C to the shell
// From here the key is ours: xterm does not turn it into a control code.
if (ev.type === 'keydown' && c) {
ev.preventDefault();
if (s.term.hasSelection()) {
const text = s.term.getSelection();
s.term.clearSelection();
copyText(text).then(() => flashStatus('скопировано', 'st-ok'), e => flashStatus('не скопировалось: ' + e.message, 'st-err'));
}
}
// Ctrl+V: the browser's paste goes on into the terminal's text field.
return false;
}
function flashStatus(t, cls) {
statusEl.textContent = t;
statusEl.className = cls;
clearTimeout(flashStatus.t);
flashStatus.t = setTimeout(showStatus, 1500);
}
// ---------- one shell ----------
class Session {
// ssh, when given, is where the agent connects by SSH for this shell:
// {host, port, user} and the secret to sign in with, sent once on open.
constructor(agent, ssh) {
this.agent = agent;
this.ssh = ssh || null;
this.sid = null; this.closed = false; this.connected = false;
this.state = 'wait'; this.text = 'подключение…';
this.inSeq = 0; this.inBusy = false; this.inQueue = [];
this.el = document.createElement('div');
this.el.className = 'pane';
this.el.innerHTML = '<div class="pane-head"><span class="dot wait"></span><span class="pane-title"></span>' +
'<button type="button" class="pane-x" title="Закрыть терминал" aria-label="Закрыть терминал">' + X + '</button></div>' +
'<div class="pane-body"></div>';
this.el.querySelector('.pane-title').textContent = who(this);
this.el.querySelector('.pane-title').title = ssh ? 'SSH через ' + agent.name : agent.name;
if (ssh) this.el.querySelector('.dot').insertAdjacentHTML('afterend', '<span class="badge-ssh">SSH</span>');
this.term = new Terminal({
cursorBlink: true, fontSize: touch ? 13 : 14,
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', theme: { background: '#0c0f13' }
});
this.fitter = new FitAddon.FitAddon();
this.term.loadAddon(this.fitter);
this.term.open(this.el.querySelector('.pane-body'));
this.term.attachCustomKeyEventHandler(ev => clipKeys(this, ev));
this.term.onData(d => this.send(TermKeys.withMods(d)));
this.term.onResize(() => this.queueIn({ t: 'r', cols: this.term.cols, rows: this.term.rows }));
this.term.onSelectionChange(() => TermKeys.selectionChanged(this));
this.el.addEventListener('pointerdown', () => setActivePane(this), true);
this.term.textarea.addEventListener('focus', () => setActivePane(this));
this.el.querySelector('.pane-x').onclick = ev => { ev.stopPropagation(); closePane(this); };
if (desk) this.el.querySelector('.pane-head').addEventListener('pointerdown', ev => startDrag(ev, 'pane', this));
this.open();
}
setState(state, text) {
this.state = state; this.text = text;
this.el.querySelector('.dot').className = 'dot ' + state;
const t = tabOf(this);
if (t) renderTab(t);
showStatus();
}
async open() {
try {
let opt = { method: 'POST' };
if (this.ssh) {
opt = { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ssh: this.ssh }) };
// The password or key goes once, to the agent; the page keeps no copy.
this.ssh = { host: this.ssh.host, port: this.ssh.port, user: this.ssh.user };
}
const r = await fetch(base + encodeURIComponent(this.agent.id) + '/open', opt);
if (!r.ok) {
let why = 'HTTP ' + r.status;
try { why = (await r.json()).error || why; } catch (e) { /* plain text */ }
this.setState('err', 'ошибка открытия: ' + why);
this.term.write('\r\n\x1b[31m[не удалось открыть сессию: ' + why + ']\x1b[0m\r\n');
return;
}
this.sid = (await r.json()).session_id;
this.setState('wait', 'ожидание агента…');
// An agent from before SSH terminals does not answer the request.
if (this.ssh) setTimeout(() => {
if (!this.closed && !this.ended && this.text === 'ожидание агента…') {
this.setState('err', 'агент не начал SSH-подключение');
this.term.write('\x1b[33mАгент не ответил. Если он старой версии, обновите его - SSH-терминал появился в новой.\x1b[0m\r\n');
}
}, 20000);
this.fit();
// A WebSocket if it connects; the long polls otherwise, and from a
// dropped WebSocket on for the rest of this session.
if (!(await this.openWS())) this.poll();
this.queueIn({ t: 'r', cols: this.term.cols, rows: this.term.rows });
} catch (e) {
this.setState('err', 'ошибка сети');
this.term.write('\r\n\x1b[31m[ошибка сети: ' + (e && e.message ? e.message : e) + ']\x1b[0m\r\n');
}
}
openWS() {
return new Promise(resolve => {
if (!window.WebSocket) { resolve(false); return; }
const ws = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + base + this.sid + '/ws');
let opened = false;
const timer = setTimeout(() => { if (!opened) { ws.close(); resolve(false); } }, 5000);
ws.onopen = () => { opened = true; clearTimeout(timer); this.ws = ws; resolve(true); };
ws.onmessage = ev => {
let j;
try { j = JSON.parse(ev.data); } catch (e) { return; }
this.take(j);
};
ws.onclose = () => {
clearTimeout(timer);
if (!opened) { resolve(false); return; }
this.ws = null;
if (!this.closed && !this.ended) this.poll();
};
});
}
// take applies one answer of the output channel, either transport's.
take(j) {
if (j.closed) { this.finish('[сессия завершена]'); return false; }
for (const f of j.frames || []) {
if (f.status) this.sshStatus(f.status, f.note || '');
if (f.exit) { this.finish('[сессия завершена]'); return false; }
if (f.data) {
if (!this.connected && !this.ssh) { this.connected = true; this.setState('ok', 'подключено'); }
this.term.write(b64ToBytes(f.data));
}
}
return true;
}
// sshStatus shows how the agent's SSH connection goes.
sshStatus(st, note) {
if (st === 'dial') this.setState('wait', 'SSH: ' + note + '…');
else if (st === 'auth') this.setState('wait', 'SSH: ' + note + '…');
else if (st === 'ok') { this.connected = true; this.setState('ok', 'SSH: ' + note); }
else if (st === 'err') this.setState('err', 'SSH: ' + note);
}
async poll() {
while (!this.closed && this.sid) {
let r;
try { r = await fetch(base + this.sid + '/out', { cache: 'no-store' }); }
catch (e) { await new Promise(res => setTimeout(res, 1000)); continue; }
if (this.closed) return;
if (!r.ok) { this.finish('[соединение закрыто]'); return; }
if (!this.take(await r.json())) return;
}
}
finish(msg) {
if (this.ended) return;
this.ended = true;
if (this.state !== 'err') this.setState('off', 'соединение закрыто');
this.term.write('\r\n\x1b[33m' + msg + '\x1b[0m\r\n');
}
// Input goes out one request at a time, so on a slow link a keystroke can
// never overtake the one before it; a request that fails goes again under
// the same number, and the server drops one it already has.
queueIn(m) {
if (this.closed || !this.sid) return;
const q = this.inQueue, last = q[q.length - 1];
if (last && last.t === 'i' && m.t === 'i') last.text += m.text;
else if (last && last.t === 'r' && m.t === 'r') q[q.length - 1] = m;
else q.push(m);
this.pumpIn();
}
async pumpIn() {
if (this.inBusy || !this.inQueue.length) return;
this.inBusy = true;
while (this.inQueue.length && !this.closed) {
const m = this.inQueue.shift();
const body = m.t === 'i'
? { t: 'i', d: bytesToB64(new TextEncoder().encode(m.text)), seq: ++this.inSeq }
: { t: 'r', cols: m.cols, rows: m.rows, seq: ++this.inSeq };
// On the WebSocket a message goes out in order with no answer to wait
// for; numbered as well, so a repeat by the long polls is dropped.
if (this.ws && this.ws.readyState === 1) {
try { this.ws.send(JSON.stringify(body)); continue; } catch (e) { /* by request below */ }
}
for (let wait = 300; !this.closed; wait = Math.min(wait * 2, 5000)) {
let r = null;
try {
r = await fetch(base + this.sid + '/in', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
} catch (e) { /* no answer: send it again */ }
if (r && r.status < 500) break;
await new Promise(res => setTimeout(res, wait));
}
}
this.inBusy = false;
}
send(data) { this.queueIn({ t: 'i', text: data }); }
fit() {
const b = this.el.querySelector('.pane-body');
if (b.clientWidth > 20 && b.clientHeight > 20) { try { this.fitter.fit(); } catch (e) { /* not shown yet */ } }
}
close() {
if (this.closed) return;
this.closed = true;
if (this.ws) { try { this.ws.close(); } catch (e) { /* closed already */ } }
if (this.sid) fetch(base + this.sid + '/close', { method: 'POST', keepalive: true }).catch(() => {});
this.term.dispose();
this.el.remove();
}
}
// who is what a shell is signed in as, for titles.
function who(s) {
return s.ssh ? s.ssh.user + '@' + s.ssh.host + (s.ssh.port && s.ssh.port !== 22 ? ':' + s.ssh.port : '') : s.agent.user + '@' + s.agent.host;
}
// ---------- tabs ----------
const tabs = [];
let active = null, activePane = null;
const tabOf = s => tabs.find(t => t.panes.includes(s));
function makeTab(panes, index) {
const t = { title: null, panes: panes, layout: panes.length === 1 ? '1' : 'h', c: 0.5, r: 0.5, gutters: [] };
t.view = document.createElement('div');
t.view.className = 'tabview';
area.insertBefore(t.view, hint);
panes.forEach(s => t.view.appendChild(s.el));
t.el = document.createElement('div');
t.el.className = 'tab enter';
t.el.innerHTML = '<span class="dot wait"></span><span class="tab-title"></span>' +
'<button type="button" class="tab-x" title="Закрыть вкладку" aria-label="Закрыть вкладку">' + X + '</button>';
t.el.addEventListener('click', ev => { if (!ev.target.closest('.tab-x, input') && !dragJustEnded) activate(t); });
t.el.querySelector('.tab-x').onclick = ev => { ev.stopPropagation(); closeTab(t); };
t.el.querySelector('.tab-title').addEventListener('dblclick', ev => { ev.stopPropagation(); rename(t); });
if (desk) t.el.addEventListener('pointerdown', ev => { if (!ev.target.closest('.tab-x, input')) startDrag(ev, 'tab', t); });
const at = index == null ? tabs.length : index;
tablist.insertBefore(t.el, tabs[at] ? tabs[at].el : mark);
tabs.splice(at, 0, t);
renderTab(t);
fitTabs();
requestAnimationFrame(() => requestAnimationFrame(() => t.el.classList.remove('enter')));
return t;
}
function tabTitle(t) {
if (t.title) return t.title;
const s = t.panes[0], name = s.ssh ? s.ssh.host : s.agent.name;
return t.panes.length > 1 ? name + ' +' + (t.panes.length - 1) : name;
}
// Tab widths: as wide as the title wants, from a comfortable minimum up to
// a maximum; when they do not fit, all give way evenly down to a floor,
// titles cut with "…", and below the floor the strip scrolls instead.
const ruler = document.createElement('canvas').getContext('2d');
const W = touch ? { min: 76, base: 124, max: 180, font: 12 } : { min: 96, base: 180, max: 300, font: 13 };
const CHROME = touch ? 52 : 62; // padding, dot, gaps, close button, borders
function fitTabs() {
ruler.font = W.font + 'px ' + getComputedStyle(document.body).fontFamily;
const live = tabs;
const want = live.map(t => Math.min(W.max, Math.max(W.base, Math.ceil(ruler.measureText(tabTitle(t)).width) + CHROME + (t.panes.some(p => p.ssh) ? 46 : 0))));
const strip = $('tabs');
const room = strip.clientWidth - parseFloat(getComputedStyle(strip).paddingLeft) * 2 - addBtn.offsetWidth - 4 * (live.length + 1);
let cap = W.max;
if (want.reduce((a, b) => a + b, 0) > room) {
// The widest give way first, to one width for all at the most.
let lo = W.min, hi = W.max;
for (let k = 0; k < 20; k++) {
const mid = (lo + hi) / 2;
if (want.reduce((a, b) => a + Math.min(b, mid), 0) > room) hi = mid; else lo = mid;
}
cap = Math.max(W.min, Math.floor(lo));
}
live.forEach((t, i) => { t.el.style.width = Math.min(want[i], cap) + 'px'; });
}
window.addEventListener('resize', () => fitTabs());
function renderTab(t) {
t.el.querySelector('.tab-title').textContent = tabTitle(t);
const ssh = t.panes.some(s => s.ssh);
if (ssh !== !!t.el.querySelector('.badge-ssh')) {
if (ssh) t.el.querySelector('.dot').insertAdjacentHTML('afterend', '<span class="badge-ssh">SSH</span>');
else t.el.querySelector('.badge-ssh').remove();
}
requestAnimationFrame(fitTabs);
t.el.title = t.panes.map(who).join(', ');
// The tab's dot: red if any shell failed, amber while one connects.
const st = t.panes.map(s => s.state);
t.el.querySelector('.dot').className = 'dot ' + (st.includes('err') ? 'err' : st.includes('wait') ? 'wait' : st.includes('ok') ? 'ok' : 'off');
t.el.classList.toggle('on', t === active);
t.view.classList.toggle('split', t.panes.length > 1);
syncFloat();
}
function activate(t) {
if (!t) return;
active = t;
tabs.forEach(x => { x.view.classList.toggle('on', x === t); renderTab(x); });
if (!t.panes.includes(activePane)) setActivePane(t.panes[0]);
layout(t, false);
t.el.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'smooth' });
if (desk && activePane) activePane.term.focus();
updateMode();
}
function setActivePane(s) {
activePane = s;
document.querySelectorAll('.pane.active').forEach(p => p.classList.toggle('active', p === s.el));
s.el.classList.add('active');
showStatus();
}
function showStatus() {
if (!activePane) { statusEl.textContent = ''; return; }
statusEl.textContent = activePane.text;
statusEl.title = activePane.text;
statusEl.className = activePane.state === 'ok' ? 'st-ok' : activePane.state === 'err' ? 'st-err' : 'muted';
}
// The corner "+" moves down out of the way of a split tab's title bars.
function syncFloat() {
document.body.classList.toggle('split1', tabs.length === 1 && tabs[0].panes.length > 1);
}
// One tab: a plain terminal, the strip folded into the corner "+".
function updateMode() {
syncFloat();
const multi = tabs.length > 1;
if (document.body.classList.contains('multi') === multi) return;
document.body.classList.toggle('multi', multi);
setTimeout(() => active && layout(active, false), 280);
}
function openTerminal(agent, ssh) {
$('empty').hidden = true;
const t = makeTab([new Session(agent, ssh)]);
activate(t);
}
function closeTab(t) {
const i = tabs.indexOf(t);
if (i < 0) return;
if (t.panes.length > 1 && !confirm('Закрыть вкладку с ' + t.panes.length + ' терминалами?')) return;
tabs.splice(i, 1);
t.panes.forEach(s => s.close());
t.el.classList.add('leave');
t.view.classList.remove('on');
setTimeout(() => { t.el.remove(); t.view.remove(); }, 220);
if (active === t) {
active = null;
activate(tabs[Math.min(i, tabs.length - 1)]);
}
if (!tabs.length) { activePane = null; showStatus(); $('empty').hidden = false; }
updateMode();
}
function rename(t) {
const span = t.el.querySelector('.tab-title');
const inp = document.createElement('input');
inp.className = 'tab-edit';
inp.value = tabTitle(t);
inp.size = Math.max(6, inp.value.length);
span.replaceWith(inp);
inp.focus(); inp.select();
let done = false;
const end = save => {
if (done) return;
done = true;
if (save) t.title = inp.value.trim() || null;
inp.replaceWith(span);
renderTab(t);
};
inp.onkeydown = ev => { if (ev.key === 'Enter') end(true); else if (ev.key === 'Escape') end(false); ev.stopPropagation(); };
inp.onblur = () => end(true);
inp.onpointerdown = ev => ev.stopPropagation();
}
// ---------- splits ----------
// Where each terminal of a tab goes, and its gutters, for a layout:
// 1, h (side by side), v (one above the other), top2 (two over one),
// bottom2 (one over two), grid (two over two). c and r are where the
// column and the row divide.
function rects(layout, c, r, W, H) {
const g = GAP, cw = Math.round((W - g) * c), rh = Math.round((H - g) * r);
const top = (y, h) => [{ x: 0, y, w: cw, h }, { x: cw + g, y, w: W - cw - g, h }];
const colG = (y, h) => ({ k: 'c', x: cw, y, w: g, h });
const rowG = { k: 'r', x: 0, y: rh, w: W, h: g };
const bottomY = rh + g, bottomH = H - rh - g;
switch (layout) {
case 'h': return { panes: top(0, H), gutters: [colG(0, H)] };
case 'v': return { panes: [{ x: 0, y: 0, w: W, h: rh }, { x: 0, y: bottomY, w: W, h: bottomH }], gutters: [rowG] };
case 'top2': return { panes: top(0, rh).concat([{ x: 0, y: bottomY, w: W, h: bottomH }]), gutters: [colG(0, rh), rowG] };
case 'bottom2': return { panes: [{ x: 0, y: 0, w: W, h: rh }].concat(top(bottomY, bottomH)), gutters: [colG(bottomY, bottomH), rowG] };
case 'grid': return { panes: top(0, rh).concat(top(bottomY, bottomH)), gutters: [colG(0, H), rowG] };
default: return { panes: [{ x: 0, y: 0, w: W, h: H }], gutters: [] };
}
}
function layout(t, animate) {
const W = t.view.clientWidth, H = t.view.clientHeight;
if (!W || !H) return;
const L = rects(t.layout, t.c, t.r, W, H);
t.view.classList.toggle('instant', !animate);
t.panes.forEach((s, i) => {
const p = L.panes[i];
s.el.style.left = p.x + 'px'; s.el.style.top = p.y + 'px';
s.el.style.width = p.w + 'px'; s.el.style.height = p.h + 'px';
});
while (t.gutters.length < L.gutters.length) {
const g = document.createElement('div');
g.className = 'gutter';
g.addEventListener('pointerdown', ev => startResize(ev, t, g));
t.view.appendChild(g);
t.gutters.push(g);
}
t.gutters.forEach((g, i) => {
const d = L.gutters[i];
g.hidden = !d;
if (!d) return;
g.className = 'gutter ' + d.k;
g.style.left = d.x + 'px'; g.style.top = d.y + 'px'; g.style.width = d.w + 'px'; g.style.height = d.h + 'px';
});
clearTimeout(t.fitTimer);
t.fitTimer = setTimeout(() => t.panes.forEach(s => s.fit()), animate ? 270 : 0);
}
function startResize(ev, t, g) {
ev.preventDefault();
g.setPointerCapture(ev.pointerId);
const box = t.view.getBoundingClientRect(), k = g.classList.contains('c') ? 'c' : 'r';
g.classList.add('drag');
let raf = 0;
const move = e => {
const v = k === 'c' ? (e.clientX - box.left) / box.width : (e.clientY - box.top) / box.height;
t[k] = Math.min(0.85, Math.max(0.15, v));
if (!raf) raf = requestAnimationFrame(() => { raf = 0; layout(t, false); });
};
const up = () => {
g.classList.remove('drag');
g.removeEventListener('pointermove', move);
g.removeEventListener('pointerup', up);
layout(t, false);
};
g.addEventListener('pointermove', move);
g.addEventListener('pointerup', up);
}
// A layout with one terminal fewer, the others keeping their places.
function withoutPane(layout, i) {
if (layout === 'h' || layout === 'v') return '1';
if (layout === 'top2') return i === 2 ? 'h' : 'v';
if (layout === 'bottom2') return i === 0 ? 'h' : 'v';
if (layout === 'grid') return i < 2 ? 'bottom2' : 'top2';
return '1';
}
function takePane(t, s) {
const i = t.panes.indexOf(s);
t.layout = withoutPane(t.layout, i);
t.panes.splice(i, 1);
}
function closePane(s) {
const t = tabOf(s);
if (!t) return;
if (t.panes.length === 1) { closeTab(t); return; }
takePane(t, s);
s.close();
if (activePane === s) setActivePane(t.panes[0]);
renderTab(t);
layout(t, true);
}
// Where a terminal dropped on tab t at (x, y) would go: the new layout,
// the order of its terminals ('P' the new one), and its place.
const SIDE = { left: 'слева', right: 'справа', top: 'сверху', bottom: 'снизу' };
function mergePlan(t, x, y) {
const n = t.panes.length, W = t.view.clientWidth, H = t.view.clientHeight;
const fx = x / W, fy = y / H;
let lay, order, label;
if (n === 1) {
const d = { left: fx, right: 1 - fx, top: fy, bottom: 1 - fy };
const k = Object.keys(d).reduce((a, b) => d[a] <= d[b] ? a : b);
lay = k === 'left' || k === 'right' ? 'h' : 'v';
order = k === 'left' || k === 'top' ? ['P', 0] : [0, 'P'];
label = SIDE[k];
} else if (n === 2 && t.layout === 'h') {
if (fy < 0.5) { lay = 'bottom2'; order = ['P', 0, 1]; label = 'сверху'; }
else { lay = 'top2'; order = [0, 1, 'P']; label = 'снизу'; }
} else if (n === 2) {
if (fy < 0.5) { lay = 'top2'; order = fx < 0.5 ? ['P', 0, 1] : [0, 'P', 1]; }
else { lay = 'bottom2'; order = fx < 0.5 ? [0, 'P', 1] : [0, 1, 'P']; }
label = (fy < 0.5 ? 'сверху ' : 'снизу ') + (fx < 0.5 ? 'слева' : 'справа');
} else if (n === 3) {
lay = 'grid';
if (t.layout === 'top2') { order = fx < 0.5 ? [0, 1, 'P', 2] : [0, 1, 2, 'P']; label = 'снизу ' + (fx < 0.5 ? 'слева' : 'справа'); }
else { order = fx < 0.5 ? ['P', 0, 1, 2] : [0, 'P', 1, 2]; label = 'сверху ' + (fx < 0.5 ? 'слева' : 'справа'); }
} else return null;
return { layout: lay, order, label, rect: rects(lay, t.c, t.r, W, H).panes[order.indexOf('P')] };
}
// ---------- drag and drop ----------
// A tab: along the strip to reorder it, or onto the current tab to join
// it. A terminal from a split, by its title bar: to the strip to make it a
// tab of its own, or onto another terminal of its tab to swap places.
let drag = null, dragJustEnded = false;
function startDrag(ev, kind, obj) {
if (ev.button !== 0) return;
drag = { kind, obj, x0: ev.clientX, y0: ev.clientY, on: false, id: ev.pointerId };
}
function beginDrag() {
drag.on = true;
const src = drag.kind === 'tab' ? drag.obj.el : drag.obj.el.querySelector('.pane-head');
const g = document.createElement('div');
g.className = 'drag-ghost ' + drag.kind;
g.textContent = drag.kind === 'tab' ? tabTitle(drag.obj) : drag.obj.agent.user + '@' + drag.obj.agent.host;
document.body.appendChild(g);
drag.ghost = g;
drag.src = src;
src.classList.add('dragging');
document.body.classList.add('dnd');
// Pulling a terminal out: the strip shows, one tab or not, to drop it on.
if (drag.kind === 'pane') document.body.classList.add('multi-hint');
}
function stripIndex(x) {
const others = tabs.filter(t => !(drag.kind === 'tab' && t === drag.obj));
let i = others.findIndex(t => { const r = t.el.getBoundingClientRect(); return x < r.left + r.width / 2; });
if (i < 0) i = others.length;
const ref = others[i] ? others[i].el.getBoundingClientRect() : (others.length ? others[others.length - 1].el.getBoundingClientRect() : null);
const lr = tablist.getBoundingClientRect();
const mx = others[i] ? ref.left - 3 : ref ? ref.right + 1 : lr.left;
return { i, others, mx: mx - lr.left + tablist.scrollLeft };
}
function showHint(r, label, bad) {
hint.hidden = false;
hint.classList.toggle('bad', !!bad);
const v = active.view;
hint.style.left = (v.offsetLeft + r.x) + 'px'; hint.style.top = (v.offsetTop + r.y) + 'px';
hint.style.width = r.w + 'px'; hint.style.height = r.h + 'px';
hint.textContent = label;
}
function onDragMove(ev) {
if (!drag || ev.pointerId !== drag.id) return;
if (!drag.on) {
if (Math.hypot(ev.clientX - drag.x0, ev.clientY - drag.y0) < 6) return;
beginDrag();
}
drag.ghost.style.transform = 'translate(' + (ev.clientX + 10) + 'px,' + (ev.clientY + 8) + 'px)';
drag.target = null;
hint.hidden = true; mark.hidden = true;
const strip = $('tabs').getBoundingClientRect();
const stripShown = document.body.classList.contains('multi') || document.body.classList.contains('multi-hint');
const inStrip = stripShown && ev.clientY >= strip.top - 6 && ev.clientY <= strip.bottom + 10;
const inArea = active && (() => { const r = active.view.getBoundingClientRect(); return ev.clientX >= r.left && ev.clientX <= r.right && ev.clientY >= r.top && ev.clientY <= r.bottom; })();
if (inStrip) {
const s = stripIndex(ev.clientX);
mark.hidden = false; mark.style.left = s.mx + 'px';
drag.target = { kind: 'strip', index: s.i, others: s.others };
drag.ghost.dataset.hint = drag.kind === 'tab' ? 'переставить' : 'отдельной вкладкой';
} else if (inArea && drag.kind === 'tab' && drag.obj !== active) {
const r = active.view.getBoundingClientRect();
if (drag.obj.panes.length > 1) {
showHint({ x: 0, y: 0, w: r.width, h: r.height }, 'вкладку с несколькими терминалами не объединить', true);
} else if (active.panes.length >= 4) {
showHint({ x: 0, y: 0, w: r.width, h: r.height }, 'в одной вкладке не больше 4 терминалов', true);
} else {
const plan = mergePlan(active, ev.clientX - r.left, ev.clientY - r.top);
showHint(plan.rect, plan.label);
drag.target = { kind: 'merge', plan };
}
drag.ghost.dataset.hint = '';
} else if (inArea && drag.kind === 'pane' && tabOf(drag.obj) === active) {
const r = active.view.getBoundingClientRect();
const over = active.panes.find(s => { const p = s.el.getBoundingClientRect(); return s !== drag.obj && ev.clientX >= p.left && ev.clientX <= p.right && ev.clientY >= p.top && ev.clientY <= p.bottom; });
if (over) {
const p = over.el.getBoundingClientRect();
showHint({ x: p.left - r.left, y: p.top - r.top, w: p.width, h: p.height }, 'поменять местами');
drag.target = { kind: 'swap', with: over };
}
drag.ghost.dataset.hint = over ? '' : 'к вкладкам - отдельной вкладкой';
} else {
drag.ghost.dataset.hint = '';
}
}
function onDragEnd(ev) {
if (!drag || ev.pointerId !== drag.id) return;
const d = drag;
drag = null;
if (!d.on) return;
d.ghost.remove();
d.src.classList.remove('dragging');
document.body.classList.remove('dnd', 'multi-hint');
hint.hidden = true; mark.hidden = true;
dragJustEnded = true;
setTimeout(() => { dragJustEnded = false; }, 0);
const tg = d.target;
if (!tg) return;
if (d.kind === 'tab' && tg.kind === 'strip') {
const t = d.obj, ref = tg.others[tg.index];
tabs.splice(tabs.indexOf(t), 1);
tabs.splice(ref ? tabs.indexOf(ref) : tabs.length, 0, t);
tablist.insertBefore(t.el, ref ? ref.el : mark);
} else if (d.kind === 'tab' && tg.kind === 'merge') {
const src = d.obj, s = src.panes[0], t = active, plan = tg.plan;
// The joining terminal starts where the hint showed it.
s.el.classList.add('instant');
s.el.style.left = plan.rect.x + 'px'; s.el.style.top = plan.rect.y + 'px';
s.el.style.width = plan.rect.w + 'px'; s.el.style.height = plan.rect.h + 'px';
t.view.appendChild(s.el);
requestAnimationFrame(() => s.el.classList.remove('instant'));
t.panes = plan.order.map(o => o === 'P' ? s : t.panes[o]);
t.layout = plan.layout;
tabs.splice(tabs.indexOf(src), 1);
src.el.classList.add('leave');
setTimeout(() => { src.el.remove(); src.view.remove(); }, 220);
setActivePane(s);
renderTab(t);
layout(t, true);
updateMode();
s.term.focus();
} else if (d.kind === 'pane' && tg.kind === 'strip') {
const s = d.obj, from = tabOf(s), ref = tg.others[tg.index];
takePane(from, s);
renderTab(from);
const t = makeTab([s], ref ? tabs.indexOf(ref) : tabs.length);
layout(from, true);
activate(t);
} else if (d.kind === 'pane' && tg.kind === 'swap') {
const t = active, a = t.panes.indexOf(d.obj), b = t.panes.indexOf(tg.with);
[t.panes[a], t.panes[b]] = [t.panes[b], t.panes[a]];
layout(t, true);
}
}
window.addEventListener('pointermove', onDragMove);
window.addEventListener('pointerup', onDragEnd);
window.addEventListener('pointercancel', onDragEnd);
// ---------- the "+" menu ----------
let agentsCache = null;
function openMenu(anchor) {
const here = (activePane && activePane.agent) || PAGE.agent;
addMenu.innerHTML = '';
const item = (html, fn, cls) => {
const b = document.createElement('button');
b.type = 'button'; b.className = 'item ' + (cls || ''); b.innerHTML = html;
b.onclick = fn;
addMenu.appendChild(b);
return b;
};
const esc = s => String(s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
item('<span class="ico">+</span><span><b>Новый терминал здесь</b><small>' + esc(here.name) + ' · ' + esc(here.user + '@' + here.host) + '</small></span>',
() => { closeMenu(); openTerminal(here); });
item('<span class="ico">' + KEYI + '</span><span><b>SSH-подключение</b><small>агент откроет SSH к другому хосту</small></span>',
() => { closeMenu(); openSSH(here); });
const other = item('<span class="ico">⇄</span><span><b>На другой машине</b><small>выбрать из списка</small></span><span class="chev">›</span>', () => {
other.remove();
const list = document.createElement('div');
list.className = 'mlist';
list.innerHTML = '<div class="muted" style="padding:8px 10px;font-size:13px">загрузка…</div>';
addMenu.appendChild(list);
(agentsCache ? Promise.resolve(agentsCache) : fetch('/api/panel/agents/presence', { cache: 'no-store' }).then(r => r.json()))
.then(all => {
agentsCache = all;
list.innerHTML = '';
all.forEach(a => {
const b = document.createElement('button');
b.type = 'button'; b.className = 'item machine'; b.disabled = !a.online;
b.innerHTML = '<span class="dot ' + (a.online ? 'ok' : 'off') + '"></span><span><b>' + esc(a.name) + '</b><small>' +
esc(a.user + '@' + a.hostname) + (a.online ? '' : ' · ' + esc(a.seen)) + '</small></span>';
b.onclick = () => { closeMenu(); openTerminal({ id: a.id, name: a.name, host: a.hostname, user: a.user }); };
list.appendChild(b);
});
if (!all.length) list.innerHTML = '<div class="muted" style="padding:8px 10px">машин нет</div>';
placeMenu(anchor);
})
.catch(() => { list.innerHTML = '<div class="muted" style="padding:8px 10px">не загрузилось</div>'; });
});
addMenu.hidden = false;
placeMenu(anchor);
setTimeout(() => document.addEventListener('pointerdown', outside, true), 0);
}
function placeMenu(anchor) {
const r = anchor.getBoundingClientRect(), m = addMenu.getBoundingClientRect();
addMenu.style.left = Math.max(8, Math.min(innerWidth - m.width - 8, r.right - m.width)) + 'px';
addMenu.style.top = Math.min(innerHeight - m.height - 8, r.bottom + 6) + 'px';
}
function outside(ev) { if (!addMenu.contains(ev.target)) closeMenu(); }
function closeMenu() {
addMenu.hidden = true;
agentsCache = null;
document.removeEventListener('pointerdown', outside, true);
}
addBtn.onclick = () => openMenu(addBtn);
floatAdd.onclick = () => openMenu(floatAdd);
$('emptyadd').onclick = ev => openMenu(ev.currentTarget);
document.addEventListener('keydown', ev => { if (ev.key === 'Escape' && !addMenu.hidden) closeMenu(); });
// ---------- the SSH dialog ----------
const KEYI = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="7.5" cy="15.5" r="4.5"/><path d="m21 2-9.6 9.6M15.5 7.5l3 3L22 7l-3-3"/></svg>';
let sshDlg = null;
function openSSH(here) {
if (!sshDlg) sshDlg = makeSSHDialog();
sshDlg.show(here);
}
function makeSSHDialog() {
const d = document.createElement('dialog');
d.className = 'sshdlg';
d.innerHTML =
'<form method="dialog" autocomplete="off">' +
'<div class="dlg-h"><span class="ico">' + KEYI + '</span><strong style="flex:1">SSH-подключение</strong>' +
'<button type="button" class="btn ghost small icon close x" data-close aria-label="Закрыть">' + X + '</button></div>' +
'<div class="dlg-b">' +
'<label class="f"><span>Подключаться с машины</span><select name="agent"></select></label>' +
'<div class="frow"><label class="f grow"><span>Хост</span><input type="text" name="host" placeholder="example.com или 10.0.0.5" required autocapitalize="off" spellcheck="false"></label>' +
'<label class="f port"><span>Порт</span><input type="number" name="port" min="1" max="65535" value="22" required></label></div>' +
'<label class="f"><span>Пользователь</span><input type="text" name="user" placeholder="root" required autocapitalize="off" spellcheck="false"></label>' +
'<div class="f"><span>Вход</span><div class="seg" role="tablist">' +
'<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>' +
'<div class="auth" data-for="password"><input type="password" name="password" placeholder="пароль" autocomplete="off"></div>' +
'<div class="auth" data-for="key" hidden>' +
'<textarea name="key" rows="5" spellcheck="false" placeholder="-----BEGIN OPENSSH PRIVATE KEY-----&#10;…вставьте ключ или загрузите файл"></textarea>' +
'<div class="frow"><button type="button" class="btn ghost small" data-load>Загрузить файл…</button><span class="muted keyname"></span>' +
'<input type="file" hidden></div>' +
'<input type="password" name="passphrase" placeholder="фраза ключа, если есть" autocomplete="off"></div>' +
'<p class="auth muted" data-for="agent" hidden>Агент войдёт своими ключами из ~/.ssh (id_ed25519, id_ecdsa, id_rsa) без фразы.</p>' +
'<p class="sshmsg"></p>' +
'<div class="row end"><button type="button" class="btn ghost" data-close>Отмена</button><button type="submit">Подключиться</button></div>' +
'</div></form>';
document.body.appendChild(d);
const f = d.querySelector('form'), msg = d.querySelector('.sshmsg');
let auth = 'password';
const setAuth = a => {
auth = a;
d.querySelectorAll('.seg button').forEach(b => b.classList.toggle('on', b.dataset.auth === a));
d.querySelectorAll('.auth').forEach(el => { el.hidden = el.dataset.for !== a; });
};
d.querySelectorAll('.seg button').forEach(b => { b.onclick = () => setAuth(b.dataset.auth); });
d.querySelectorAll('[data-close]').forEach(b => { b.onclick = () => close(); });
const file = d.querySelector('input[type=file]');
d.querySelector('[data-load]').onclick = () => file.click();
file.onchange = () => {
const fl = file.files[0];
if (!fl) return;
if (fl.size > 32768) { msg.textContent = 'файл великоват для ключа'; return; }
fl.text().then(t => { f.key.value = t.trim() + '\n'; d.querySelector('.keyname').textContent = fl.name; msg.textContent = ''; });
file.value = '';
};
// then runs once the dialog is shut and focus is back where it was, so
// what it focuses stays focused.
function close(then) {
d.close();
if (then) then();
}
d.addEventListener('cancel', ev => { ev.preventDefault(); close(); });
d.addEventListener('click', ev => { if (ev.target === d) close(); });
let agents = [];
f.onsubmit = ev => {
ev.preventDefault();
const a = agents.find(x => x.id === f.agent.value);
if (!a) { msg.textContent = 'выберите машину'; return; }
const t = { host: f.host.value.trim(), port: Number(f.port.value) || 22, user: f.user.value.trim() };
if (auth === 'password') {
if (!f.password.value) { msg.textContent = 'введите пароль'; f.password.focus(); return; }
t.password = f.password.value;
} else if (auth === 'key') {
if (!/PRIVATE KEY/.test(f.key.value)) { msg.textContent = 'вставьте закрытый ключ или загрузите файл'; return; }
t.key = f.key.value;
if (f.passphrase.value) t.passphrase = f.passphrase.value;
}
try { localStorage.setItem('lpra.ssh.last', JSON.stringify({ agent: a.id, host: t.host, port: t.port, user: t.user, auth: auth })); } catch (e) { /* not kept */ }
f.password.value = ''; f.passphrase.value = ''; f.key.value = '';
d.querySelector('.keyname').textContent = '';
close(() => openTerminal(a, t));
};
return {
show(here) {
msg.textContent = '';
let last = {};
try { last = JSON.parse(localStorage.getItem('lpra.ssh.last') || '{}'); } catch (e) { /* none */ }
agents = [here];
const fill = () => {
f.agent.innerHTML = '';
agents.forEach(a => {
const o = document.createElement('option');
o.value = a.id; o.textContent = a.name + ' · ' + a.user + '@' + a.host + (a.online === false ? ' (не в сети)' : '');
o.disabled = a.online === false;
f.agent.appendChild(o);
});
f.agent.value = agents.some(a => a.id === last.agent && a.online !== false) ? last.agent : here.id;
};
fill();
fetch('/api/panel/agents/presence', { cache: 'no-store' }).then(r => r.json()).then(all => {
agents = all.map(a => ({ id: a.id, name: a.name, host: a.hostname, user: a.user, online: a.online }));
if (!agents.some(a => a.id === here.id)) agents.unshift(here);
fill();
}).catch(() => {});
if (last.host) { f.host.value = last.host; f.port.value = last.port || 22; f.user.value = last.user || ''; }
setAuth(last.auth === 'key' || last.auth === 'agent' ? last.auth : 'password');
d.showModal();
// On a phone the keyboard waits for a tap.
if (desk) (last.host ? (last.auth === 'password' || !last.auth ? f.password : f.host) : f.host).focus();
}
};
}
// ---------- page ----------
function refit() {
if (touch && window.visualViewport) {
// Follow the visible area, so the extra keys sit right above the
// on-screen keyboard.
const vv = window.visualViewport;
document.body.style.top = vv.offsetTop + 'px';
document.body.style.height = vv.height + 'px';
}
if (active) layout(active, false);
}
window.addEventListener('resize', refit);
if (touch && window.visualViewport) {
window.visualViewport.addEventListener('resize', refit);
window.visualViewport.addEventListener('scroll', refit);
}
window.addEventListener('pagehide', () => {
tabs.forEach(t => t.panes.forEach(s => { if (s.sid) navigator.sendBeacon(base + s.sid + '/close'); }));
});
// For the touch helpers in the page: the terminal being typed in.
window.TermPane = { active: () => activePane };
openTerminal(PAGE.agent);
refit();
})();