From 67222e2e05ce8a3b30e7988857491fca46e157eb Mon Sep 17 00:00:00 2001 From: iRedTea Date: Wed, 14 Oct 2026 20:42:41 +0300 Subject: [PATCH] feat: terminal tabs - several machines at once, up to four terminals split in a tab by drag and drop, resizable, renamable; the top bar keeps its status in sight --- internal/server/handlers_panel.go | 11 +- internal/server/web/static/app.css | 5 + internal/server/web/static/terminal.js | 600 ++++++++++++++++++++ internal/server/web/templates/terminal.html | 363 +++++------- 4 files changed, 769 insertions(+), 210 deletions(-) create mode 100644 internal/server/web/static/terminal.js diff --git a/internal/server/handlers_panel.go b/internal/server/handlers_panel.go index b414672..2b25934 100644 --- a/internal/server/handlers_panel.go +++ b/internal/server/handlers_panel.go @@ -151,14 +151,17 @@ func (s *Server) handlePresence(w http.ResponseWriter, r *http.Request) { return } type item struct { - ID string `json:"id"` - Online bool `json:"online"` - Seen string `json:"seen"` + ID string `json:"id"` + Name string `json:"name"` + Hostname string `json:"hostname"` + User string `json:"user"` // who a terminal opens as + Online bool `json:"online"` + Seen string `json:"seen"` } out := make([]item, 0, len(agents)) for _, a := range agents { on := s.online(&a) - out = append(out, item{ID: a.ID, Online: on, Seen: seenText(on, a.LastSeen)}) + out = append(out, item{ID: a.ID, Name: a.Name, Hostname: a.Hostname, User: a.TerminalUser(), Online: on, Seen: seenText(on, a.LastSeen)}) } writeJSON(w, out) } diff --git a/internal/server/web/static/app.css b/internal/server/web/static/app.css index 423c35d..c6086c9 100644 --- a/internal/server/web/static/app.css +++ b/internal/server/web/static/app.css @@ -25,6 +25,11 @@ header.topbar { padding: 14px 24px; border-bottom: 1px solid var(--border); background: var(--panel); } header.topbar .brand { font-weight: 600; } +/* A long machine name gives way: the links and the status stay in sight. */ +@media (min-width: 641px) { + header.topbar .brand { flex: 1 1 auto; min-width: 0; margin-right: 16px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + header.topbar nav { flex: none; white-space: nowrap; } +} header.topbar nav a { margin-left: 18px; color: var(--muted); } header.topbar nav a:hover { color: var(--text); } .wrap { max-width: 960px; margin: 0 auto; padding: 24px 16px; } diff --git a/internal/server/web/static/terminal.js b/internal/server/web/static/terminal.js new file mode 100644 index 0000000..de00d94 --- /dev/null +++ b/internal/server/web/static/terminal.js @@ -0,0 +1,600 @@ +// 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 $ = 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 = ''; + + 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; + } + + // ---------- one shell ---------- + class Session { + constructor(agent) { + this.agent = agent; + 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 = '
' + + '
' + + '
'; + this.el.querySelector('.pane-title').textContent = agent.user + '@' + agent.host; + this.el.querySelector('.pane-title').title = agent.name; + 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.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 { + const r = await fetch(base + encodeURIComponent(this.agent.id) + '/open', { method: 'POST' }); + if (!r.ok) { + this.setState('err', 'ошибка открытия (' + r.status + ')'); + this.term.write('\r\n\x1b[31m[не удалось открыть сессию: HTTP ' + r.status + ']\x1b[0m\r\n'); + return; + } + this.sid = (await r.json()).session_id; + this.setState('wait', 'ожидание агента…'); + this.fit(); + this.queueIn({ t: 'r', cols: this.term.cols, rows: this.term.rows }); + this.poll(); + } catch (e) { + this.setState('err', 'ошибка сети'); + this.term.write('\r\n\x1b[31m[ошибка сети: ' + (e && e.message ? e.message : e) + ']\x1b[0m\r\n'); + } + } + 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; } + const j = await r.json(); + if (j.closed) { this.finish('[сессия завершена]'); return; } + for (const f of j.frames || []) { + if (f.exit) { this.finish('[сессия завершена]'); return; } + if (f.data) { + if (!this.connected) { this.connected = true; this.setState('ok', 'подключено'); } + this.term.write(b64ToBytes(f.data)); + } + } + } + } + finish(msg) { + if (this.ended) return; + this.ended = true; + 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 }; + 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.sid) fetch(base + this.sid + '/close', { method: 'POST', keepalive: true }).catch(() => {}); + this.term.dispose(); + this.el.remove(); + } + } + + // ---------- 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 = '' + + ''; + 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); + requestAnimationFrame(() => requestAnimationFrame(() => t.el.classList.remove('enter'))); + renderTab(t); + return t; + } + function tabTitle(t) { + if (t.title) return t.title; + const a = t.panes[0].agent; + return t.panes.length > 1 ? a.name + ' +' + (t.panes.length - 1) : a.name; + } + function renderTab(t) { + t.el.querySelector('.tab-title').textContent = tabTitle(t); + t.el.title = t.panes.map(s => s.agent.user + '@' + s.agent.host).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); + } + 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.className = activePane.state === 'ok' ? 'st-ok' : activePane.state === 'err' ? 'st-err' : 'muted'; + } + // One tab: a plain terminal, the strip folded into the corner "+". + function updateMode() { + 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) { + $('empty').hidden = true; + const t = makeTab([new Session(agent)]); + 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); + item('+Новый терминал здесь' + esc(here.name) + ' · ' + esc(here.user + '@' + here.host) + '', + () => { closeMenu(); openTerminal(here); }); + const other = item('⇄На другой машиневыбрать из списка›', () => { + other.remove(); + const list = document.createElement('div'); + list.className = 'mlist'; + list.innerHTML = '
загрузка…
'; + 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 = '' + esc(a.name) + '' + + esc(a.user + '@' + a.hostname) + (a.online ? '' : ' · ' + esc(a.seen)) + ''; + b.onclick = () => { closeMenu(); openTerminal({ id: a.id, name: a.name, host: a.hostname, user: a.user }); }; + list.appendChild(b); + }); + if (!all.length) list.innerHTML = '
машин нет
'; + placeMenu(anchor); + }) + .catch(() => { list.innerHTML = '
не загрузилось
'; }); + }); + 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(); }); + + // ---------- 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(); +})(); diff --git a/internal/server/web/templates/terminal.html b/internal/server/web/templates/terminal.html index 97e2236..e27f342 100644 --- a/internal/server/web/templates/terminal.html +++ b/internal/server/web/templates/terminal.html @@ -9,12 +9,103 @@ @@ -45,12 +136,19 @@ Порты Статус Изменить - подключение… + -
-
+
+
+
+
+ + + +
+