Files
lpra/internal/server/web/templates/terminal.html
T
redtea 3dde07f403 feat: terminal as a chosen OS user, create users from the panel (0.0.2)
- agent reports the OS user it runs as (enroll + poll header)
- per-machine terminal user on the edit page; defaults to the agent's own user (root on Linux)
- create a new user from the panel: name, password, optional sudo group
- show the reason in the terminal when a shell fails to start
- bump version to 0.0.2
2026-10-13 01:28:57 +03:00

150 lines
4.8 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>
<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>