diff --git a/README.md b/README.md
index e64c4a1..43085a7 100644
--- a/README.md
+++ b/README.md
@@ -70,6 +70,7 @@ WebSSH is a secure, self-hosted workspace for SSH terminals and SFTP file operat
- **Broadcast Input** - Send a command to all open SSH sessions simultaneously (cluster-SSH style)
- **Multi-Session Support** - Up to 10 concurrent SSH sessions with tabs
- **Split Panes** - 1, 2, or 4-pane layouts for monitoring multiple servers
+- **Focused Session Workspace** - In single-pane mode, open the active server's SFTP browser beside its terminal while keeping live Linux CPU, RAM, disk, and uptime information next to the notepad
- **Session Restoration** - Restore live sessions after a page refresh without injecting terminal input
- **Persistent tmux Sessions** - Keep remote shells and running commands alive across browser closes and WebSSH restarts, then reattach later
- **Manual Reconnect** - Reconnect from a session tab; SSH-key and Tailscale sessions can reconnect directly, while password sessions reopen the pre-filled connection form
@@ -85,11 +86,16 @@ WebSSH is a secure, self-hosted workspace for SSH terminals and SFTP file operat
- **Session Notes** - Per-session notes, auto-saved as you type
- **Command Palette** - Fuzzy command launcher (Ctrl+K)
+
+
+
+
### File Manager (SFTP)
+- **Active-Session Split** - In the single-terminal layout, open a compact SFTP browser that automatically follows the active SSH session
- **Dual-Pane Browser** - Side-by-side file browsing
- **Drag & Drop** - Transfer files between local and remote
- **Server-to-Server** - Direct transfer between SSH hosts
diff --git a/app/session_insights.py b/app/session_insights.py
new file mode 100644
index 0000000..db06cad
--- /dev/null
+++ b/app/session_insights.py
@@ -0,0 +1,227 @@
+"""Bounded, agentless Linux statistics for an active SSH session."""
+
+import socket
+import time
+from threading import Lock
+
+from . import ssh_manager
+
+
+DEFAULT_MAX_BYTES = 16 * 1024
+DEFAULT_TIMEOUT = 2.0
+REQUEST_RATE_LIMIT = '30 per minute'
+
+_collector_locks = {}
+_collector_locks_guard = Lock()
+
+
+LINUX_STATS_COMMAND = r"""LC_ALL=C
+awk 'NR == 1 { $1=""; sub(/^ /, ""); print "cpu=" $0; exit }' /proc/stat
+awk '
+ /^MemTotal:/ { print "mem_total_kib=" $2 }
+ /^MemAvailable:/ { print "mem_available_kib=" $2 }
+' /proc/meminfo
+df -Pk / | awk 'NR == 2 {
+ percent=$5; sub(/%$/, "", percent)
+ print "disk_total_kib=" $2
+ print "disk_used_kib=" $3
+ print "disk_available_kib=" $4
+ print "disk_percent=" percent
+}'
+awk '{ print "uptime_seconds=" $1; exit }' /proc/uptime
+if [ -r /etc/os-release ]; then
+ os_name=$(sed -n 's/^PRETTY_NAME=//p' /etc/os-release | head -n 1)
+ os_name=${os_name#\"}; os_name=${os_name%\"}
+ os_name=${os_name#\'}; os_name=${os_name%\'}
+else
+ os_name=Linux
+fi
+printf 'os_name=%s\n' "$os_name"
+"""
+
+
+def _non_negative_int(values, key):
+ try:
+ value = int(values[key])
+ except (KeyError, TypeError, ValueError) as exc:
+ raise ValueError(f'invalid {key}') from exc
+ if value < 0:
+ raise ValueError(f'invalid {key}')
+ return value
+
+
+def parse_linux_stats(text, *, max_bytes=DEFAULT_MAX_BYTES):
+ """Parse the fixed collector output into a small, safe payload."""
+ if not isinstance(text, str):
+ raise ValueError('invalid stats payload')
+ if len(text.encode('utf-8')) > max_bytes:
+ raise ValueError('stats payload too large')
+
+ values = {}
+ for raw_line in text.splitlines():
+ key, separator, value = raw_line.partition('=')
+ if not separator:
+ continue
+ if key in {
+ 'cpu', 'mem_total_kib', 'mem_available_kib',
+ 'disk_total_kib', 'disk_used_kib', 'disk_available_kib',
+ 'disk_percent', 'uptime_seconds', 'os_name',
+ }:
+ values[key] = value.strip()
+
+ try:
+ cpu = [int(part) for part in values['cpu'].split()]
+ except (KeyError, TypeError, ValueError) as exc:
+ raise ValueError('invalid cpu') from exc
+ if len(cpu) < 4 or any(value < 0 for value in cpu):
+ raise ValueError('invalid cpu')
+
+ mem_total = _non_negative_int(values, 'mem_total_kib')
+ mem_available = _non_negative_int(values, 'mem_available_kib')
+ if mem_total <= 0 or mem_available > mem_total:
+ raise ValueError('invalid memory')
+
+ disk_total = _non_negative_int(values, 'disk_total_kib')
+ disk_used = _non_negative_int(values, 'disk_used_kib')
+ disk_available = _non_negative_int(values, 'disk_available_kib')
+ disk_percent = _non_negative_int(values, 'disk_percent')
+ if (
+ disk_total <= 0
+ or disk_used > disk_total
+ or disk_available > disk_total
+ or disk_percent > 100
+ ):
+ raise ValueError('invalid disk')
+
+ try:
+ uptime_seconds = int(float(values['uptime_seconds']))
+ except (KeyError, TypeError, ValueError, OverflowError) as exc:
+ raise ValueError('invalid uptime') from exc
+ if uptime_seconds < 0:
+ raise ValueError('invalid uptime')
+
+ os_name = values.get('os_name', '').strip()
+ if not os_name or len(os_name) > 200:
+ raise ValueError('invalid os name')
+
+ return {
+ 'cpu': cpu,
+ 'memory': {
+ 'total_kib': mem_total,
+ 'available_kib': mem_available,
+ 'used_kib': mem_total - mem_available,
+ },
+ 'disk': {
+ 'total_kib': disk_total,
+ 'used_kib': disk_used,
+ 'available_kib': disk_available,
+ 'percent': disk_percent,
+ },
+ 'uptime_seconds': uptime_seconds,
+ 'os_name': os_name,
+ }
+
+
+def _acquire_session_lock(session_id):
+ with _collector_locks_guard:
+ entry = _collector_locks.setdefault(
+ session_id,
+ {'lock': Lock(), 'references': 0},
+ )
+ entry['references'] += 1
+ collector_lock = entry['lock']
+
+ if collector_lock.acquire(blocking=False):
+ return collector_lock
+
+ with _collector_locks_guard:
+ entry = _collector_locks.get(session_id)
+ if entry and entry['lock'] is collector_lock:
+ entry['references'] -= 1
+ if entry['references'] == 0:
+ _collector_locks.pop(session_id, None)
+ return None
+
+
+def _release_session_lock(session_id, collector_lock):
+ with _collector_locks_guard:
+ entry = _collector_locks.get(session_id)
+ collector_lock.release()
+ if entry and entry['lock'] is collector_lock:
+ entry['references'] -= 1
+ if entry['references'] == 0:
+ _collector_locks.pop(session_id, None)
+
+
+def collect_linux_stats(session_id, *, timeout=DEFAULT_TIMEOUT,
+ max_bytes=DEFAULT_MAX_BYTES):
+ """Collect one Linux sample without touching the interactive PTY."""
+ if not isinstance(session_id, str) or not session_id:
+ return None, 'unavailable'
+
+ collector_lock = _acquire_session_lock(session_id)
+ if collector_lock is None:
+ return None, 'busy'
+
+ channel = None
+ try:
+ with ssh_manager.sessions_lock:
+ session = ssh_manager.sessions.get(session_id)
+ if not session or not session.get('connected'):
+ return None, 'unavailable'
+ client = session.get('client')
+
+ transport = client.get_transport() if client else None
+ if not transport or not transport.is_active():
+ return None, 'unavailable'
+
+ channel = ssh_manager._open_exec_channel(
+ transport,
+ LINUX_STATS_COMMAND,
+ timeout=timeout,
+ )
+ deadline = time.monotonic() + timeout
+ output = bytearray()
+
+ while True:
+ if channel.recv_ready():
+ chunk = channel.recv(min(4096, max_bytes + 1 - len(output)))
+ if not chunk:
+ break
+ output.extend(chunk)
+ if len(output) > max_bytes:
+ return None, 'unavailable'
+ continue
+ if channel.exit_status_ready():
+ break
+ if time.monotonic() >= deadline:
+ return None, 'unavailable'
+ time.sleep(0.02)
+
+ while channel.recv_ready():
+ chunk = channel.recv(min(4096, max_bytes + 1 - len(output)))
+ if not chunk:
+ break
+ output.extend(chunk)
+ if len(output) > max_bytes:
+ return None, 'unavailable'
+
+ if channel.recv_exit_status() != 0:
+ return None, 'unavailable'
+
+ try:
+ text = output.decode('utf-8', errors='strict')
+ return parse_linux_stats(text, max_bytes=max_bytes), None
+ except (UnicodeDecodeError, ValueError):
+ return None, 'unavailable'
+ except (OSError, socket.timeout):
+ return None, 'unavailable'
+ except Exception:
+ return None, 'unavailable'
+ finally:
+ if channel is not None:
+ try:
+ channel.close()
+ except Exception:
+ pass
+ _release_session_lock(session_id, collector_lock)
diff --git a/app/socket_events.py b/app/socket_events.py
index 319eb6e..643af64 100644
--- a/app/socket_events.py
+++ b/app/socket_events.py
@@ -1,7 +1,8 @@
from flask_socketio import emit, join_room, disconnect
from flask import request, current_app, url_for
from . import (socketio, ssh_manager, profile_manager, key_manager,
- sftp_handler, jump_host_manager, post_connect_manager)
+ sftp_handler, jump_host_manager, post_connect_manager,
+ session_insights)
from .decorators import socket_login_required
from .auth import register_socket_session, get_user_from_socket, check_socket_rate_limit
from .models import db, SSHSession, SocketSession
@@ -906,11 +907,19 @@ def handle_list_directory(data, current_user=None):
import time as _time
_t0 = _time.time()
try:
- session_id = data.get('session_id')
- remote_path = data.get('remote_path', '.')
+ payload = data if isinstance(data, dict) else {}
+ session_id = payload.get('session_id')
+ remote_path = payload.get('remote_path', '.')
+ request_id = payload.get('request_id')
+ request_context = {
+ 'operation': 'list_directory',
+ 'session_id': session_id,
+ 'path': remote_path,
+ 'request_id': request_id,
+ }
if not session_id:
- emit('error', {'error': 'Session ID required'})
+ emit('error', {'error': 'Session ID required', **request_context})
return
authorized = False
@@ -924,7 +933,7 @@ def handle_list_directory(data, current_user=None):
_t1 = _time.time()
if not authorized:
log_warning(f"list_directory unauthorized", session_id=session_id, user=current_user.username)
- emit('error', {'error': 'Unauthorized access to session'})
+ emit('error', {'error': 'Unauthorized access to session', **request_context})
return
files, error = sftp_handler.list_directory(session_id, remote_path)
@@ -933,19 +942,26 @@ def handle_list_directory(data, current_user=None):
if error:
log_warning(f"list_directory failed", path=remote_path, error=error,
auth_ms=int((_t1-_t0)*1000), sftp_ms=int((_t2-_t1)*1000))
- emit('error', {'error': f'Failed to list directory: {error}'})
+ emit('error', {'error': f'Failed to list directory: {error}', **request_context})
else:
log_info(f"list_directory OK", path=remote_path, files=len(files),
auth_ms=int((_t1-_t0)*1000), sftp_ms=int((_t2-_t1)*1000))
emit('directory_listing', {
'session_id': session_id,
'path': remote_path,
- 'files': files
+ 'files': files,
+ 'request_id': request_id,
})
except Exception as e:
log_error(f"list_directory exception", error=str(e), elapsed_ms=int((_time.time()-_t0)*1000))
- emit('error', {'error': 'Failed to list directory'})
+ emit('error', {
+ 'error': 'Failed to list directory',
+ 'operation': 'list_directory',
+ 'session_id': payload.get('session_id'),
+ 'path': payload.get('remote_path', '.'),
+ 'request_id': payload.get('request_id'),
+ })
@socketio.on('set_theme')
@socket_login_required
@@ -1351,6 +1367,73 @@ def verify_session_ownership(session_id, user_id):
return False
+@socketio.on('request_session_insights')
+@socket_login_required
+def handle_request_session_insights(data, current_user=None):
+ """Return one bounded Linux sample for an owned active SSH session."""
+ session_id = data.get('session_id') if isinstance(data, dict) else None
+ request_id = data.get('request_id') if isinstance(data, dict) else None
+
+ valid_identifiers = (
+ isinstance(session_id, str)
+ and 0 < len(session_id) <= 128
+ and isinstance(request_id, str)
+ and 0 < len(request_id) <= 128
+ )
+ safe_session_id = session_id if valid_identifiers else ''
+ safe_request_id = request_id if valid_identifiers else ''
+
+ def emit_unavailable():
+ emit('session_insights', {
+ 'success': False,
+ 'session_id': safe_session_id,
+ 'request_id': safe_request_id,
+ 'error': 'Session insights unavailable',
+ })
+
+ if not valid_identifiers:
+ emit_unavailable()
+ return
+
+ if check_socket_rate_limit(
+ current_user.id,
+ 'session_insights',
+ session_insights.REQUEST_RATE_LIMIT):
+ emit_unavailable()
+ return
+
+ if not verify_session_ownership(session_id, current_user.id):
+ log_warning(
+ 'Unauthorized session insights request',
+ user_id=current_user.id,
+ session_id=session_id,
+ )
+ emit_unavailable()
+ return
+
+ try:
+ stats, error = session_insights.collect_linux_stats(session_id)
+ except Exception as exc:
+ log_warning(
+ 'Session insights collection failed',
+ session_id=session_id,
+ error_type=type(exc).__name__,
+ )
+ emit_unavailable()
+ return
+
+ if error or stats is None:
+ emit_unavailable()
+ return
+
+ emit('session_insights', {
+ 'success': True,
+ 'session_id': session_id,
+ 'request_id': request_id,
+ 'stats': stats,
+ })
+
+
@socketio.on('prepare_transfer')
@socket_login_required
def handle_prepare_transfer(data, current_user=None):
@@ -1652,16 +1735,23 @@ def handle_get_home_directory(data, current_user=None):
import time as _time
_t0 = _time.time()
try:
- session_id = data.get('session_id')
+ payload = data if isinstance(data, dict) else {}
+ session_id = payload.get('session_id')
+ request_id = payload.get('request_id')
+ request_context = {
+ 'operation': 'get_home_directory',
+ 'session_id': session_id,
+ 'request_id': request_id,
+ }
if not session_id:
- emit('error', {'error': 'Session ID required'})
+ emit('error', {'error': 'Session ID required', **request_context})
return
if not verify_session_ownership(session_id, current_user.id):
conn_info = connection_pool.temp_connection_pool.get_connection_info(session_id)
if not conn_info or conn_info['user_id'] != str(current_user.id):
- emit('error', {'error': 'Unauthorized access'})
+ emit('error', {'error': 'Unauthorized access', **request_context})
return
_t1 = _time.time()
@@ -1671,16 +1761,25 @@ def handle_get_home_directory(data, current_user=None):
if error:
log_warning(f"get_home_directory failed", error=error,
auth_ms=int((_t1-_t0)*1000), sftp_ms=int((_t2-_t1)*1000))
- emit('error', {'error': f'Failed to get home directory: {error}'})
+ emit('error', {'error': f'Failed to get home directory: {error}', **request_context})
else:
log_info(f"get_home_directory OK", path=home_path,
auth_ms=int((_t1-_t0)*1000), sftp_ms=int((_t2-_t1)*1000))
- emit('home_directory', {'session_id': session_id, 'path': home_path})
+ emit('home_directory', {
+ 'session_id': session_id,
+ 'path': home_path,
+ 'request_id': request_id,
+ })
except Exception as e:
log_error(f"get_home_directory exception", error=str(e),
elapsed_ms=int((_time.time()-_t0)*1000))
- emit('error', {'error': 'Failed to get home directory'})
+ emit('error', {
+ 'error': 'Failed to get home directory',
+ 'operation': 'get_home_directory',
+ 'session_id': payload.get('session_id'),
+ 'request_id': payload.get('request_id'),
+ })
@socketio.on('check_exists')
@socket_login_required
diff --git a/assets/session-workspace.png b/assets/session-workspace.png
new file mode 100644
index 0000000..5df906b
Binary files /dev/null and b/assets/session-workspace.png differ
diff --git a/site/index.html b/site/index.html
index 7222ae4..7fc7129 100644
--- a/site/index.html
+++ b/site/index.html
@@ -87,8 +87,8 @@
The daily server workspace, in one tab.
Keep hosts, usernames, authentication choices, jump hosts, and optional post-connect commands ready for the next session.
Jump hosts
Recent connections
Command sets
- 02
Terminal workspace
Work across tabs and split panes, search terminal output, broadcast input, and restore live sessions after refresh.
- 03
SFTP file manager
Browse two locations side by side, transfer files, preview content, edit text, and move data directly between servers.
+ 02
Terminal workspace
Work across tabs and split panes, or focus one Linux server with its terminal, live resource status, files, and notes together.
+ 03
SFTP file manager
Open SFTP beside the active terminal or use the full dual-pane manager for previews, editing, and server-to-server transfers.
04
Identity options
Use local accounts with bcrypt passwords, optional passkeys, recovery codes, or optional OpenID Connect with PKCE.
05
Operations built in
Manage users, review structured audit events, control registration, and create or restore verified backups.
@@ -98,12 +98,12 @@
The daily server workspace, in one tab.
-
One workspace
Terminal work and file work stay together.
-
Open several SSH sessions, arrange panes for visibility, then switch to the SFTP workspace without leaving the browser.
+
One workspace
Your active server stays in context.
+
Keep one Linux terminal, its SFTP files, live resource status, and notes visible together. Switch to two or four SSH panes when the task needs more hosts.
-
Terminal workspaceSplit panes and session tools
-
+
Active server workspaceSSH + SFTP + live Linux status