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
|
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."""
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
|
||||||
}());
|
}());
|
||||||
|
|
|
||||||
|
|
@ -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
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