feat: initial commit
This commit is contained in:
1 parent
353f62e257
commit
c6852c6f1b
62 files changed
+5911
-2
No files matched your search
@@ -0,0 +1,149 @@
|
||||
<!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.Hostname}})</span></div>
|
||||
<nav>
|
||||
<a href="/">← Машины</a>
|
||||
<a href="/agents/{{.Agent.ID}}/files">Файлы</a>
|
||||
<a href="/agents/{{.Agent.ID}}/forwards">Порты</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>
|
||||
Reference in new issue
Block a user