436 lines
19 KiB
HTML
436 lines
19 KiB
HTML
<!doctype html>
|
|
<html lang="ru">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>Терминал · {{.Agent.Name}} - LPRA</title>
|
|
<link rel="stylesheet" href="{{asset "app.css"}}">
|
|
<link rel="stylesheet" href="{{asset "xterm.css"}}">
|
|
<style>
|
|
#status.st-ok { color:#37c871; }
|
|
#status.st-err { color:#ff5c5c; }
|
|
/* 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. */
|
|
.keys { display:none; }
|
|
body.touch { position:fixed; left:0; right:0; top:0; height:100%; display:flex; flex-direction:column; overflow:hidden; }
|
|
body.touch .wrap { flex:1; min-height:0; display:flex; width:100%; margin:0; padding:6px; }
|
|
body.touch #terminal { flex:1; min-width:0; height:auto; }
|
|
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 #terminal { -webkit-user-select:none; user-select:none; -webkit-touch-callout:none; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<header class="topbar">
|
|
<div class="brand">LPRA · Терминал - {{.Agent.Name}} <span class="muted">({{.Agent.TerminalUser}}@{{.Agent.Hostname}})</span></div>
|
|
<nav>
|
|
<a href="/">← Машины</a>
|
|
<a href="/agents/{{.Agent.ID}}/files">Файлы</a>
|
|
<a href="/agents/{{.Agent.ID}}/forwards">Порты</a>
|
|
<a href="/agents/{{.Agent.ID}}/status">Статус</a>
|
|
<a href="/agents/{{.Agent.ID}}/edit">Изменить</a>
|
|
<span id="status" class="muted" style="margin-left:28px">подключение…</span>
|
|
</nav>
|
|
</header>
|
|
<div class="wrap" style="max-width:none">
|
|
<div id="terminal"></div>
|
|
</div>
|
|
<div class="selmenu" id="selmenu" hidden>
|
|
<button type="button" data-a="copy" title="Копировать" aria-label="Копировать">
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1"/></svg>
|
|
</button>
|
|
<button type="button" class="btn ghost" data-a="paste" title="Вставить" aria-label="Вставить">
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="8" y="2" width="8" height="4" rx="1"/><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/></svg>
|
|
</button>
|
|
<button type="button" class="btn ghost" data-a="all" title="Выделить всё" aria-label="Выделить всё">
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 3a2 2 0 0 0-2 2M19 3a2 2 0 0 1 2 2M21 19a2 2 0 0 1-2 2M5 21a2 2 0 0 1-2-2M9 3h1M9 21h1M14 3h1M14 21h1M3 9v1M21 9v1M3 14v1M21 14v1"/><rect x="7" y="7" width="10" height="10" rx="1"/></svg>
|
|
</button>
|
|
<button type="button" class="btn ghost close" data-a="close" title="Закрыть" aria-label="Закрыть">
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg>
|
|
</button>
|
|
</div>
|
|
<div class="keys" id="keys">
|
|
<button type="button" data-k="esc">ESC</button>
|
|
<button type="button" data-k="/">/</button>
|
|
<button type="button" data-k="-">-</button>
|
|
<button type="button" data-k="home">HOME</button>
|
|
<button type="button" data-k="up">↑</button>
|
|
<button type="button" data-k="end">END</button>
|
|
<button type="button" data-k="pgup">PGUP</button>
|
|
<button type="button" data-k="tab">TAB</button>
|
|
<button type="button" data-k="ctrl" data-mod="ctrl">CTRL</button>
|
|
<button type="button" data-k="alt" data-mod="alt">ALT</button>
|
|
<button type="button" data-k="left">←</button>
|
|
<button type="button" data-k="down">↓</button>
|
|
<button type="button" data-k="right">→</button>
|
|
<button type="button" data-k="pgdn">PGDN</button>
|
|
</div>
|
|
|
|
<script src="{{asset "xterm.js"}}"></script>
|
|
<script src="{{asset "addon-fit.js"}}"></script>
|
|
<script>
|
|
(function () {
|
|
const agentID = {{.Agent.ID}};
|
|
const statusEl = document.getElementById('status');
|
|
const touch = window.matchMedia('(pointer: coarse)').matches;
|
|
if (touch) document.body.classList.add('touch');
|
|
function setStatus(t, cls) { statusEl.textContent = t; statusEl.className = cls || ''; }
|
|
|
|
const term = new Terminal({
|
|
cursorBlink: true,
|
|
fontSize: touch ? 13 : 14,
|
|
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
|
|
theme: { background: '#0c0f13' }
|
|
});
|
|
const fit = new FitAddon.FitAddon();
|
|
term.loadAddon(fit);
|
|
term.open(document.getElementById('terminal'));
|
|
fit.fit();
|
|
|
|
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);
|
|
const u = new Uint8Array(bin.length);
|
|
for (let i = 0; i < bin.length; i++) u[i] = bin.charCodeAt(i);
|
|
return u;
|
|
}
|
|
|
|
const base = '/api/panel/term/';
|
|
let sid = null;
|
|
let closed = false;
|
|
let connected = false;
|
|
|
|
function post(path, body) {
|
|
return fetch(path, {
|
|
method: 'POST',
|
|
headers: body ? { 'Content-Type': 'application/json' } : {},
|
|
body: body ? JSON.stringify(body) : undefined,
|
|
keepalive: true
|
|
});
|
|
}
|
|
|
|
async function open() {
|
|
try {
|
|
const r = await post(base + encodeURIComponent(agentID) + '/open');
|
|
if (!r.ok) {
|
|
const body = await r.text().catch(() => '');
|
|
setStatus('ошибка открытия (' + r.status + ')', 'st-err');
|
|
term.write('\r\n\x1b[31m[не удалось открыть сессию: HTTP ' + r.status + ']\x1b[0m\r\n');
|
|
if (body) term.write(body.slice(0, 300).replace(/\n/g, '\r\n') + '\r\n');
|
|
if (r.status === 404) term.write('\x1b[33mМашина не найдена на сервере - возможно, устаревший бинарник сервера без маршрута /open.\x1b[0m\r\n');
|
|
return;
|
|
}
|
|
const j = await r.json();
|
|
sid = j.session_id;
|
|
setStatus('ожидание агента…');
|
|
sendResize();
|
|
pollLoop();
|
|
} catch (e) {
|
|
setStatus('ошибка сети', 'st-err');
|
|
term.write('\r\n\x1b[31m[ошибка сети: ' + (e && e.message ? e.message : e) + ']\x1b[0m\r\n');
|
|
}
|
|
}
|
|
|
|
async function pollLoop() {
|
|
while (!closed && sid) {
|
|
let r;
|
|
try {
|
|
r = await fetch(base + sid + '/out', { cache: 'no-store' });
|
|
} catch (e) {
|
|
// transient network hiccup; brief pause then retry
|
|
await new Promise(res => setTimeout(res, 1000));
|
|
continue;
|
|
}
|
|
if (!r.ok) { finish('[соединение закрыто]'); return; }
|
|
const j = await r.json();
|
|
if (j.closed) { finish('[сессия завершена]'); return; }
|
|
if (j.frames) {
|
|
for (const f of j.frames) {
|
|
if (f.exit) { finish('[сессия завершена]'); return; }
|
|
if (f.data) {
|
|
if (!connected) { connected = true; setStatus('подключено', 'st-ok'); }
|
|
term.write(b64ToBytes(f.data));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function finish(msg) {
|
|
if (closed) return;
|
|
closed = true;
|
|
setStatus('соединение закрыто', 'muted');
|
|
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; what is typed meanwhile is sent together
|
|
// next. A request that fails goes again under the same number, and the
|
|
// server drops one it already has.
|
|
let inSeq = 0, inBusy = false;
|
|
const inQueue = [];
|
|
function queueIn(m) {
|
|
if (closed || !sid) return;
|
|
const last = inQueue[inQueue.length - 1];
|
|
if (last && last.t === 'i' && m.t === 'i') last.text += m.text;
|
|
else if (last && last.t === 'r' && m.t === 'r') inQueue[inQueue.length - 1] = m;
|
|
else inQueue.push(m);
|
|
pumpIn();
|
|
}
|
|
async function pumpIn() {
|
|
if (inBusy || !inQueue.length) return;
|
|
inBusy = true;
|
|
while (inQueue.length && !closed) {
|
|
const m = inQueue.shift();
|
|
const body = m.t === 'i'
|
|
? { t: 'i', d: bytesToB64(new TextEncoder().encode(m.text)), seq: ++inSeq }
|
|
: { t: 'r', cols: m.cols, rows: m.rows, seq: ++inSeq };
|
|
for (let wait = 300; !closed; wait = Math.min(wait * 2, 5000)) {
|
|
let r = null;
|
|
try {
|
|
r = await fetch(base + 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; // taken, or refused for good
|
|
await new Promise(res => setTimeout(res, wait));
|
|
}
|
|
}
|
|
inBusy = false;
|
|
}
|
|
function send(data) { queueIn({ t: 'i', text: data }); }
|
|
// What the keyboard types gets the CTRL / ALT of the extra keys, if armed.
|
|
term.onData(function (data) { send(withMods(data)); });
|
|
|
|
// --- Extra keys (touch screens) ---
|
|
// 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) {
|
|
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;
|
|
}
|
|
send(keySeq(b.dataset.k));
|
|
});
|
|
b.addEventListener('pointercancel', function () { if (press) { clearTimeout(press.timer); press = null; } });
|
|
b.addEventListener('contextmenu', function (e) { e.preventDefault(); });
|
|
});
|
|
|
|
function sendResize() { queueIn({ t: 'r', cols: term.cols, rows: term.rows }); }
|
|
// --- 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.
|
|
if (touch) {
|
|
const box = document.getElementById('terminal'), menu = document.getElementById('selmenu');
|
|
let timer = null, start = null, anchor = null, selecting = false;
|
|
function cellAt(x, y) {
|
|
const r = box.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(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(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);
|
|
}
|
|
// The menu shows as soon as there is a selection, however it came, and
|
|
// sits a little above the extra keys.
|
|
function showMenu() {
|
|
const keys = document.getElementById('keys').getBoundingClientRect();
|
|
menu.style.top = '';
|
|
menu.style.bottom = (window.innerHeight - keys.top + 14) + 'px';
|
|
menu.querySelector('[data-a="copy"]').disabled = !term.hasSelection();
|
|
menu.hidden = false;
|
|
}
|
|
function hideMenu() { menu.hidden = true; term.clearSelection(); anchor = null; }
|
|
box.addEventListener('touchstart', function (e) {
|
|
if (e.touches.length !== 1) { clearTimeout(timer); return; }
|
|
const t = e.touches[0];
|
|
start = { x: t.clientX, y: t.clientY };
|
|
if (!menu.hidden) hideMenu();
|
|
clearTimeout(timer);
|
|
timer = setTimeout(function () {
|
|
selecting = true;
|
|
selectWord(cellAt(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];
|
|
if (selecting) {
|
|
e.preventDefault(); // no scrolling while the selection stretches
|
|
e.stopPropagation();
|
|
stretch(cellAt(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(); });
|
|
term.onSelectionChange(function () {
|
|
if (term.hasSelection()) showMenu();
|
|
else if (!selecting) menu.hidden = true;
|
|
});
|
|
|
|
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');
|
|
if (!b) return;
|
|
const a = b.dataset.a;
|
|
if (a === 'copy') {
|
|
const text = term.getSelection();
|
|
hideMenu();
|
|
copyText(text).then(function () { setStatus('скопировано', 'st-ok'); setTimeout(function () { setStatus(connected ? 'подключено' : '', connected ? 'st-ok' : ''); }, 1500); },
|
|
function (err) { setStatus('не скопировалось: ' + err.message, 'st-err'); });
|
|
} else if (a === 'paste') {
|
|
hideMenu();
|
|
if (!navigator.clipboard || !navigator.clipboard.readText) { setStatus('вставка: браузер не даёт буфер', 'st-err'); return; }
|
|
navigator.clipboard.readText().then(function (t) { if (t) term.paste(t); }, function () { setStatus('вставка: нет доступа к буферу', 'st-err'); });
|
|
} else if (a === 'all') {
|
|
term.selectAll();
|
|
showMenu();
|
|
} else {
|
|
hideMenu();
|
|
}
|
|
});
|
|
}
|
|
|
|
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';
|
|
}
|
|
fit.fit();
|
|
}
|
|
window.addEventListener('resize', refit);
|
|
if (touch && window.visualViewport) {
|
|
window.visualViewport.addEventListener('resize', refit);
|
|
window.visualViewport.addEventListener('scroll', refit);
|
|
}
|
|
refit();
|
|
term.onResize(sendResize);
|
|
|
|
window.addEventListener('pagehide', function () {
|
|
if (sid) navigator.sendBeacon(base + sid + '/close');
|
|
});
|
|
|
|
term.focus();
|
|
open();
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|