feat: live SSH terminal in drawer (xterm.js + asyncssh WebSocket)
Adds a Terminal tab to the log drawer with a full PTY session bridged
over WebSocket to the TrueNAS SSH host. xterm.js loaded lazily on
first tab open. Supports resize, paste, full color, and reconnect.
- app/terminal.py: asyncssh PTY ↔ WebSocket bridge
- routes.py: @router.websocket("/ws/terminal")
- dashboard.html: Terminal tab + drawer panel
- app.js: xterm.js lazy load, init, onData, resize observer, reconnect
- app.css: terminal panel styles (no padding, overflow hidden)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
70c26121a8
commit
5a802bff2e
5 changed files with 351 additions and 1 deletions
|
|
@ -5,7 +5,7 @@ import json
|
|||
from datetime import datetime, timezone
|
||||
|
||||
import aiosqlite
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, WebSocket
|
||||
from fastapi.responses import HTMLResponse, StreamingResponse
|
||||
from sse_starlette.sse import EventSourceResponse
|
||||
|
||||
|
|
@ -984,6 +984,13 @@ async def test_ssh():
|
|||
return {"ok": True}
|
||||
|
||||
|
||||
@router.websocket("/ws/terminal")
|
||||
async def terminal_ws(websocket: WebSocket):
|
||||
"""WebSocket endpoint bridging the browser xterm.js terminal to an SSH PTY."""
|
||||
from app import terminal as _term
|
||||
await _term.handle(websocket)
|
||||
|
||||
|
||||
@router.get("/api/v1/updates/check")
|
||||
async def check_updates():
|
||||
"""Check for a newer release on Forgejo."""
|
||||
|
|
|
|||
|
|
@ -2408,3 +2408,42 @@ tr.drawer-row-active {
|
|||
padding-bottom: 1px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Live Terminal drawer panel (xterm.js)
|
||||
----------------------------------------------------------------------- */
|
||||
.drawer-panel-terminal {
|
||||
padding: 0 !important;
|
||||
overflow: hidden !important;
|
||||
position: relative;
|
||||
background: #0d1117;
|
||||
}
|
||||
|
||||
/* Let xterm fill the full panel height */
|
||||
.drawer-panel-terminal .xterm {
|
||||
height: 100%;
|
||||
}
|
||||
.drawer-panel-terminal .xterm-viewport {
|
||||
overflow-y: auto !important;
|
||||
}
|
||||
|
||||
/* Reconnect bar — floats over the terminal when disconnected */
|
||||
.term-reconnect-bar {
|
||||
position: absolute;
|
||||
bottom: 12px;
|
||||
right: 12px;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: rgba(13,17,23,0.85);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.term-reconnect-bar .btn-secondary {
|
||||
padding: 3px 10px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1117,6 +1117,14 @@
|
|||
document.querySelectorAll('.drawer-panel').forEach(function (p) {
|
||||
p.classList.toggle('active', p.id === 'drawer-panel-' + _drawerTab);
|
||||
});
|
||||
// Terminal tab: init/fit on activation; hide autoscroll (N/A for terminal)
|
||||
var asl = document.querySelector('.autoscroll-label');
|
||||
if (_drawerTab === 'terminal') {
|
||||
if (asl) asl.style.visibility = 'hidden';
|
||||
openTerminalTab();
|
||||
} else {
|
||||
if (asl) asl.style.visibility = '';
|
||||
}
|
||||
});
|
||||
|
||||
// Close button
|
||||
|
|
@ -1141,4 +1149,155 @@
|
|||
}).catch(function () { showToast('Network error', 'error'); });
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Live Terminal (xterm.js + SSH WebSocket)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
var _xtermReady = false; // xterm.js + FitAddon libraries loaded
|
||||
var _terminal = null; // xterm.js Terminal instance
|
||||
var _termFit = null; // FitAddon instance
|
||||
var _termWs = null; // active WebSocket (null = disconnected)
|
||||
|
||||
function _loadXtermLibs(cb) {
|
||||
var link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
link.href = 'https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css';
|
||||
document.head.appendChild(link);
|
||||
|
||||
var s1 = document.createElement('script');
|
||||
s1.src = 'https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.js';
|
||||
s1.onload = function () {
|
||||
var s2 = document.createElement('script');
|
||||
s2.src = 'https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.js';
|
||||
s2.onload = cb;
|
||||
document.head.appendChild(s2);
|
||||
};
|
||||
document.head.appendChild(s1);
|
||||
}
|
||||
|
||||
function openTerminalTab() {
|
||||
var panel = document.getElementById('drawer-panel-terminal');
|
||||
if (!panel) return;
|
||||
|
||||
if (!_xtermReady) {
|
||||
panel.innerHTML = '<div class="drawer-loading">Loading terminal\u2026</div>';
|
||||
_loadXtermLibs(function () {
|
||||
_xtermReady = true;
|
||||
_termInit(panel);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_terminal) {
|
||||
_termInit(panel);
|
||||
return;
|
||||
}
|
||||
|
||||
// Already initialised — refit to current panel dimensions
|
||||
setTimeout(function () {
|
||||
if (_termFit) try { _termFit.fit(); } catch (_) {}
|
||||
}, 30);
|
||||
}
|
||||
|
||||
function _termInit(panel) {
|
||||
panel.innerHTML = '';
|
||||
|
||||
var term = new Terminal({
|
||||
cursorBlink: true,
|
||||
fontSize: 13,
|
||||
fontFamily: '"SF Mono","Fira Code",Consolas,"DejaVu Sans Mono",monospace',
|
||||
theme: {
|
||||
background: '#0d1117',
|
||||
foreground: '#e6edf3',
|
||||
cursor: '#58a6ff',
|
||||
cursorAccent: '#0d1117',
|
||||
selectionBackground: 'rgba(88,166,255,0.25)',
|
||||
black: '#484f58', red: '#ff7b72', green: '#3fb950', yellow: '#d29922',
|
||||
blue: '#58a6ff', magenta: '#bc8cff', cyan: '#39c5cf', white: '#b1bac4',
|
||||
brightBlack: '#6e7681', brightRed: '#ffa198', brightGreen: '#56d364',
|
||||
brightYellow: '#e3b341', brightBlue: '#79c0ff', brightMagenta: '#d2a8ff',
|
||||
brightCyan: '#56d4dd', brightWhite: '#f0f6fc',
|
||||
},
|
||||
scrollback: 2000,
|
||||
allowProposedApi: true,
|
||||
});
|
||||
|
||||
var fit = new FitAddon.FitAddon();
|
||||
term.loadAddon(fit);
|
||||
term.open(panel);
|
||||
|
||||
_terminal = term;
|
||||
_termFit = fit;
|
||||
|
||||
// Initial fit after the panel is visible
|
||||
setTimeout(function () {
|
||||
if (_termFit) try { _termFit.fit(); } catch (_) {}
|
||||
}, 30);
|
||||
|
||||
// Forward all keystrokes → SSH (onData registered once here)
|
||||
term.onData(function (data) {
|
||||
if (_termWs && _termWs.readyState === 1) {
|
||||
_termWs.send(new TextEncoder().encode(data));
|
||||
}
|
||||
});
|
||||
|
||||
// Refit + notify server on resize
|
||||
new ResizeObserver(function () {
|
||||
if (!_termFit) return;
|
||||
try { _termFit.fit(); } catch (_) {}
|
||||
if (_termWs && _termWs.readyState === 1 && _terminal) {
|
||||
_termWs.send(JSON.stringify({ type: 'resize', cols: _terminal.cols, rows: _terminal.rows }));
|
||||
}
|
||||
}).observe(panel);
|
||||
|
||||
_termConnect();
|
||||
}
|
||||
|
||||
function _termConnect() {
|
||||
if (_termWs && _termWs.readyState <= 1) return; // already open or connecting
|
||||
|
||||
var proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
var ws = new WebSocket(proto + '//' + location.host + '/ws/terminal');
|
||||
ws.binaryType = 'arraybuffer';
|
||||
_termWs = ws;
|
||||
|
||||
ws.onopen = function () {
|
||||
_termHideReconnect();
|
||||
if (_terminal && ws.readyState === 1) {
|
||||
ws.send(JSON.stringify({ type: 'resize', cols: _terminal.cols, rows: _terminal.rows }));
|
||||
}
|
||||
};
|
||||
|
||||
ws.onmessage = function (e) {
|
||||
if (!_terminal) return;
|
||||
_terminal.write(e.data instanceof ArrayBuffer ? new Uint8Array(e.data) : e.data);
|
||||
};
|
||||
|
||||
ws.onclose = function () {
|
||||
if (_terminal) _terminal.write('\r\n\x1b[33m\u2500\u2500 disconnected \u2500\u2500\x1b[0m\r\n');
|
||||
_termShowReconnect();
|
||||
};
|
||||
|
||||
ws.onerror = function () { /* onclose fires too */ };
|
||||
}
|
||||
|
||||
function _termShowReconnect() {
|
||||
var panel = document.getElementById('drawer-panel-terminal');
|
||||
if (!panel || panel.querySelector('.term-reconnect-bar')) return;
|
||||
var bar = document.createElement('div');
|
||||
bar.className = 'term-reconnect-bar';
|
||||
bar.innerHTML = '<span>Connection closed</span>'
|
||||
+ '<button class="btn-secondary">\u21ba Reconnect</button>';
|
||||
bar.querySelector('button').onclick = function () {
|
||||
bar.remove();
|
||||
_termConnect();
|
||||
};
|
||||
panel.appendChild(bar);
|
||||
}
|
||||
|
||||
function _termHideReconnect() {
|
||||
var bar = document.querySelector('.term-reconnect-bar');
|
||||
if (bar) bar.remove();
|
||||
}
|
||||
|
||||
}());
|
||||
|
|
|
|||
|
|
@ -83,6 +83,7 @@
|
|||
<button class="drawer-tab active" data-tab="burnin">Burn-In</button>
|
||||
<button class="drawer-tab" data-tab="smart">SMART</button>
|
||||
<button class="drawer-tab" data-tab="events">Events</button>
|
||||
<button class="drawer-tab" data-tab="terminal">Terminal</button>
|
||||
</nav>
|
||||
<div class="drawer-controls">
|
||||
<label class="autoscroll-label">
|
||||
|
|
@ -96,6 +97,7 @@
|
|||
<div class="drawer-panel active" id="drawer-panel-burnin"></div>
|
||||
<div class="drawer-panel" id="drawer-panel-smart"></div>
|
||||
<div class="drawer-panel" id="drawer-panel-events"></div>
|
||||
<div class="drawer-panel drawer-panel-terminal" id="drawer-panel-terminal"></div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
|
|||
143
app/terminal.py
Normal file
143
app/terminal.py
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
"""
|
||||
WebSocket → asyncssh PTY bridge for the live terminal drawer tab.
|
||||
|
||||
Protocol
|
||||
--------
|
||||
Client → server: binary = raw terminal input bytes
|
||||
text = JSON control message, e.g. {"type":"resize","cols":80,"rows":24}
|
||||
Server → client: binary = raw terminal output bytes
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
|
||||
import asyncssh
|
||||
from fastapi import WebSocket, WebSocketDisconnect
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def handle(ws: WebSocket) -> None:
|
||||
"""Accept a WebSocket connection and bridge it to an SSH PTY."""
|
||||
await ws.accept()
|
||||
|
||||
from app.config import settings # late import — avoids circular at module level
|
||||
|
||||
# ── Guard: SSH must be configured ──────────────────────────────────────
|
||||
if not settings.ssh_host:
|
||||
await _send(ws,
|
||||
b"\r\n\x1b[33mSSH not configured.\x1b[0m "
|
||||
b"Set SSH Host in \x1b[1mSettings \u2192 SSH\x1b[0m first.\r\n"
|
||||
)
|
||||
await ws.close(1008)
|
||||
return
|
||||
|
||||
connect_kw: dict = dict(
|
||||
host=settings.ssh_host,
|
||||
port=settings.ssh_port,
|
||||
username=settings.ssh_user,
|
||||
known_hosts=None,
|
||||
)
|
||||
|
||||
if settings.ssh_key.strip():
|
||||
try:
|
||||
connect_kw["client_keys"] = [asyncssh.import_private_key(settings.ssh_key)]
|
||||
except Exception as exc:
|
||||
await _send(ws, f"\r\n\x1b[31mBad SSH key: {exc}\x1b[0m\r\n".encode())
|
||||
await ws.close(1011)
|
||||
return
|
||||
elif settings.ssh_password:
|
||||
connect_kw["password"] = settings.ssh_password
|
||||
else:
|
||||
await _send(ws,
|
||||
b"\r\n\x1b[33mNo SSH credentials configured.\x1b[0m "
|
||||
b"Set a password or private key in Settings.\r\n"
|
||||
)
|
||||
await ws.close(1008)
|
||||
return
|
||||
|
||||
await _send(ws,
|
||||
f"\r\n\x1b[36mConnecting to {settings.ssh_host}\u2026\x1b[0m\r\n".encode()
|
||||
)
|
||||
|
||||
# ── Open SSH connection ─────────────────────────────────────────────────
|
||||
try:
|
||||
async with asyncssh.connect(**connect_kw) as conn:
|
||||
process = await conn.create_process(
|
||||
term_type="xterm-256color",
|
||||
term_size=(80, 24),
|
||||
encoding=None, # raw bytes — xterm.js handles encoding
|
||||
)
|
||||
await _send(ws, b"\r\n\x1b[32mConnected\x1b[0m\r\n\r\n")
|
||||
|
||||
stop = asyncio.Event()
|
||||
|
||||
async def ssh_to_ws() -> None:
|
||||
try:
|
||||
async for chunk in process.stdout:
|
||||
await ws.send_bytes(chunk)
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
stop.set()
|
||||
|
||||
async def ws_to_ssh() -> None:
|
||||
try:
|
||||
while not stop.is_set():
|
||||
msg = await ws.receive()
|
||||
if msg["type"] == "websocket.disconnect":
|
||||
break
|
||||
if msg.get("bytes"):
|
||||
process.stdin.write(msg["bytes"])
|
||||
elif msg.get("text"):
|
||||
try:
|
||||
ctrl = json.loads(msg["text"])
|
||||
if ctrl.get("type") == "resize":
|
||||
process.change_terminal_size(
|
||||
int(ctrl["cols"]), int(ctrl["rows"])
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
stop.set()
|
||||
|
||||
t1 = asyncio.create_task(ssh_to_ws())
|
||||
t2 = asyncio.create_task(ws_to_ssh())
|
||||
|
||||
_done, pending = await asyncio.wait(
|
||||
[t1, t2], return_when=asyncio.FIRST_COMPLETED
|
||||
)
|
||||
for t in pending:
|
||||
t.cancel()
|
||||
try:
|
||||
await t
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
except asyncssh.PermissionDenied:
|
||||
await _send(ws, b"\r\n\x1b[31mSSH permission denied.\x1b[0m\r\n")
|
||||
except asyncssh.DisconnectError as exc:
|
||||
await _send(ws, f"\r\n\x1b[31mSSH disconnected: {exc}\x1b[0m\r\n".encode())
|
||||
except OSError as exc:
|
||||
await _send(ws, f"\r\n\x1b[31mCannot reach {settings.ssh_host}: {exc}\x1b[0m\r\n".encode())
|
||||
except Exception as exc:
|
||||
log.exception("Terminal WebSocket error")
|
||||
await _send(ws, f"\r\n\x1b[31mError: {exc}\x1b[0m\r\n".encode())
|
||||
finally:
|
||||
try:
|
||||
await ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def _send(ws: WebSocket, data: bytes) -> None:
|
||||
"""Best-effort send — silently swallow errors if the socket is already gone."""
|
||||
try:
|
||||
await ws.send_bytes(data)
|
||||
except Exception:
|
||||
pass
|
||||
Loading…
Add table
Reference in a new issue