From 21e03aa990a25727e78782689cca5cf0232001af Mon Sep 17 00:00:00 2001 From: rabadjiyski Date: Thu, 3 Sep 2026 15:19:47 +0300 Subject: [PATCH] some GUI changes --- README.md | 1 + src/static/webssh.css | 21 +++++++++++- src/static/webssh.js | 21 +++++++++++- src/webssh_backend/ssh_handler.py | 53 ++++++++++++++++++++++++++++++- 4 files changed, 93 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index a96ed54..0ede1ea 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ Multi-connection SSH client that runs in your browser with JWT authentication. - **SFTP File Manager**: Built-in file manager with upload/download capabilities - **Terminal emulator**: Uses xterm.js for a full terminal experience - **Connection management**: Sidebar with connection list, status indicators, and quick actions +- **Remote load monitoring**: Active SSH connections display the remote Linux 1/5/15-minute load average - **Session Recording**: Automatic recording of SSH sessions in asciinema format with playback and configurable retention - **Dark theme**: Modern dark UI design - **Single port**: All services run through port 8080 (HTTP API + WebSocket) diff --git a/src/static/webssh.css b/src/static/webssh.css index a73b44b..5f66d96 100644 --- a/src/static/webssh.css +++ b/src/static/webssh.css @@ -77,12 +77,31 @@ body { .connection-name { font-weight: 500; font-size: 14px; - margin-bottom: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.connection-heading { + display: flex; + align-items: baseline; + gap: 8px; + min-width: 0; + margin-bottom: 4px; +} + +.connection-heading .connection-name { min-width: 0; } + +.connection-load { + flex: 0 1 auto; + max-width: 145px; + overflow: hidden; + color: #6ee7b7; + font-size: 10px; + white-space: nowrap; + text-overflow: ellipsis; +} + .connection-info { font-size: 12px; color: #9ca3af; diff --git a/src/static/webssh.js b/src/static/webssh.js index 274133d..6f5a82a 100644 --- a/src/static/webssh.js +++ b/src/static/webssh.js @@ -40,6 +40,7 @@ async function loadSavedConnections() { connections.forEach(conn => { conn.connected = false; conn.socket = null; + conn.loadAverage = null; if (!conn.terminalBuffer) conn.terminalBuffer = []; }); renderConnectionList(); @@ -76,6 +77,7 @@ function loadLocalConnections() { key: '', connected: false, socket: null, + loadAverage: null, terminalBuffer: c.terminalBuffer || [] })); renderConnectionList(); @@ -414,6 +416,7 @@ function createConnection() { disconnectTimeout: disconnectTimeout, connected: false, socket: null, + loadAverage: null, terminalBuffer: [] }; @@ -433,11 +436,17 @@ function renderConnectionList() { const list = document.getElementById('connectionList'); list.innerHTML = ''; connections.forEach(conn => { + const loadText = conn.connected + ? (conn.loadAverage || 'N/A') + : (conn.socket && conn.socket.readyState === WebSocket.CONNECTING ? 'Loading...' : '—'); const item = document.createElement('div'); item.className = `connection-item ${conn.id === currentConnectionId ? 'active' : ''} ${conn.connected ? 'connected' : 'disconnected'}`; item.onclick = () => selectConnection(conn.id); item.innerHTML = ` -
${conn.name}
+
+
${conn.name}
+
Load: ${loadText}
+
${conn.username}@${conn.host}:${conn.port} @@ -607,6 +616,8 @@ async function connectConnection(id) { term.writeln(`Connecting to ${conn.name} (${conn.username}@${conn.host}:${conn.port})...`); updateTerminalStatus('connecting'); + conn.loadAverage = null; + renderConnectionList(); try { if (!backendWsPort) { @@ -649,12 +660,18 @@ async function connectConnection(id) { resetInactivityTimers(); return; } + if (msg.type === 'load_average') { + conn.loadAverage = msg.value || null; + renderConnectionList(); + return; + } if (msg.type === 'error') { if (conn.id === currentConnectionId) { term.writeln(`[ERROR] ${msg.message}`); updateTerminalStatus('disconnected'); } conn.connected = false; + conn.loadAverage = null; renderConnectionList(); return; } @@ -673,11 +690,13 @@ async function connectConnection(id) { updateTerminalStatus('disconnected'); } conn.connected = false; + conn.loadAverage = null; renderConnectionList(); }; conn.socket.onclose = (event) => { conn.connected = false; + conn.loadAverage = null; conn.socket = null; if (conn.id === currentConnectionId) { term.writeln(`[DISCONNECTED] Code: ${event.code}`); diff --git a/src/webssh_backend/ssh_handler.py b/src/webssh_backend/ssh_handler.py index 100bda6..68646cf 100644 --- a/src/webssh_backend/ssh_handler.py +++ b/src/webssh_backend/ssh_handler.py @@ -215,7 +215,58 @@ async def handle_client(ws: WebSocketServerProtocol): finally: logger.info(f"ssh_to_ws finished for {client_ip}") - await asyncio.gather(ws_to_ssh(), ssh_to_ws()) + async def monitor_load_average(): + """Send remote Linux load averages without using the interactive shell.""" + monitoring_error_logged = False + try: + while chan.active and not ws_closed.is_set(): + try: + command = "cat /proc/loadavg" + + def read_load_average(): + stdin, stdout, stderr = ssh.exec_command(command, timeout=SSH_TIMEOUT) + try: + return stdout.read().decode(errors="ignore") + finally: + stdin.close() + stdout.close() + stderr.close() + + output = await asyncio.get_event_loop().run_in_executor( + None, read_load_average + ) + load_values = output.split()[:3] + if len(load_values) != 3 or any( + not value.replace('.', '', 1).isdigit() for value in load_values + ): + raise ValueError("Unexpected /proc/loadavg response") + load_average = ", ".join(load_values) + monitoring_error_logged = False + except Exception as e: + load_average = None + if not monitoring_error_logged: + logger.warning(f"Load average unavailable for {config.host}: {e}") + monitoring_error_logged = True + + if ws_closed.is_set(): + break + try: + await ws.send(json.dumps({ + "type": "load_average", + "value": load_average + })) + except websockets.exceptions.ConnectionClosed: + break + try: + await asyncio.wait_for(ws_closed.wait(), timeout=5) + except asyncio.TimeoutError: + pass + except asyncio.CancelledError: + raise + finally: + logger.info(f"load monitor finished for {client_ip}") + + await asyncio.gather(ws_to_ssh(), ssh_to_ws(), monitor_load_average()) logger.info(f"Session ended for {client_ip}") except json.JSONDecodeError as e: