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: