diff --git a/app/routes.py b/app/routes.py
index 6186e3b..8d89f08 100644
--- a/app/routes.py
+++ b/app/routes.py
@@ -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."""
diff --git a/app/static/app.css b/app/static/app.css
index 8b663cb..94dafc4 100644
--- a/app/static/app.css
+++ b/app/static/app.css
@@ -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;
+}
diff --git a/app/static/app.js b/app/static/app.js
index 3d9c5b2..ca59f68 100644
--- a/app/static/app.js
+++ b/app/static/app.js
@@ -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 = '
Loading terminal\u2026
';
+ _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 = 'Connection closed'
+ + '';
+ bar.querySelector('button').onclick = function () {
+ bar.remove();
+ _termConnect();
+ };
+ panel.appendChild(bar);
+ }
+
+ function _termHideReconnect() {
+ var bar = document.querySelector('.term-reconnect-bar');
+ if (bar) bar.remove();
+ }
+
}());
diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html
index 1f08320..dd7ec38 100644
--- a/app/templates/dashboard.html
+++ b/app/templates/dashboard.html
@@ -83,6 +83,7 @@
+
{% endblock %}
diff --git a/app/terminal.py b/app/terminal.py
new file mode 100644
index 0000000..0d489e6
--- /dev/null
+++ b/app/terminal.py
@@ -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