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

676 lines
34 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.
// The workspace of the Терминал view: panes - terminals, file managers - of
// one or more machines, in tabs. It lives as long as the page, so moving to
// Машины or Настройки and back keeps every session. With one tab there is
// only 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
// panes, split side by side or one above the other - and a pane dragged out
// of it back to the strip. Splits resize by their gutters; a tab is renamed
// by a double click on its title. Pane types register in Workspace.paneTypes.
(function () {
'use strict';
const touch = matchMedia('(pointer: coarse)').matches;
const desk = !touch; // drag and drop, splits: computers only
if (touch) document.body.classList.add('touch');
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>';
// ---------- 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 name = t.panes[0].title();
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(p => p.tooltip ? p.tooltip() : p.title()).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.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() {
// The header's status line is the workspace's only while it is shown.
if (!activePane || document.querySelector('main#app').dataset.view !== 'terminal') { 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 openPane(kind, agent, opts) {
const make = paneTypes[kind];
if (!make) return null;
$('empty').hidden = true;
const t = makeTab([make(agent, opts || {})]);
activate(t);
return t;
}
function openTerminal(agent, ssh) { return openPane(ssh ? 'ssh' : 'term', agent, {ssh}); }
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);
// The pane in the top right corner sits under the corner "+": it keeps
// its first bar clear of it.
let corner = 0;
L.panes.forEach((p, i) => { const c = L.panes[corner]; if (p.y < c.y || (p.y === c.y && p.x + p.w > c.x + c.w)) corner = i; });
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';
s.el.classList.toggle('under-plus', i === corner);
});
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.tooltip ? drag.obj.tooltip() : drag.obj.title());
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.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;
addMenu.innerHTML = '';
const esc = v => String(v).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
const item = (parent, html, fn, cls) => {
const b = document.createElement('button');
b.type = 'button'; b.className = 'item ' + (cls || ''); b.innerHTML = html;
b.onclick = fn;
parent.appendChild(b);
return b;
};
const FILES = '<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="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>';
// A group: «Терминал ›» or «Файловый менеджер ›», opening to here, the
// other machines and an SSH host.
const group = (icon, label, kind, sshKind, mode) => {
const g = item(addMenu, '<span class="ico">' + icon + '</span><span><b>' + label + '</b><small>здесь, на другой машине или по SSH</small></span><span class="chev">›</span>', () => {
if (g.nextSibling && g.nextSibling.classList && g.nextSibling.classList.contains('mlist')) { g.nextSibling.remove(); placeMenu(anchor); return; }
addMenu.querySelectorAll('.mlist').forEach(x => x.remove());
const list = document.createElement('div');
list.className = 'mlist';
g.after(list);
if (here) item(list, '<span class="ico">+</span><span><b>Здесь</b><small>' + esc(here.name) + ' · ' + esc(here.user + '@' + here.host) + '</small></span>',
() => { closeMenu(); openPane(kind, here); });
const box = document.createElement('div');
box.innerHTML = '<div class="muted" style="padding:8px 10px;font-size:13px">загрузка…</div>';
list.appendChild(box);
item(list, '<span class="ico">' + KEYI + '</span><span><b>SSH-хост…</b><small>машина откроет SSH к другому хосту</small></span>',
() => { closeMenu(); openSSH(here, mode); });
placeMenu(anchor);
(agentsCache ? Promise.resolve(agentsCache) : fetch('/api/panel/agents/presence', { cache: 'no-store' }).then(r => r.json()))
.then(all => {
agentsCache = all;
box.innerHTML = '';
all.filter(a => !here || a.id !== here.id).forEach(a => {
const b = item(box, '<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>',
() => { closeMenu(); openPane(kind, { id: a.id, name: a.name, host: a.hostname, user: a.user }); }, 'machine');
b.disabled = !a.online;
});
placeMenu(anchor);
})
.catch(() => { box.innerHTML = '<div class="muted" style="padding:8px 10px">не загрузилось</div>'; });
});
if (!paneTypes[kind]) g.disabled = true;
return g;
};
group('&gt;_', 'Терминал', 'term', 'ssh', 'term');
group(FILES, 'Файловый менеджер', 'files', 'sshfiles', 'files');
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, sshMode = 'term';
// openSSH asks where to connect; mode says what opens there: a terminal
// ('term') or a file manager ('files', through Workspace.sshFiles).
function openSSH(here, mode) {
if (!sshDlg) sshDlg = makeSSHDialog();
sshDlg.show(here, mode || 'term');
}
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 = '';
if (sshMode === 'files') {
// The machine connects first; the pane opens once it is in.
const btn = f.querySelector('button[type=submit]');
btn.disabled = true;
msg.textContent = 'подключение…';
window.Workspace.sshFiles(a, t).then(r => {
btn.disabled = false;
if (!r.ok) { msg.textContent = r.error || 'не подключилось'; return; }
msg.textContent = '';
close();
});
return;
}
close(() => openTerminal(a, t));
};
return {
show(here, mode) {
sshMode = mode;
d.querySelector('.dlg-h strong').textContent = mode === 'files' ? 'Файлы по SSH' : 'SSH-подключение';
msg.textContent = '';
let last = {};
try { last = JSON.parse(localStorage.getItem('lpra.ssh.last') || '{}'); } catch (e) { /* none */ }
agents = here ? [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 ? here.id : (agents.find(a => a.online !== false) || {}).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 (here && !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');
// A file pane on an SSH host lets the machine drop its connection.
if (s.src && s.src.close) s.src.close();
}));
});
// The view coming into sight: fit the panes, show the status line.
document.addEventListener('app:view', ev => {
if (ev.detail === 'terminal') { refit(); showStatus(); } else statusEl.textContent = '';
});
$('empty').hidden = tabs.length > 0;
const paneTypes = {};
// open opens a pane of kind on agent in a new tab and shows the view.
function open(kind, agent, opts) {
const t = openPane(kind, agent, opts);
if (location.pathname !== '/terminal' && window.App) App.go('/terminal');
return t;
}
window.Workspace = {
paneTypes, desk, touch, open, activate,
hasLiveSessions: () => tabs.some(t => t.panes.some(p => p.live())),
panes: () => tabs.flatMap(t => t.panes),
activePane: () => activePane,
renderPane: p => { const t = tabOf(p); if (t) renderTab(t); if (p === activePane) showStatus(); },
setActivePane, tabOf, renderTab, showStatus, closePane, startDrag
};
})();