diff --git a/internal/server/handlers_panel.go b/internal/server/handlers_panel.go index 6baccdc..f5a1ec6 100644 --- a/internal/server/handlers_panel.go +++ b/internal/server/handlers_panel.go @@ -177,19 +177,6 @@ func (s *Server) handlePresence(w http.ResponseWriter, r *http.Request) { writeJSON(w, out) } -func (s *Server) handleTerminalPage(w http.ResponseWriter, r *http.Request) { - id := r.PathValue("id") - a, err := s.db.GetAgent(id) - if err != nil || a == nil { - http.NotFound(w, r) - return - } - s.render(w, "terminal.html", map[string]any{ - "Agent": a, - "CSRF": csrfToken(r), - }) -} - func (s *Server) handleAgentDelete(w http.ResponseWriter, r *http.Request) { if !checkCSRF(r) { http.Error(w, "bad csrf", http.StatusForbidden) diff --git a/internal/server/web/static/app.css b/internal/server/web/static/app.css index 72907ed..0e0024d 100644 --- a/internal/server/web/static/app.css +++ b/internal/server/web/static/app.css @@ -262,3 +262,163 @@ input.sw:disabled { opacity: .45; cursor: not-allowed; } /* The one-page shell: the header's current view. */ header.topbar nav a.on { color: var(--text); } + +/* ---- The workspace (Терминал view) ---- */ +#status.st-ok { color:#37c871; } +#status.st-err { color:#ff5c5c; } +#status { display:inline-block; vertical-align:middle; margin-left:18px; min-width:0; max-width:32vw; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } +/* SSH: the badge on a pane, and the dialog to connect. */ +.badge-ssh { flex:none; font-size:10px; font-weight:600; letter-spacing:.04em; padding:1px 5px; border-radius:4px; + background:#12202f; color:var(--accent); border:1px solid #1f3a57; } +dialog.sshdlg { max-width:460px; } +.sshdlg .dlg-h .ico { color:var(--accent); display:inline-flex; } +.sshdlg .dlg-b { display:grid; gap:12px; } +.sshdlg .f { display:grid; gap:5px; margin:0; } +.sshdlg .f > span { font-size:12px; color:var(--muted); } +.sshdlg .frow { display:flex; gap:10px; align-items:end; } +.sshdlg .frow .grow { flex:1; min-width:0; } +.sshdlg .frow .port { width:92px; flex:none; } +.sshdlg input, .sshdlg select, .sshdlg textarea { width:100%; box-sizing:border-box; } +.sshdlg textarea { font:12px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; resize:vertical; min-height:90px; + background:var(--bg, #0c0f13); color:var(--text); border:1px solid var(--border); border-radius:8px; padding:8px 10px; } +.sshdlg textarea:focus { border-color:var(--accent); outline:none; } +.sshdlg .auth { display:grid; gap:8px; animation:auth-in .18s ease; } +.sshdlg .auth[hidden] { display:none; } +@keyframes auth-in { from { opacity:0; transform:translateY(-4px); } } +.sshdlg p.auth { margin:0; font-size:13px; } +.sshdlg .keyname { font-size:12px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; align-self:center; } +.sshdlg .seg { display:flex; padding:3px; gap:3px; background:#0c0f13; border:1px solid var(--border); border-radius:9px; } +.sshdlg .seg button { flex:1; padding:6px 8px; font-size:13px; background:transparent; color:var(--muted); border:0; border-radius:6px; + transition:background .18s, color .18s; } +.sshdlg .seg button:hover { color:var(--text); } +.sshdlg .seg button.on { background:#12202f; color:var(--text); box-shadow:inset 0 0 0 1px #1f3a57; } +.sshdlg .sshmsg { margin:0; min-height:18px; font-size:13px; color:var(--danger); } +.sshdlg .row.end { display:flex; justify-content:flex-end; gap:8px; } +@media (max-width:520px) { .sshdlg .seg button { font-size:12px; padding:6px 4px; } } +/* The page is the terminal: bar, tabs, then the terminals to the bottom. */ +/* The shell: the header, then the view to the bottom. Машины and + Настройки scroll inside it; the terminal view fills it. */ +body.app { height:100vh; display:flex; flex-direction:column; overflow:hidden; } +body.app main#app { flex:1; min-height:0; overflow:auto; } +.view-term { height:100%; display:flex; flex-direction:column; overflow:hidden; } +header.topbar { flex:none; } +/* Tabs: folded away while there is one, sliding in with the second. */ +.tabs { flex:none; display:flex; align-items:flex-end; gap:4px; padding:0 16px; max-height:0; opacity:0; overflow:hidden; + transition:max-height .28s ease, opacity .22s ease, padding .28s ease; } +body.multi .tabs, body.multi-hint .tabs { max-height:48px; opacity:1; padding-top:10px; } +.tablist { position:relative; display:flex; gap:4px; min-width:0; overflow-x:auto; scrollbar-width:none; } +.tablist::-webkit-scrollbar { display:none; } +.tab { display:flex; align-items:center; gap:8px; flex:none; min-width:0; height:34px; padding:0 6px 0 12px; box-sizing:border-box; + border:1px solid var(--border); border-bottom:0; border-radius:9px 9px 0 0; background:#11151b; color:var(--muted); + font-size:13px; user-select:none; cursor:default; overflow:hidden; white-space:nowrap; + transition:background .18s, color .18s, width .22s ease, max-width .22s ease, opacity .2s, padding .22s ease; } +.tab:hover { color:var(--text); background:#151a21; } +.tab.on { background:var(--panel); color:var(--text); } +.tab.enter, .tab.leave { max-width:0; opacity:0; padding:0; } +.tab.dragging { opacity:.3; } +.tab-title { flex:1; overflow:hidden; text-overflow:ellipsis; min-width:0; } +.tab-edit { width:auto; min-width:60px; max-width:150px; height:24px; padding:2px 6px; font-size:13px; } +.tab-x, .pane-x { flex:none; width:20px; height:20px; padding:0; display:inline-flex; align-items:center; justify-content:center; + background:transparent; border:0; border-radius:5px; color:var(--muted); opacity:.6; transition:opacity .15s, background .15s, color .15s; } +.tab-x:hover, .pane-x:hover { opacity:1; background:#3a1d1d; color:var(--danger); } +.tab-add { flex:none; width:34px; height:34px; padding:0; display:inline-flex; align-items:center; justify-content:center; + border:1px solid var(--border); border-bottom:0; border-radius:9px 9px 0 0; background:transparent; color:var(--muted); + font-size:18px; line-height:1; transition:background .18s, color .18s; } +.tab-add:hover { background:#151a21; color:var(--text); } +.tabmark { position:absolute; top:6px; bottom:0; width:3px; margin-left:-1px; border-radius:2px; background:var(--accent); + pointer-events:none; transition:left .12s ease; } +.tabmark[hidden] { display:none; } +.dot { width:7px; height:7px; border-radius:50%; background:#555d6a; flex:none; transition:background .3s; } +.dot.ok { background:var(--ok); } .dot.err { background:var(--danger); padding:0; border:0; margin:0; } .dot.wait { background:#e6a23c; } +/* The terminals' area. */ +.area { position:relative; flex:1; min-height:0; margin:12px 16px 16px; border:1px solid var(--border); border-radius:10px; background:#0c0f13; overflow:hidden; } +body.multi .area { margin-top:0; border-top-left-radius:0; } +.float-add { position:absolute; top:8px; right:10px; z-index:6; width:28px; height:28px; padding:0; display:inline-flex; + align-items:center; justify-content:center; font-size:17px; line-height:1; color:var(--text); + background:transparent; border:1px solid var(--border); opacity:.28; transition:opacity .25s ease, background .25s ease, top .25s ease, right .25s ease; } +/* One tab split in panes: below the panes' title bars, clear of their close buttons. */ +body.split1 .float-add { top:42px; right:16px; } +.float-add:hover, .float-add:focus-visible { opacity:1; background:var(--panel); } +body.multi .float-add { opacity:0; pointer-events:none; } +.tabview { position:absolute; inset:8px; opacity:0; visibility:hidden; transform:translateY(4px) scale(.995); + transition:opacity .22s ease, transform .22s ease, visibility 0s .22s; } +.tabview.on { opacity:1; visibility:visible; transform:none; transition:opacity .22s ease, transform .22s ease, visibility 0s; } +.pane { position:absolute; display:flex; flex-direction:column; border-radius:7px; overflow:hidden; + transition:left .26s ease, top .26s ease, width .26s ease, height .26s ease, border-color .2s; } +.tabview.instant .pane, .pane.instant { transition:none; } +.pane-head { display:none; } +.tabview.split .pane { border:1px solid var(--border); } +.tabview.split .pane.active { border-color:rgba(79,157,255,.7); } +.tabview.split .pane-head { display:flex; align-items:center; gap:7px; flex:none; height:26px; padding:0 4px 0 9px; + background:#11151b; border-bottom:1px solid var(--border); font-size:12px; color:var(--muted); cursor:grab; user-select:none; } +.tabview.split .pane.active .pane-head { color:var(--text); } +.pane-title { flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } +.pane-body { flex:1; min-height:0; padding:4px 0 0 4px; } +/* A lone terminal's scrollbar sits in the margin by the frame, right of + the corner "+", not over the text. */ +body:not(.touch) .tabview:not(.split) .pane { overflow:visible; } +body:not(.touch) .tabview:not(.split) .xterm .xterm-viewport { right:-7px; } +.pane.dragging { opacity:.45; } +.gutter { position:absolute; z-index:3; } +.gutter.c { cursor:col-resize; } .gutter.r { cursor:row-resize; } +.gutter::after { content:""; position:absolute; inset:0; border-radius:3px; background:var(--accent); opacity:0; transition:opacity .15s; } +.gutter:hover::after, .gutter.drag::after { opacity:.55; } +.hint { position:absolute; z-index:5; display:flex; align-items:center; justify-content:center; text-align:center; padding:8px; + border:2px solid var(--accent); border-radius:9px; background:rgba(79,157,255,.14); color:var(--text); font-size:13px; + pointer-events:none; transition:left .14s ease, top .14s ease, width .14s ease, height .14s ease; } +.hint.bad { border-color:var(--danger); background:rgba(255,92,92,.12); } +.hint[hidden] { display:none; } +.drag-ghost { position:fixed; left:0; top:0; z-index:60; pointer-events:none; padding:7px 12px; border:1px solid var(--accent); + border-radius:8px; background:var(--panel); color:var(--text); font-size:13px; white-space:nowrap; + box-shadow:0 12px 30px rgba(0,0,0,.55); transition:transform .04s linear; } +.drag-ghost[data-hint]:not([data-hint=""])::after { content:attr(data-hint); display:block; margin-top:3px; font-size:11px; color:var(--muted); } +body.dnd, body.dnd * { cursor:grabbing !important; } +.empty { position:absolute; inset:0; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:12px; color:var(--muted); } +.empty[hidden] { display:none; } +/* The "+" menu: a terminal here, or on another machine. */ +.addmenu { position:fixed; z-index:50; min-width:270px; max-width:340px; max-height:70vh; overflow:auto; padding:6px; + background:var(--panel); border:1px solid var(--border); border-radius:10px; box-shadow:0 14px 36px rgba(0,0,0,.55); + animation:menu-in .14s ease; } +.addmenu[hidden] { display:none; } +@keyframes menu-in { from { opacity:0; transform:translateY(-4px); } } +.addmenu .item { display:flex; align-items:center; gap:10px; width:100%; padding:8px 10px; border-radius:7px; text-align:left; + background:transparent; color:var(--text); font-weight:400; } +.addmenu .item:hover:not(:disabled) { background:#12202f; } +.addmenu .item:disabled { opacity:.45; cursor:not-allowed; } +.addmenu .item b { display:block; font-weight:500; font-size:14px; } +.addmenu .item small { display:block; color:var(--muted); font-size:12px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; max-width:240px; } +.addmenu .ico { width:22px; text-align:center; color:var(--accent); font-size:16px; flex:none; } +.addmenu .chev { margin-left:auto; color:var(--muted); } +.addmenu .mlist { border-top:1px solid var(--border); margin-top:4px; padding-top:4px; } +/* Touch screens: the page is as tall as what the on-screen keyboard leaves, + with a row of extra keys at the bottom, the way Termux does it. Tabs are + smaller, and there is no splitting. */ +.keys { display:none; } +body.touch { position:fixed; left:0; right:0; top:0; height:100%; } +body.touch .area { margin:6px; } +body.touch.multi .area { margin-top:0; } +body.touch .tabs { padding-left:6px; padding-right:6px; } +body.touch.multi .tabs { padding-top:6px; max-height:38px; } +body.touch .tab { height:28px; font-size:12px; padding:0 2px 0 8px; gap:6px; } +body.touch .tab-add { width:30px; height:28px; } +body.touch .tabview { inset:4px; } +body.touch .keys { + display:grid; grid-template-columns:repeat(7, 1fr); gap:4px; flex:none; + padding:4px 4px calc(4px + env(safe-area-inset-bottom)); background:var(--panel); border-top:1px solid var(--border); +} +.keys button { + height:38px; padding:0; border:1px solid var(--border); border-radius:6px; + background:#0c0f13; color:var(--text); font:500 13px ui-monospace, SFMono-Regular, Menlo, monospace; + touch-action:manipulation; user-select:none; -webkit-user-select:none; -webkit-tap-highlight-color:transparent; +} +.keys button:active { background:var(--border); } +.keys button.on { background:var(--accent); border-color:var(--accent); color:#fff; } +.keys button.lock { background:var(--accent); border-color:#fff; color:#fff; } +/* Copy and paste on touch screens: a long press selects, this menu acts. */ +.selmenu { position:fixed; left:50%; transform:translateX(-50%); z-index:20; display:flex; gap:7px; padding:5px; + background:var(--panel); border:1px solid var(--border); border-radius:8px; box-shadow:0 6px 24px rgba(0,0,0,.5); } +.selmenu[hidden] { display:none; } +.selmenu button { width:42px; height:42px; padding:0; display:inline-flex; align-items:center; justify-content:center; + touch-action:manipulation; -webkit-tap-highlight-color:transparent; } +body.touch .area { -webkit-user-select:none; user-select:none; -webkit-touch-callout:none; } +#status:empty { display:none; } diff --git a/internal/server/web/static/app.js b/internal/server/web/static/app.js index ccbc182..f560e53 100644 --- a/internal/server/web/static/app.js +++ b/internal/server/web/static/app.js @@ -128,6 +128,9 @@ function start() { document.querySelectorAll('main#app > .view').forEach(mount); show({dashboard: 'machines', settings: 'settings'}[$('main#app').dataset.view] || 'terminal'); + // /agents/{id}/terminal or /files: that pane, at once. + const o = window.APP && window.APP.open; + if (o && window.Workspace) window.Workspace.open(o.kind, o.agent); } window.App = {go, load, register, put, start, scope}; })(); diff --git a/internal/server/web/static/pages.js b/internal/server/web/static/pages.js index 38f834d..bb5d767 100644 --- a/internal/server/web/static/pages.js +++ b/internal/server/web/static/pages.js @@ -30,6 +30,13 @@ // the tab is hidden. App.register('dashboard', { mount(root, S) { + // Terminal and files buttons open a tab in the workspace, no reload. + S.on(root, 'click', function (ev) { + var b = ev.target.closest('[data-open]'); + if (!b || !window.Workspace || !Workspace.paneTypes[b.dataset.open]) return; + ev.preventDefault(); + Workspace.open(b.dataset.open, {id: b.dataset.id, name: b.dataset.name, host: b.dataset.host, user: b.dataset.user}); + }); var setTimeout = S.timeout, clearTimeout = S.clear, fetch = S.fetch; var rows = {}; diff --git a/internal/server/web/static/termpane.js b/internal/server/web/static/termpane.js new file mode 100644 index 0000000..647e0e2 --- /dev/null +++ b/internal/server/web/static/termpane.js @@ -0,0 +1,467 @@ +// Terminal panes of the workspace: a shell on a machine, or a shell the +// machine opens by SSH. Also the touch helpers for typing in them (extra +// keys, long-press copy). The workspace (workspace.js) holds the tabs. +// Touch helpers, for the terminal being typed in: the extra keys with their +// CTRL / ALT, and selecting and copying with a long press. +window.TermKeys = (function () { + const touch = window.matchMedia('(pointer: coarse)').matches; + const cur = () => window.TermPane && window.TermPane.active(); + // CTRL and ALT: a tap arms the key for the next key press, a long press + // keeps it on until tapped again. + const mods = { ctrl: 0, alt: 0 }; // 0 off, 1 next key, 2 locked + const modBtn = {}; + document.querySelectorAll('#keys [data-mod]').forEach(function (b) { modBtn[b.dataset.mod] = b; }); + function showMods() { + for (const m in mods) { + modBtn[m].classList.toggle('on', mods[m] === 1); + modBtn[m].classList.toggle('lock', mods[m] === 2); + } + } + function useMods() { + const used = { ctrl: !!mods.ctrl, alt: !!mods.alt }; + for (const m in mods) if (mods[m] === 1) mods[m] = 0; + showMods(); + return used; + } + function ctrlChar(c) { + if (c === ' ' || c === '@') return '\x00'; + if (c === '?') return '\x7f'; + const code = c.toUpperCase().charCodeAt(0); + return code >= 64 && code <= 95 ? String.fromCharCode(code & 31) : c; + } + function withMods(data) { + if (!mods.ctrl && !mods.alt) return data; + const m = useMods(); + if (m.ctrl && data.length === 1) data = ctrlChar(data); + return m.alt ? '\x1b' + data : data; + } + // xterm's modifier parameter: 1 + shift(1) + alt(2) + ctrl(4). + const CSI = { up: 'A', down: 'B', right: 'C', left: 'D', home: 'H', end: 'F' }; + const TILDE = { pgup: 5, pgdn: 6 }; + function keySeq(k, term) { + const m = useMods(), p = 1 + (m.alt ? 2 : 0) + (m.ctrl ? 4 : 0); + if (CSI[k]) { + if (p > 1) return '\x1b[1;' + p + CSI[k]; + return (term.modes.applicationCursorKeysMode ? '\x1bO' : '\x1b[') + CSI[k]; + } + if (TILDE[k]) return '\x1b[' + TILDE[k] + (p > 1 ? ';' + p : '') + '~'; + let s = k === 'esc' ? '\x1b' : k === 'tab' ? '\t' : k; + if (m.ctrl && s.length === 1) s = ctrlChar(s); + return m.alt ? '\x1b' + s : s; + } + let press = null; + document.querySelectorAll('#keys button').forEach(function (b) { + // Keep the focus (and the on-screen keyboard) on the terminal. + b.addEventListener('pointerdown', function (e) { + e.preventDefault(); + const mod = b.dataset.mod; + if (mod) press = { mod: mod, timer: setTimeout(function () { mods[mod] = 2; press = null; showMods(); }, 450) }; + }); + b.addEventListener('pointerup', function (e) { + e.preventDefault(); + const mod = b.dataset.mod; + if (mod) { + if (press && press.mod === mod) { + clearTimeout(press.timer); + press = null; + mods[mod] = mods[mod] ? 0 : 1; + showMods(); + } + return; + } + const s = cur(); + if (s) s.send(keySeq(b.dataset.k, s.term)); + }); + b.addEventListener('pointercancel', function () { if (press) { clearTimeout(press.timer); press = null; } }); + b.addEventListener('contextmenu', function (e) { e.preventDefault(); }); + }); + + // --- Selecting and copying on touch screens --- + // xterm.js has no touch selection of its own. A long press selects the word + // under the finger and dragging on from there stretches the selection; a + // menu then copies it or pastes. A plain tap or swipe still types and scrolls. + const menu = document.getElementById('selmenu'); + let selecting = false; + function showMenu() { + const s = cur(); + const keys = document.getElementById('keys').getBoundingClientRect(); + menu.style.top = ''; + menu.style.bottom = (window.innerHeight - keys.top + 14) + 'px'; + menu.querySelector('[data-a="copy"]').disabled = !(s && s.term.hasSelection()); + menu.hidden = false; + } + function selectionChanged(s) { + if (!touch || s !== cur()) return; + if (s.term.hasSelection()) showMenu(); + else if (!selecting) menu.hidden = true; + } + if (touch) { + const box = document.getElementById('area'); + let timer = null, start = null, anchor = null; + function cellAt(term, el, x, y) { + const r = el.querySelector('.xterm-screen').getBoundingClientRect(); + const col = Math.max(0, Math.min(term.cols - 1, Math.floor((x - r.left) / (r.width / term.cols)))); + const row = Math.max(0, Math.min(term.rows - 1, Math.floor((y - r.top) / (r.height / term.rows)))); + return { col: col, row: row + term.buffer.active.viewportY }; + } + function selectWord(term, c) { + const line = term.buffer.active.getLine(c.row); + const text = line ? line.translateToString(false) : ''; + const word = ch => ch && !/\s/.test(ch); + let a = c.col, b = c.col; + if (word(text[c.col])) { + while (a > 0 && word(text[a - 1])) a--; + while (b < text.length - 1 && word(text[b + 1])) b++; + } + term.select(a, c.row, b - a + 1); + anchor = { from: c.row * term.cols + a, to: c.row * term.cols + b }; + } + function stretch(term, c) { + const p = c.row * term.cols + c.col; + const from = Math.min(anchor.from, p), to = Math.max(anchor.to, p); + term.select(from % term.cols, Math.floor(from / term.cols), to - from + 1); + } + function hideMenu() { menu.hidden = true; const s = cur(); if (s) s.term.clearSelection(); anchor = null; } + box.addEventListener('touchstart', function (e) { + if (e.touches.length !== 1 || !e.target.closest('.pane-body')) { clearTimeout(timer); return; } + const t = e.touches[0]; + start = { x: t.clientX, y: t.clientY }; + if (!menu.hidden) hideMenu(); + clearTimeout(timer); + timer = setTimeout(function () { + const s = cur(); + if (!s) return; + selecting = true; + selectWord(s.term, cellAt(s.term, s.el, start.x, start.y)); + showMenu(); + if (navigator.vibrate) navigator.vibrate(15); + }, 450); + }, { passive: true, capture: true }); + box.addEventListener('touchmove', function (e) { + const t = e.touches[0], s = cur(); + if (selecting && s) { + e.preventDefault(); // no scrolling while the selection stretches + e.stopPropagation(); + stretch(s.term, cellAt(s.term, s.el, t.clientX, t.clientY)); + } else if (start && Math.hypot(t.clientX - start.x, t.clientY - start.y) > 8) { + clearTimeout(timer); // a swipe: let it scroll + } + }, { passive: false, capture: true }); + box.addEventListener('touchend', function (e) { + clearTimeout(timer); + if (!selecting) return; + selecting = false; + e.preventDefault(); // no tap: the keyboard stays as it was + e.stopPropagation(); + showMenu(); + }, { passive: false, capture: true }); + box.addEventListener('contextmenu', function (e) { e.preventDefault(); }); + + const statusEl = document.getElementById('status'); + function flash(t, cls) { + statusEl.textContent = t; statusEl.className = cls; + setTimeout(function () { const s = cur(); if (s) s.setState(s.state, s.text); }, 1500); + } + 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'); + 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(); + return ok ? Promise.resolve() : Promise.reject(new Error('нет доступа к буферу')); + } + menu.addEventListener('pointerdown', function (e) { e.preventDefault(); }); + menu.addEventListener('click', function (e) { + const b = e.target.closest('button'), s = cur(); + if (!b || !s) return; + const a = b.dataset.a; + if (a === 'copy') { + const text = s.term.getSelection(); + hideMenu(); + copyText(text).then(function () { flash('скопировано', 'st-ok'); }, function (err) { flash('не скопировалось: ' + err.message, 'st-err'); }); + } else if (a === 'paste') { + hideMenu(); + if (!navigator.clipboard || !navigator.clipboard.readText) { flash('вставка: браузер не даёт буфер', 'st-err'); return; } + navigator.clipboard.readText().then(function (t) { if (t) s.term.paste(t); }, function () { flash('вставка: нет доступа к буферу', 'st-err'); }); + } else if (a === 'all') { + s.term.selectAll(); + showMenu(); + } else { + hideMenu(); + } + }); + } + return { withMods: withMods, selectionChanged: selectionChanged }; +})(); +(function () { + 'use strict'; + const W = window.Workspace; + const touch = W.touch; + const statusEl = document.getElementById('status'); + const base = '/api/panel/term/'; + 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; + } + + // ---------- 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(W.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.kind = ssh ? 'ssh' : 'term'; + // Where its files are, for dropped paths: the machine, or the SSH host + // through it. + this.source = ssh + ? {kind: 'ssh', id: agent.id + '|' + ssh.user + '@' + ssh.host + ':' + (ssh.port || 22), label: ssh.user + '@' + ssh.host} + : {kind: 'agent', id: agent.id, label: agent.name}; + 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 = who(this); + this.el.querySelector('.pane-title').title = ssh ? 'SSH через ' + agent.name : agent.name; + if (ssh) this.el.querySelector('.dot').insertAdjacentHTML('afterend', 'SSH'); + 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', () => W.setActivePane(this), true); + this.term.textarea.addEventListener('focus', () => W.setActivePane(this)); + this.el.querySelector('.pane-x').onclick = ev => { ev.stopPropagation(); W.closePane(this); }; + if (W.desk) this.el.querySelector('.pane-head').addEventListener('pointerdown', ev => W.startDrag(ev, 'pane', this)); + this.open(); + } + setState(state, text) { + this.state = state; this.text = text; + this.el.querySelector('.dot').className = 'dot ' + state; + const t = W.tabOf(this); + if (t) W.renderTab(t); + W.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 */ } } + } + title() { return this.ssh ? this.ssh.host : this.agent.name; } + tooltip() { return who(this); } + live() { return !!this.sid && !this.ended && !this.closed; } + focus() { this.term.focus(); } + dropTarget() { return {kind: 'term', source: this.source}; } + // Files dropped here from the same place: their paths, quoted, typed in. + drop(item) { this.send(WSUtil.shellQuoteAll(item.paths) + ' '); this.term.focus(); } + 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; + } + + + W.paneTypes.term = agent => new Session(agent); + W.paneTypes.ssh = (agent, opts) => new Session(agent, opts.ssh); + // For the touch helpers: the terminal being typed in. + window.TermPane = {active: () => { const p = W.activePane(); return p && p.term ? p : null; }}; +})(); diff --git a/internal/server/web/static/terminal.js b/internal/server/web/static/workspace.js similarity index 65% rename from internal/server/web/static/terminal.js rename to internal/server/web/static/workspace.js index ed8a8c7..a934a83 100644 --- a/internal/server/web/static/terminal.js +++ b/internal/server/web/static/workspace.js @@ -1,12 +1,13 @@ -// 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. +// 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 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'); @@ -17,248 +18,6 @@ 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; - } - - // ---------- 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 = '
' + - '
' + - '
'; - 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', 'SSH'); - 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; @@ -288,7 +47,7 @@ } function tabTitle(t) { if (t.title) return t.title; - const s = t.panes[0], name = s.ssh ? s.ssh.host : s.agent.name; + 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 @@ -325,7 +84,7 @@ else t.el.querySelector('.badge-ssh').remove(); } requestAnimationFrame(fitTabs); - t.el.title = t.panes.map(who).join(', '); + 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'); @@ -340,7 +99,7 @@ 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(); + if (desk && activePane) activePane.focus(); updateMode(); } function setActivePane(s) { @@ -350,7 +109,8 @@ showStatus(); } function showStatus() { - if (!activePane) { statusEl.textContent = ''; return; } + // 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'; @@ -367,11 +127,15 @@ document.body.classList.toggle('multi', multi); setTimeout(() => active && layout(active, false), 280); } - function openTerminal(agent, ssh) { + function openPane(kind, agent, opts) { + const make = paneTypes[kind]; + if (!make) return null; $('empty').hidden = true; - const t = makeTab([new Session(agent, ssh)]); + 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; @@ -666,43 +430,53 @@ // ---------- the "+" menu ---------- let agentsCache = null; function openMenu(anchor) { - const here = (activePane && activePane.agent) || PAGE.agent; + const here = activePane && activePane.agent; addMenu.innerHTML = ''; - const item = (html, fn, cls) => { + const esc = v => String(v).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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; - addMenu.appendChild(b); + parent.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); }); - item('' + KEYI + 'SSH-подключениеагент откроет SSH к другому хосту', - () => { closeMenu(); openSSH(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 = '
не загрузилось
'; }); - }); + const FILES = ''; + // A group: «Терминал ›» or «Файловый менеджер ›», opening to here, the + // other machines and an SSH host. + const group = (icon, label, kind, sshKind, mode) => { + const g = item(addMenu, '' + icon + '' + label + 'здесь, на другой машине или по SSH›', () => { + 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, '+Здесь' + esc(here.name) + ' · ' + esc(here.user + '@' + here.host) + '', + () => { closeMenu(); openPane(kind, here); }); + const box = document.createElement('div'); + box.innerHTML = '
загрузка…
'; + list.appendChild(box); + item(list, '' + KEYI + 'SSH-хост…машина откроет SSH к другому хосту', + () => { 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, '' + esc(a.name) + '' + + esc(a.user + '@' + a.hostname) + (a.online ? '' : ' · ' + esc(a.seen)) + '', + () => { 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 = '
не загрузилось
'; }); + }); + if (!paneTypes[kind]) g.disabled = true; + return g; + }; + group('>_', 'Терминал', 'term', 'ssh', 'term'); + group(FILES, 'Файловый менеджер', 'files', 'sshfiles', 'files'); addMenu.hidden = false; placeMenu(anchor); setTimeout(() => document.addEventListener('pointerdown', outside, true), 0); @@ -725,10 +499,12 @@ // ---------- the SSH dialog ---------- const KEYI = ''; - let sshDlg = null; - function openSSH(here) { + 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); + sshDlg.show(here, mode || 'term'); } function makeSSHDialog() { const d = document.createElement('dialog'); @@ -798,14 +574,29 @@ 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) { + 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]; + agents = here ? [here] : []; const fill = () => { f.agent.innerHTML = ''; agents.forEach(a => { @@ -814,12 +605,12 @@ 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; + 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 (!agents.some(a => a.id === here.id)) agents.unshift(here); + 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 || ''; } @@ -851,8 +642,25 @@ 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(); + // 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 + }; })(); diff --git a/internal/server/web/templates/app.html b/internal/server/web/templates/app.html index c8a67b2..46dc4c2 100644 --- a/internal/server/web/templates/app.html +++ b/internal/server/web/templates/app.html @@ -9,12 +9,58 @@ {{template "topbar" .}}
{{if eq .View "dashboard"}}{{.Body}}{{end}}
-
+
+
+
+ +
+
+ + + +
+ + +
+ + + + + + + + + + + + + + +
+ +
{{if eq .View "settings"}}{{.Body}}{{end}}
+ + + + diff --git a/internal/server/web/templates/dashboard.html b/internal/server/web/templates/dashboard.html index 0e5c015..fb7d1aa 100644 --- a/internal/server/web/templates/dashboard.html +++ b/internal/server/web/templates/dashboard.html @@ -15,10 +15,10 @@ {{.Seen}}
- + - + diff --git a/internal/server/web/templates/terminal.html b/internal/server/web/templates/terminal.html deleted file mode 100644 index 31b82f4..0000000 --- a/internal/server/web/templates/terminal.html +++ /dev/null @@ -1,423 +0,0 @@ - - - - - -Терминал · {{.Agent.Name}} - LPRA - - - - - - - -
-
LPRA · Терминал - {{.Agent.Name}} ({{.Agent.TerminalUser}}@{{.Agent.Hostname}})
- -
-
-
- -
-
- - - -
- - -
- - - - - - - - - - - - - - -
- - - - - - -