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:
Brandon Walter 2026-02-24 09:30:56 -05:00
parent 70c26121a8
commit 5a802bff2e
5 changed files with 351 additions and 1 deletions

View file

@ -5,7 +5,7 @@ import json
from datetime import datetime, timezone from datetime import datetime, timezone
import aiosqlite 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 fastapi.responses import HTMLResponse, StreamingResponse
from sse_starlette.sse import EventSourceResponse from sse_starlette.sse import EventSourceResponse
@ -984,6 +984,13 @@ async def test_ssh():
return {"ok": True} 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") @router.get("/api/v1/updates/check")
async def check_updates(): async def check_updates():
"""Check for a newer release on Forgejo.""" """Check for a newer release on Forgejo."""

View file

@ -2408,3 +2408,42 @@ tr.drawer-row-active {
padding-bottom: 1px; padding-bottom: 1px;
font-variant-numeric: tabular-nums; 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;
}

View file

@ -1117,6 +1117,14 @@
document.querySelectorAll('.drawer-panel').forEach(function (p) { document.querySelectorAll('.drawer-panel').forEach(function (p) {
p.classList.toggle('active', p.id === 'drawer-panel-' + _drawerTab); 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 // Close button
@ -1141,4 +1149,155 @@
}).catch(function () { showToast('Network error', 'error'); }); }).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();
}
}()); }());

View file

@ -83,6 +83,7 @@
<button class="drawer-tab active" data-tab="burnin">Burn-In</button> <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="smart">SMART</button>
<button class="drawer-tab" data-tab="events">Events</button> <button class="drawer-tab" data-tab="events">Events</button>
<button class="drawer-tab" data-tab="terminal">Terminal</button>
</nav> </nav>
<div class="drawer-controls"> <div class="drawer-controls">
<label class="autoscroll-label"> <label class="autoscroll-label">
@ -96,6 +97,7 @@
<div class="drawer-panel active" id="drawer-panel-burnin"></div> <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-smart"></div>
<div class="drawer-panel" id="drawer-panel-events"></div> <div class="drawer-panel" id="drawer-panel-events"></div>
<div class="drawer-panel drawer-panel-terminal" id="drawer-panel-terminal"></div>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

143
app/terminal.py Normal file
View 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