152 lines
4.9 KiB
HTML
152 lines
4.9 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="/static/app.css">
|
|
<link rel="stylesheet" href="/static/xterm.css">
|
|
<style>
|
|
#status.st-ok { color:#37c871; }
|
|
#status.st-err { color:#ff5c5c; }
|
|
</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>
|
|
|
|
<script src="/static/xterm.js"></script>
|
|
<script src="/static/addon-fit.js"></script>
|
|
<script>
|
|
(function () {
|
|
const agentID = {{.Agent.ID}};
|
|
const statusEl = document.getElementById('status');
|
|
function setStatus(t, cls) { statusEl.textContent = t; statusEl.className = cls || ''; }
|
|
|
|
const term = new Terminal({
|
|
cursorBlink: true,
|
|
fontSize: 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');
|
|
}
|
|
|
|
term.onData(function (data) {
|
|
if (closed || !sid) return;
|
|
const bytes = new TextEncoder().encode(data);
|
|
post(base + sid + '/in', { t: 'i', d: bytesToB64(bytes) });
|
|
});
|
|
|
|
function sendResize() {
|
|
if (closed || !sid) return;
|
|
post(base + sid + '/in', { t: 'r', cols: term.cols, rows: term.rows });
|
|
}
|
|
window.addEventListener('resize', function () { fit.fit(); sendResize(); });
|
|
term.onResize(sendResize);
|
|
|
|
window.addEventListener('pagehide', function () {
|
|
if (sid) navigator.sendBeacon(base + sid + '/close');
|
|
});
|
|
|
|
term.focus();
|
|
open();
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|