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) +

+ Single-session workspace with SSH terminal, embedded SFTP browser, live Linux server statistics, and notepad +

+

Split Panes

### 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.

-
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
- WebSSH terminal workspace using a split-pane layout +
Active server workspaceSSH + SFTP + live Linux status
+ WebSSH active server workspace with SSH terminal, SFTP files, Linux resource status, and notes
diff --git a/static/css/session-workspace.css b/static/css/session-workspace.css new file mode 100644 index 0000000..1c1e8b1 --- /dev/null +++ b/static/css/session-workspace.css @@ -0,0 +1,343 @@ +.session-sftp-toggle .material-icons { + font-size: 17px; +} + +.session-sftp-toggle:disabled { + cursor: not-allowed; + opacity: 0.4; +} + +.session-sftp-toggle[aria-pressed="true"] { + color: var(--accent-primary); + background: var(--bg-active); + border-color: var(--accent-primary); +} + +.session-main-split { + display: grid; + grid-template-columns: minmax(0, 1fr); + width: 100%; + height: 100%; + min-width: 0; + min-height: 0; +} + +.session-main-split.sftp-open { + grid-template-columns: minmax(0, 1fr) clamp(360px, 38%, 520px); +} + +.session-files-panel { + min-width: 0; + min-height: 0; + margin: 16px 16px 16px 0; + border: 1px solid var(--border-color); + border-radius: 10px; + background: var(--bg-secondary); + box-shadow: var(--shadow-sm); + display: flex; + flex-direction: column; + overflow: hidden; +} + +.session-panel-header, +.session-insights-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.session-panel-header { + padding: 14px 16px 10px; +} + +.session-panel-eyebrow { + margin-bottom: 4px; + color: var(--text-muted); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.session-panel-title { + display: flex; + align-items: center; + gap: 7px; + color: var(--text-primary); + font-size: 15px; + font-weight: 700; +} + +.session-panel-title .material-icons { + color: var(--accent-primary); + font-size: 19px; +} + +.session-panel-close { + width: 30px; + height: 30px; + border: 1px solid transparent; + border-radius: 6px; + background: transparent; + color: var(--text-secondary); + cursor: pointer; + font-size: 21px; + line-height: 1; +} + +.session-panel-close:hover { + border-color: var(--border-color); + background: var(--bg-hover); + color: var(--text-primary); +} + +.session-files-mount { + display: flex; + flex: 1; + min-width: 0; + min-height: 0; + overflow: hidden; +} + +.session-files-mount > .fm-embedded-mode { + display: flex; + flex: 1; + min-width: 0; + min-height: 0; + flex-direction: column; + gap: 10px; + padding: 0 12px 12px; + overflow: hidden; +} + +.fm-embedded-mode .fm-panes { + grid-template-columns: minmax(0, 1fr); +} + +.fm-embedded-mode #fmRightPane, +.fm-embedded-mode #fmPaneTabs, +.fm-embedded-mode #fmTransfer, +.fm-embedded-mode #fmLeftPane > .fm-pane-header, +.fm-embedded-mode #fmMobileUpload { + display: none !important; +} + +.fm-embedded-mode #fmLeftPane { + display: flex !important; +} + +.fm-embedded-mode .fm-toolbar-center { + display: none; +} + +.fm-embedded-upload { + display: none !important; +} + +.fm-embedded-mode .fm-embedded-upload { + display: inline-flex !important; +} + +.fm-embedded-mode .fm-toolbar { + flex-wrap: wrap; + padding: 8px; +} + +.fm-embedded-mode .fm-toolbar-left, +.fm-embedded-mode .fm-toolbar-right { + flex: 1 1 auto; +} + +.fm-embedded-mode .fm-toolbar-right { + justify-content: flex-end; +} + +.notepad-panel { + min-height: 0; +} + +.notepad-section { + display: flex; + flex: 1; + min-height: 0; + flex-direction: column; + border-top: 1px solid var(--border-color); +} + +.session-insights-card { + flex: 0 0 auto; + padding: 14px; + background: linear-gradient(145deg, var(--bg-secondary), var(--bg-tertiary)); +} + +.session-insights-host { + max-width: 180px; + overflow: hidden; + color: var(--text-primary); + font-family: var(--font-mono); + font-size: 12px; + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; +} + +.session-insights-state { + padding: 3px 7px; + border: 1px solid var(--border-color); + border-radius: 999px; + color: var(--text-muted); + font-size: 9px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.session-insights-state.ready { + border-color: color-mix(in srgb, var(--success-color, #3fb950) 55%, var(--border-color)); + color: var(--success-color, #3fb950); +} + +.session-insights-state.stale, +.session-insights-state.loading { + color: var(--warning-color, #d29922); +} + +.session-insights-grid { + display: grid; + grid-template-columns: 88px minmax(0, 1fr); + align-items: center; + gap: 10px; + margin: 13px 0; +} + +.session-cpu-gauge { + display: grid; + width: 82px; + height: 82px; + padding: 7px; + border-radius: 50%; + background: conic-gradient(var(--gauge-color, var(--accent-primary)) calc(var(--value) * 1%), var(--bg-hover) 0); + place-items: center; +} + +.session-cpu-gauge::before { + content: ''; + grid-area: 1 / 1; + width: 100%; + height: 100%; + border-radius: 50%; + background: var(--bg-secondary); +} + +.session-cpu-gauge > div { + z-index: 1; + grid-area: 1 / 1; + text-align: center; +} + +.session-cpu-gauge strong { + display: block; + color: var(--text-primary); + font-family: var(--font-mono); + font-size: 17px; +} + +.session-cpu-gauge span { + color: var(--text-muted); + font-size: 9px; + font-weight: 700; + letter-spacing: 0.07em; +} + +.session-cpu-chart { + width: 100%; + height: 72px; +} + +.session-resource { + margin-top: 10px; +} + +.session-resource > div:first-child { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 5px; + color: var(--text-secondary); + font-size: 10px; + font-weight: 600; +} + +.session-resource strong { + color: var(--text-primary); + font-family: var(--font-mono); + font-size: 10px; + font-weight: 500; +} + +.session-resource-track { + height: 6px; + overflow: hidden; + border-radius: 999px; + background: var(--bg-hover); +} + +.session-resource-track span { + display: block; + width: 0; + height: 100%; + border-radius: inherit; + background: var(--accent-primary); + transition: width 0.35s ease; +} + +.session-resource.warning .session-resource-track span, +.session-cpu-gauge.warning { + --gauge-color: var(--warning-color, #d29922); +} + +.session-resource.warning .session-resource-track span { + background: var(--warning-color, #d29922); +} + +.session-resource.critical .session-resource-track span, +.session-cpu-gauge.critical { + --gauge-color: var(--danger-color, #f85149); +} + +.session-resource.critical .session-resource-track span { + background: var(--danger-color, #f85149); +} + +.session-insights-meta { + display: flex; + justify-content: space-between; + gap: 8px; + margin-top: 12px; + color: var(--text-muted); + font-size: 9px; +} + +@media (max-width: 1100px) { + .session-main-split.sftp-open { + grid-template-columns: minmax(0, 1fr) 360px; + } +} + +@media (max-width: 850px) { + .session-sftp-toggle, + .session-files-panel { + display: none !important; + } + + .session-main-split, + .session-main-split.sftp-open { + grid-template-columns: minmax(0, 1fr); + } + + .session-insights-card { + display: none; + } +} diff --git a/static/js/app.js b/static/js/app.js index 38dfc50..81f3766 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -1022,7 +1022,7 @@ }); socket.on('error', (data) => { - if (window.sftpFileManager && window.sftpFileManager.isOpen) return; + if (window.sftpFileManager?.handlesSocketError?.(data)) return; showNotification(`Error: ${data.error}`, 'error'); }); @@ -2294,6 +2294,12 @@ setupResizeHandle(); TerminalSearch.init(); FilePreview.init(); + window.sessionWorkspace = window.SessionWorkspaceUI?.init({ + socket: window.socket, + sessionManager: SessionManager, + terminalManager: TerminalManager, + document, + }); shortcutsModal = setupShortcutsModal(); openShortcuts = () => { diff --git a/static/js/i18n.js b/static/js/i18n.js index a925b55..338e111 100644 --- a/static/js/i18n.js +++ b/static/js/i18n.js @@ -340,6 +340,7 @@ const translations = { 'fm.newFolder': 'New Folder', 'fm.transfer': 'Transfer', 'fm.download': 'Download', + 'fm.upload': 'Upload', 'fm.rename': 'Rename', 'fm.delete': 'Delete', 'fm.downloadOnlySSH': 'Download only works for SSH sources', @@ -893,6 +894,7 @@ const translations = { 'fm.newFolder': 'Thư mục mới', 'fm.transfer': 'Truyền', 'fm.download': 'Tải xuống', + 'fm.upload': 'Tải lên', 'fm.rename': 'Đổi tên', 'fm.delete': 'Xóa', 'fm.downloadOnlySSH': 'Tải xuống chỉ hoạt động với nguồn SSH', @@ -1462,6 +1464,7 @@ const translations = { 'fm.newFolder': 'Neuer Ordner', 'fm.transfer': 'Übertragen', 'fm.download': 'Herunterladen', + 'fm.upload': 'Hochladen', 'fm.rename': 'Umbenennen', 'fm.delete': 'Löschen', 'fm.downloadOnlySSH': 'Download funktioniert nur bei SSH-Quellen', @@ -2022,6 +2025,7 @@ const translations = { 'fm.newFolder': 'Nouveau dossier', 'fm.transfer': 'Transférer', 'fm.download': 'Télécharger', + 'fm.upload': 'Téléverser', 'fm.rename': 'Renommer', 'fm.delete': 'Supprimer', 'fm.downloadOnlySSH': 'Le téléchargement ne fonctionne que pour les sources SSH', @@ -2573,6 +2577,7 @@ const translations = { 'fm.newFolder': 'Nueva carpeta', 'fm.transfer': 'Transferir', 'fm.download': 'Descargar', + 'fm.upload': 'Subir', 'fm.rename': 'Renombrar', 'fm.delete': 'Eliminar', 'fm.downloadOnlySSH': 'La descarga solo funciona para fuentes SSH', @@ -3115,6 +3120,7 @@ const translations = { 'fm.newFolder': '新建文件夹', 'fm.transfer': '传输', 'fm.download': '下载', + 'fm.upload': '上传', 'fm.rename': '重命名', 'fm.delete': '删除', 'fm.downloadOnlySSH': '下载功能仅适用于 SSH 源', diff --git a/static/js/session-files-panel.js b/static/js/session-files-panel.js new file mode 100644 index 0000000..dcdfa2a --- /dev/null +++ b/static/js/session-files-panel.js @@ -0,0 +1,49 @@ +(function (root, factory) { + const api = factory(); + if (typeof module === 'object' && module.exports) { + module.exports = api; + } + if (root && root.document) { + root.SessionFilesPanelModule = api; + } +}(typeof globalThis !== 'undefined' ? globalThis : this, function () { + 'use strict'; + + function createController(options = {}) { + const manager = options.manager; + const container = options.container; + if (!manager) { + throw new Error('SFTPFileManager instance is required'); + } + if (typeof manager.openEmbedded !== 'function') { + throw new Error('SFTPFileManager must support openEmbedded'); + } + if (!container) { + throw new Error('Embedded SFTP container is required'); + } + + return { + open(sessionId, session = {}) { + manager.openEmbedded(container, sessionId, session); + }, + + follow(sessionId, session = {}) { + manager.followEmbedded(sessionId, session); + }, + + close() { + manager.closeEmbedded(); + }, + + isOpen() { + return Boolean(manager.isEmbeddedOpen?.()); + }, + + setDisconnected(sessionId) { + manager.handleEmbeddedDisconnect?.(sessionId); + }, + }; + } + + return { createController }; +})); diff --git a/static/js/session-insights.js b/static/js/session-insights.js new file mode 100644 index 0000000..b278ee3 --- /dev/null +++ b/static/js/session-insights.js @@ -0,0 +1,202 @@ +(function (root, factory) { + const api = factory(); + if (typeof module === 'object' && module.exports) { + module.exports = api; + } + if (root && root.document) { + root.SessionInsightsModule = api; + } +}(typeof globalThis !== 'undefined' ? globalThis : this, function () { + 'use strict'; + + const POLL_INTERVAL_MS = 4000; + const RESPONSE_TIMEOUT_MS = 3500; + const HISTORY_LIMIT = 15; + + function calculateCpuPercent(previous, current) { + if (!Array.isArray(previous) || !Array.isArray(current)) return null; + if (previous.length < 4 || current.length < 4) return null; + const length = Math.min(previous.length, current.length); + let totalDelta = 0; + for (let index = 0; index < length; index += 1) { + const delta = Number(current[index]) - Number(previous[index]); + if (!Number.isFinite(delta) || delta < 0) return null; + totalDelta += delta; + } + if (totalDelta <= 0) return null; + const idleDelta = ( + Number(current[3]) - Number(previous[3]) + + (length > 4 ? Number(current[4]) - Number(previous[4]) : 0) + ); + if (!Number.isFinite(idleDelta) || idleDelta < 0) return null; + const percent = Math.round(((totalDelta - idleDelta) / totalDelta) * 100); + return Math.max(0, Math.min(100, percent)); + } + + function formatKib(value) { + const kib = Number(value); + if (!Number.isFinite(kib) || kib < 0) return '—'; + const mib = kib / 1024; + if (mib < 1024) return `${mib.toFixed(1)} MB`; + return `${(mib / 1024).toFixed(1)} GB`; + } + + function severityForPercent(value) { + const percent = Number(value); + if (percent >= 90) return 'critical'; + if (percent >= 75) return 'warning'; + return 'normal'; + } + + function createController(options) { + const socket = options.socket; + const render = options.render || (() => {}); + const setIntervalFn = options.setIntervalFn || setInterval; + const clearIntervalFn = options.clearIntervalFn || clearInterval; + const setTimeoutFn = options.setTimeoutFn || setTimeout; + const clearTimeoutFn = options.clearTimeoutFn || clearTimeout; + + let sessionId = null; + let connected = false; + let visible = true; + let intervalId = null; + let responseTimeoutId = null; + let pendingRequest = null; + let requestCounter = 0; + let failureCount = 0; + let lastGood = null; + const previousCpuBySession = new Map(); + const historyBySession = new Map(); + + function currentState(status, extra = {}) { + return { + status, + sessionId, + failureCount, + ...extra, + }; + } + + function clearResponseTimeout() { + if (responseTimeoutId !== null) { + clearTimeoutFn(responseTimeoutId); + responseTimeoutId = null; + } + } + + function clearPolling() { + if (intervalId !== null) { + clearIntervalFn(intervalId); + intervalId = null; + } + clearResponseTimeout(); + pendingRequest = null; + } + + function renderFailure() { + failureCount += 1; + const status = failureCount >= 3 ? 'unavailable' : 'stale'; + render(currentState(status, lastGood ? { ...lastGood } : {})); + } + + function requestSample() { + if (!visible || !connected || !sessionId || pendingRequest) return; + requestCounter += 1; + const requestId = `insights-${requestCounter}`; + pendingRequest = { sessionId, requestId }; + socket.emit('request_session_insights', { + session_id: sessionId, + request_id: requestId, + }); + responseTimeoutId = setTimeoutFn(() => { + if (!pendingRequest || pendingRequest.requestId !== requestId) return; + pendingRequest = null; + responseTimeoutId = null; + renderFailure(); + }, RESPONSE_TIMEOUT_MS); + } + + function startPolling() { + if (!visible || !connected || !sessionId) return; + requestSample(); + intervalId = setIntervalFn(requestSample, POLL_INTERVAL_MS); + } + + function handleResponse(payload) { + if (!pendingRequest || !payload) return; + if ( + payload.session_id !== pendingRequest.sessionId + || payload.request_id !== pendingRequest.requestId + || payload.session_id !== sessionId + ) { + return; + } + + pendingRequest = null; + clearResponseTimeout(); + if (!payload.success || !payload.stats) { + renderFailure(); + return; + } + + failureCount = 0; + const stats = payload.stats; + const previousCpu = previousCpuBySession.get(sessionId) || null; + const cpuPercent = calculateCpuPercent(previousCpu, stats.cpu); + previousCpuBySession.set(sessionId, Array.isArray(stats.cpu) ? stats.cpu.slice() : null); + + const history = historyBySession.get(sessionId) || []; + if (cpuPercent !== null) history.push(cpuPercent); + while (history.length > HISTORY_LIMIT) history.shift(); + historyBySession.set(sessionId, history); + + lastGood = { + stats, + cpuPercent, + cpuHistory: history.slice(), + }; + render(currentState('ready', { ...lastGood })); + } + + socket.on('session_insights', handleResponse); + + return { + setSession(nextSessionId, isConnected) { + clearPolling(); + sessionId = typeof nextSessionId === 'string' && nextSessionId + ? nextSessionId + : null; + connected = Boolean(isConnected && sessionId); + failureCount = 0; + lastGood = null; + render(currentState(connected ? 'loading' : 'disconnected')); + startPolling(); + }, + + setVisible(nextVisible) { + const normalized = Boolean(nextVisible); + if (visible === normalized) return; + visible = normalized; + clearPolling(); + if (visible) { + render(currentState(connected ? 'loading' : 'disconnected')); + startPolling(); + } + }, + + destroy() { + clearPolling(); + socket.off?.('session_insights', handleResponse); + }, + }; + } + + return { + POLL_INTERVAL_MS, + RESPONSE_TIMEOUT_MS, + calculateCpuPercent, + formatKib, + severityForPercent, + createController, + }; +})); diff --git a/static/js/session-manager.js b/static/js/session-manager.js index 359414b..14c4ec0 100644 --- a/static/js/session-manager.js +++ b/static/js/session-manager.js @@ -341,6 +341,7 @@ const SessionManager = { } else { this.activeSessionId = null; this.updateSessionMeta(null); + this.notifyWorkspaceChange(); } }, @@ -493,6 +494,7 @@ const SessionManager = { } else if (status === 'connected') { this.hideReconnectOverlay(sessionId); } + this.notifyWorkspaceChange(); }, createPendingConnection(requestId, host, username, port) { @@ -710,6 +712,18 @@ const SessionManager = { return document.getElementById('terminalGrid'); }, + notifyWorkspaceChange() { + if (!window.dispatchEvent || !window.CustomEvent) { + return; + } + window.dispatchEvent(new CustomEvent('session-workspace-change', { + detail: { + layout: this.layout, + sessionId: this.activeSessionId, + }, + })); + }, + setSplitLayout(layout) { const grid = this.ensureTerminalGrid(); if (!grid) { @@ -757,6 +771,7 @@ const SessionManager = { } this.setActivePane(this.activePaneIndex); this.updateSplitControls(); + this.notifyWorkspaceChange(); }, refreshEmptyPanes() { @@ -890,6 +905,7 @@ const SessionManager = { } }, 50); } + this.notifyWorkspaceChange(); }, focusActivePane() { diff --git a/static/js/session-workspace-ui.js b/static/js/session-workspace-ui.js new file mode 100644 index 0000000..62f6607 --- /dev/null +++ b/static/js/session-workspace-ui.js @@ -0,0 +1,209 @@ +(function (root) { + 'use strict'; + + function formatUptime(seconds) { + const total = Number(seconds); + if (!Number.isFinite(total) || total < 0) return 'Uptime --'; + const days = Math.floor(total / 86400); + const hours = Math.floor((total % 86400) / 3600); + if (days > 0) return `Uptime ${days}d ${hours}h`; + const minutes = Math.floor((total % 3600) / 60); + return `Uptime ${hours}h ${minutes}m`; + } + + function percent(used, total) { + const usedValue = Number(used); + const totalValue = Number(total); + if (!Number.isFinite(usedValue) || !Number.isFinite(totalValue) || totalValue <= 0) { + return 0; + } + return Math.max(0, Math.min(100, Math.round((usedValue / totalValue) * 100))); + } + + function drawCpuHistory(canvas, history) { + if (!canvas) return; + const context = canvas.getContext('2d'); + if (!context) return; + const width = canvas.width; + const height = canvas.height; + context.clearRect(0, 0, width, height); + + const styles = getComputedStyle(canvas); + const lineColor = styles.getPropertyValue('--accent-primary').trim() || '#58a6ff'; + const gridColor = styles.getPropertyValue('--border-color').trim() || 'rgba(127,127,127,.25)'; + context.strokeStyle = gridColor; + context.lineWidth = 1; + [0.25, 0.5, 0.75].forEach(ratio => { + context.beginPath(); + context.moveTo(0, Math.round(height * ratio) + 0.5); + context.lineTo(width, Math.round(height * ratio) + 0.5); + context.stroke(); + }); + + const values = Array.isArray(history) ? history : []; + if (values.length < 2) return; + context.strokeStyle = lineColor; + context.lineWidth = 2; + context.lineJoin = 'round'; + context.lineCap = 'round'; + context.beginPath(); + values.forEach((value, index) => { + const x = (index / (values.length - 1)) * (width - 4) + 2; + const y = height - ((Math.max(0, Math.min(100, value)) / 100) * (height - 8)) - 4; + if (index === 0) context.moveTo(x, y); + else context.lineTo(x, y); + }); + context.stroke(); + } + + function init(options) { + const socket = options.socket; + const sessionManager = options.sessionManager; + const terminalManager = options.terminalManager; + const documentRef = options.document || document; + const insightsModule = root.SessionInsightsModule; + const filesModule = root.SessionFilesPanelModule; + const workspaceModule = root.SessionWorkspaceModule; + const fileManager = root.getSFTPFileManager?.(); + if (!socket || !sessionManager || !insightsModule || !filesModule || !workspaceModule || !fileManager) { + return null; + } + + const elements = { + mainSplit: documentRef.getElementById('sessionMainSplit'), + filesPanel: documentRef.getElementById('sessionFilesPanel'), + toggle: documentRef.getElementById('sessionSftpToggleBtn'), + filesMount: documentRef.getElementById('sessionFilesMount'), + notepadPanel: documentRef.getElementById('notepadPanel'), + insightsHost: documentRef.getElementById('sessionInsightsHost'), + insightsState: documentRef.getElementById('sessionInsightsState'), + cpuGauge: documentRef.getElementById('sessionCpuGauge'), + cpuValue: documentRef.getElementById('sessionCpuValue'), + cpuChart: documentRef.getElementById('sessionCpuChart'), + ramResource: documentRef.getElementById('sessionRamResource'), + ramValue: documentRef.getElementById('sessionRamValue'), + ramBar: documentRef.getElementById('sessionRamBar'), + diskResource: documentRef.getElementById('sessionDiskResource'), + diskValue: documentRef.getElementById('sessionDiskValue'), + diskBar: documentRef.getElementById('sessionDiskBar'), + osValue: documentRef.getElementById('sessionOsValue'), + uptimeValue: documentRef.getElementById('sessionUptimeValue'), + }; + if (!elements.mainSplit || !elements.toggle || !elements.filesPanel || !elements.filesMount) return null; + + let filesController = null; + let coordinator = null; + + function setResource(element, bar, value) { + const severity = insightsModule.severityForPercent(value); + element.classList.remove('normal', 'warning', 'critical'); + element.classList.add(severity); + bar.style.width = `${value}%`; + } + + function renderInsights(state) { + const active = sessionManager.getSession(state.sessionId); + elements.insightsHost.textContent = active + ? `${active.username}@${active.host}` + : 'No active session'; + const labels = { + ready: 'Live', loading: 'Sampling', stale: 'Stale', + unavailable: 'Unavailable', disconnected: 'Offline', + }; + elements.insightsState.textContent = labels[state.status] || 'Offline'; + elements.insightsState.className = `session-insights-state ${state.status || ''}`; + if (!state.stats) { + if (state.status === 'disconnected' || state.status === 'unavailable') { + elements.cpuValue.textContent = '--'; + elements.cpuGauge.style.setProperty('--value', '0'); + elements.ramValue.textContent = '--'; + elements.diskValue.textContent = '--'; + elements.ramBar.style.width = '0%'; + elements.diskBar.style.width = '0%'; + elements.osValue.textContent = 'Linux only'; + elements.uptimeValue.textContent = state.status === 'unavailable' + ? 'Telemetry unavailable' + : 'Refreshes every 4s'; + drawCpuHistory(elements.cpuChart, []); + } + return; + } + + const cpu = state.cpuPercent; + elements.cpuValue.textContent = cpu === null ? '...' : `${cpu}%`; + elements.cpuGauge.style.setProperty('--value', String(cpu || 0)); + elements.cpuGauge.classList.remove('normal', 'warning', 'critical'); + elements.cpuGauge.classList.add(insightsModule.severityForPercent(cpu || 0)); + drawCpuHistory(elements.cpuChart, state.cpuHistory); + + const memoryPercent = percent(state.stats.memory.used_kib, state.stats.memory.total_kib); + const diskPercent = Number(state.stats.disk.percent) || 0; + elements.ramValue.textContent = `${insightsModule.formatKib(state.stats.memory.used_kib)} / ${insightsModule.formatKib(state.stats.memory.total_kib)}`; + elements.diskValue.textContent = `${insightsModule.formatKib(state.stats.disk.used_kib)} / ${insightsModule.formatKib(state.stats.disk.total_kib)}`; + setResource(elements.ramResource, elements.ramBar, memoryPercent); + setResource(elements.diskResource, elements.diskBar, diskPercent); + elements.osValue.textContent = state.stats.os_name; + elements.uptimeValue.textContent = formatUptime(state.stats.uptime_seconds); + } + + filesController = filesModule.createController({ + manager: fileManager, + container: elements.filesMount, + }); + const insightsController = insightsModule.createController({ socket, render: renderInsights }); + coordinator = workspaceModule.createCoordinator({ + filesPanel: filesController, + insights: insightsController, + isDesktop: () => root.matchMedia('(min-width: 851px)').matches, + render(state) { + elements.toggle.disabled = !state.sftpEnabled; + elements.toggle.setAttribute('aria-pressed', String(state.sftpOpen)); + elements.mainSplit.classList.toggle('sftp-open', state.sftpOpen); + elements.filesPanel.classList.toggle('hidden', !state.sftpOpen); + root.requestAnimationFrame(() => terminalManager?.fitAllTerminals?.()); + }, + }); + + function sync() { + const activeId = sessionManager.getActiveSession(); + coordinator.update({ + layout: sessionManager.layout, + sessionId: activeId, + session: activeId ? sessionManager.getSession(activeId) : null, + }); + } + + elements.toggle.addEventListener('click', () => coordinator.toggleSftp()); + documentRef.getElementById('sessionFilesCloseBtn')?.addEventListener('click', () => coordinator.toggleSftp()); + root.addEventListener('session-sftp-request-close', () => { + if (coordinator.getState().sftpOpen) coordinator.toggleSftp(); + }); + + const desktopQuery = root.matchMedia('(min-width: 851px)'); + function syncInsightsVisibility() { + coordinator.setVisible( + documentRef.visibilityState !== 'hidden' + && desktopQuery.matches + && !elements.notepadPanel?.classList.contains('collapsed') + ); + } + root.addEventListener('session-workspace-change', sync); + documentRef.addEventListener('visibilitychange', syncInsightsVisibility); + desktopQuery.addEventListener?.('change', () => { + syncInsightsVisibility(); + sync(); + }); + if (elements.notepadPanel && root.MutationObserver) { + new MutationObserver(syncInsightsVisibility).observe(elements.notepadPanel, { + attributes: true, + attributeFilter: ['class'], + }); + } + root.addEventListener('themeChanged', () => drawCpuHistory(elements.cpuChart, [])); + syncInsightsVisibility(); + sync(); + return coordinator; + } + + root.SessionWorkspaceUI = { init, formatUptime }; +}(window)); diff --git a/static/js/session-workspace.js b/static/js/session-workspace.js new file mode 100644 index 0000000..bf27b19 --- /dev/null +++ b/static/js/session-workspace.js @@ -0,0 +1,112 @@ +(function (root, factory) { + const api = factory(); + if (typeof module === 'object' && module.exports) { + module.exports = api; + } + if (root && root.document) { + root.SessionWorkspaceModule = api; + } +}(typeof globalThis !== 'undefined' ? globalThis : this, function () { + 'use strict'; + + function createCoordinator(options) { + const filesPanel = options.filesPanel; + const insights = options.insights || {}; + const render = options.render || (() => {}); + const isDesktop = options.isDesktop || (() => true); + + let layout = 1; + let sessionId = null; + let session = null; + let sftpOpen = false; + let visible = true; + + function canOpenSftp() { + return Boolean( + layout === 1 + && isDesktop() + && sessionId + && session?.connected + ); + } + + function state() { + return { + layout, + sessionId, + connected: Boolean(session?.connected), + sftpOpen, + sftpEnabled: canOpenSftp(), + visible, + }; + } + + function renderState() { + render(state()); + } + + function closeSftp() { + if (!sftpOpen) return; + sftpOpen = false; + filesPanel?.close?.(); + } + + return { + update(next) { + const previousSessionId = sessionId; + const wasConnected = Boolean(session?.connected); + layout = [1, 2, 4].includes(next?.layout) ? next.layout : 1; + sessionId = typeof next?.sessionId === 'string' && next.sessionId + ? next.sessionId + : null; + session = next?.session || null; + const connected = Boolean(sessionId && session?.connected); + const sessionChanged = sessionId !== previousSessionId; + const connectionChanged = connected !== wasConnected; + + if (sessionChanged || connectionChanged) { + insights.setSession?.(sessionId, connected); + } + + if (layout !== 1 || !isDesktop() || !connected) { + if (sftpOpen && !connected && sessionId) { + filesPanel?.setDisconnected?.(sessionId); + } + closeSftp(); + } else if (sftpOpen && sessionChanged) { + filesPanel?.follow?.(sessionId, session); + } + + renderState(); + }, + + toggleSftp() { + if (sftpOpen) { + closeSftp(); + renderState(); + return false; + } + if (!canOpenSftp()) { + renderState(); + return false; + } + sftpOpen = true; + filesPanel?.open?.(sessionId, session); + renderState(); + return true; + }, + + setVisible(nextVisible) { + visible = Boolean(nextVisible); + insights.setVisible?.(visible); + renderState(); + }, + + getState() { + return state(); + }, + }; + } + + return { createCoordinator }; +})); diff --git a/static/js/sftp-file-manager.js b/static/js/sftp-file-manager.js index 743a4f6..1eea508 100644 --- a/static/js/sftp-file-manager.js +++ b/static/js/sftp-file-manager.js @@ -4,6 +4,8 @@ class SFTPFileManager { this.socket = window.socket; this.modal = null; this.isOpen = false; + this.displayMode = 'closed'; + this.embeddedContainer = null; this.browserFS = new BrowserFileSystem(); @@ -31,6 +33,7 @@ class SFTPFileManager { this.draggedItems = []; this.dragSource = null; + this.requestSequence = 0; this.init(); } @@ -47,7 +50,12 @@ class SFTPFileManager { hostInfo: null, loading: false, loadingTimeout: null, - error: null + error: null, + homePath: null, + pendingHomeRequestId: null, + pendingDirectoryRequestId: null, + pendingDirectoryPath: null, + autoHomeEligible: false }; } @@ -90,6 +98,9 @@ class SFTPFileManager {
+ @@ -247,6 +258,9 @@ class SFTPFileManager { document.body.appendChild(modal); this.modal = modal; + this.modalContent = modal.querySelector('.modal-content'); + this.modalBody = modal.querySelector('.modal-body'); + this.actionSheet = modal.querySelector('.fm-action-sheet'); this.createQuickConnectModal(); @@ -438,9 +452,11 @@ class SFTPFileManager { const mobileUpload = document.getElementById('fmMobileUpload'); const mobileUploadInput = document.getElementById('fmMobileUploadInput'); + const embeddedUpload = document.getElementById('fmEmbeddedUpload'); if (mobileUpload && mobileUploadInput) { mobileUpload.addEventListener('click', () => mobileUploadInput.click()); mobileUploadInput.addEventListener('change', (e) => this.handleMobileUpload(e)); + embeddedUpload?.addEventListener('click', () => mobileUploadInput.click()); } document.querySelectorAll('.fm-action-sheet-item').forEach(item => { @@ -504,7 +520,9 @@ class SFTPFileManager { ['left', 'right'].forEach(pane => { const state = this.panes[pane]; if (state.type === 'ssh' && - (state.sessionId === data.session_id || state.connectionId === data.session_id)) { + (state.sessionId === data.session_id || state.connectionId === data.session_id) && + state.pendingDirectoryRequestId === data.request_id && + state.pendingDirectoryPath === data.path) { if (state.loadingTimeout) { clearTimeout(state.loadingTimeout); state.loadingTimeout = null; @@ -513,6 +531,8 @@ class SFTPFileManager { state.path = data.path; state.loading = false; state.error = null; + state.pendingDirectoryRequestId = null; + state.pendingDirectoryPath = null; this.updatePathInput(pane, data.path); this.renderPane(pane); } @@ -520,15 +540,17 @@ class SFTPFileManager { }); this.socket.on('home_directory', (data) => { + if (!this.isOpen) return; ['left', 'right'].forEach(pane => { const state = this.panes[pane]; if (state.type === 'ssh' && - (state.sessionId === data.session_id || state.connectionId === data.session_id)) { - if (!state.homePath) { - state.homePath = data.path; - if (state.path === '/') { - this.navigatePaneTo(pane, data.path); - } + (state.sessionId === data.session_id || state.connectionId === data.session_id) && + state.pendingHomeRequestId === data.request_id) { + state.pendingHomeRequestId = null; + state.homePath = data.path; + if (state.autoHomeEligible && state.path === '/') { + state.autoHomeEligible = false; + this.navigatePaneTo(pane, data.path); } } }); @@ -584,23 +606,28 @@ class SFTPFileManager { }); this.socket.on('error', (data) => { + if (!this.handlesSocketError(data)) return; const errorMsg = data.error || data.message || 'Unknown error'; console.error('[FM] SFTP Error received:', errorMsg, data); ['left', 'right'].forEach(pane => { const state = this.panes[pane]; - if (state.loading && state.type === 'ssh') { + if (state.loading && state.type === 'ssh' && + (state.sessionId === data.session_id || state.connectionId === data.session_id) && + state.pendingDirectoryRequestId === data.request_id && + state.pendingDirectoryPath === data.path) { if (state.loadingTimeout) { clearTimeout(state.loadingTimeout); state.loadingTimeout = null; } state.loading = false; state.error = errorMsg; + state.pendingDirectoryRequestId = null; + state.pendingDirectoryPath = null; this.renderPane(pane); + this.showNotification(errorMsg, 'error'); } }); - - this.showNotification(errorMsg, 'error'); }); this.socket.on('file_exists_result', (data) => { @@ -612,59 +639,87 @@ class SFTPFileManager { } setupKeyboardShortcuts() { - document.addEventListener('keydown', (e) => { - if (!this.isOpen) return; + document.addEventListener('keydown', e => this.handleKeyboardShortcut(e)); + } - if (e.key === 'Escape') { - this.closeContextMenu(); - if (!this.hasOpenDialogs()) { + handlesSocketError(data) { + if (!this.isOpen || data?.operation !== 'list_directory') return false; + return ['left', 'right'].some(pane => { + const state = this.panes[pane]; + return state.type === 'ssh' + && (state.sessionId === data.session_id || state.connectionId === data.session_id) + && state.pendingDirectoryRequestId === data.request_id + && state.pendingDirectoryPath === data.path; + }); + } + + handleKeyboardShortcut(e) { + if (!this.isOpen) return; + + if (e.key === 'Escape') { + this.closeContextMenu(); + if (!this.hasOpenDialogs()) { + if (this.displayMode === 'embedded') { + window.dispatchEvent?.(new CustomEvent('session-sftp-request-close')); + if (this.displayMode === 'embedded') this.closeEmbedded(); + } else { this.close(); } } + } - if (e.key === 'Tab' && !e.target.matches('input, textarea, select')) { - e.preventDefault(); - this.setActivePane(this.activePane === 'left' ? 'right' : 'left'); - } + if ( + e.key === 'Tab' + && this.displayMode !== 'embedded' + && !e.target.matches('input, textarea, select') + ) { + e.preventDefault(); + this.setActivePane(this.activePane === 'left' ? 'right' : 'left'); + } - if (e.ctrlKey && e.key === 'a' && !e.target.matches('input, textarea')) { - e.preventDefault(); - this.selectAll(); - } + if (e.ctrlKey && e.key === 'a' && !e.target.matches('input, textarea')) { + e.preventDefault(); + this.selectAll(); + } - if (e.key === 'Delete' && !e.target.matches('input, textarea')) { - e.preventDefault(); - this.deleteSelected(); - } + if (e.key === 'Delete' && !e.target.matches('input, textarea')) { + e.preventDefault(); + this.deleteSelected(); + } - if (e.key === 'F5') { - e.preventDefault(); - this.executeTransfer(); - } + if (e.key === 'F5') { + e.preventDefault(); + if (this.displayMode === 'embedded') this.refreshPane(this.activePane); + else this.executeTransfer(); + } - if (e.key === 'F7') { - e.preventDefault(); - this.createNewFolder(); - } + if (e.key === 'F7') { + e.preventDefault(); + this.createNewFolder(); + } - if (e.key === 'F2') { - e.preventDefault(); - this.renameSelected(); - } + if (e.key === 'F2') { + e.preventDefault(); + this.renameSelected(); + } - if (e.key === 'Enter' && !e.target.matches('input, textarea')) { - e.preventDefault(); - const state = this.panes[this.activePane]; - if (state.selected.size === 1) { - const index = Array.from(state.selected)[0]; - this.handleItemDblClick(this.activePane, index); - } + if (e.key === 'Enter' && !e.target.matches('input, textarea')) { + e.preventDefault(); + const state = this.panes[this.activePane]; + if (state.selected.size === 1) { + const index = Array.from(state.selected)[0]; + this.handleItemDblClick(this.activePane, index); } - }); + } } open() { + if (this.displayMode === 'embedded') { + window.dispatchEvent?.(new CustomEvent('session-sftp-request-close')); + if (this.displayMode === 'embedded') this.closeEmbedded(); + } this.isOpen = true; + this.displayMode = 'modal'; if (window.ModalManager) { window.ModalManager.open(this.modal); } else { @@ -706,7 +761,12 @@ class SFTPFileManager { } close() { + if (this.displayMode === 'embedded') { + this.closeEmbedded(); + return; + } this.isOpen = false; + this.displayMode = 'closed'; if (window.ModalManager) { window.ModalManager.close(this.modal); } else { @@ -732,6 +792,70 @@ class SFTPFileManager { } } + async openEmbedded(container, sessionId, session = {}) { + if (!container || !sessionId) return false; + if (this.displayMode === 'modal') this.close(); + + this.isOpen = true; + this.displayMode = 'embedded'; + this.embeddedContainer = container; + this.modalBody.classList.add('fm-embedded-mode'); + this.applyTranslations(); + container.replaceChildren(this.modalBody); + this.updateSessionLists(); + + const sessionRecord = { + ...session, + id: sessionId, + connected: session.connected !== false, + }; + const index = this.availableSessions.findIndex(item => item.id === sessionId); + if (index >= 0) this.availableSessions[index] = sessionRecord; + else this.availableSessions.push(sessionRecord); + + this.setActivePane('left'); + await this.onSourceChange('left', `ssh:${sessionId}`); + return true; + } + + async followEmbedded(sessionId, session = {}) { + if (this.displayMode !== 'embedded' || !sessionId) return false; + if (this.panes.left.sessionId === sessionId) { + this.panes.left.hostInfo = { + host: session.host, + username: session.username, + port: session.port, + }; + this.updatePaneBadge('left'); + return true; + } + return this.openEmbedded(this.embeddedContainer, sessionId, session); + } + + closeEmbedded() { + if (this.displayMode !== 'embedded') return; + this.closeContextMenu(); + this.resetPane('left'); + this.modalBody.classList.remove('fm-embedded-mode'); + this.modalContent.insertBefore(this.modalBody, this.actionSheet); + this.embeddedContainer = null; + this.displayMode = 'closed'; + this.isOpen = false; + } + + isEmbeddedOpen() { + return this.displayMode === 'embedded'; + } + + handleEmbeddedDisconnect(sessionId) { + if ( + this.displayMode === 'embedded' + && this.panes.left.sessionId === sessionId + ) { + this.resetPane('left'); + } + } + handleSessionDisconnected(sessionId) { ['left', 'right'].forEach(pane => { const state = this.panes[pane]; @@ -775,15 +899,22 @@ class SFTPFileManager { async onSourceChange(pane, value) { const state = this.panes[pane]; + if (value === 'quick-connect') { + this.pendingQuickConnectPane = pane; + this.openQuickConnect(); + const select = document.getElementById(`fm${this.capitalize(pane)}Source`); + if (select) { + select.value = state.type === 'ssh' ? `ssh:${state.sessionId || state.connectionId}` : ''; + } + return; + } + if (state.loadingTimeout) { clearTimeout(state.loadingTimeout); - state.loadingTimeout = null; } - - state.files = []; - state.selected.clear(); + Object.keys(state).forEach(key => delete state[key]); + Object.assign(state, this.createEmptyPaneState()); state.loading = true; - state.error = null; this.renderPane(pane); if (!value) { @@ -818,26 +949,20 @@ class SFTPFileManager { } this.updatePaneBadge(pane); - } else if (value === 'quick-connect') { - this.pendingQuickConnectPane = pane; - this.openQuickConnect(); - const select = document.getElementById(`fm${this.capitalize(pane)}Source`); - select.value = state.type === 'ssh' ? `ssh:${state.sessionId || state.connectionId}` : ''; - state.loading = false; - } else if (value.startsWith('ssh:')) { const sessionId = value.substring(4); state.type = 'ssh'; state.sessionId = sessionId; state.connectionId = null; + state.autoHomeEligible = true; const session = this.availableSessions.find(s => s.id === sessionId); if (session) { state.hostInfo = { host: session.host, username: session.username, port: session.port }; } - this.socket.emit('get_home_directory', { session_id: sessionId }); - this.socket.emit('list_directory', { session_id: sessionId, remote_path: '/' }); + this.requestHomeDirectory(pane, sessionId); + this.requestDirectory(pane, '/'); this.updatePaneBadge(pane); this.setLoadingTimeout(pane); @@ -849,19 +974,46 @@ class SFTPFileManager { state.type = 'ssh'; state.sessionId = null; state.connectionId = connectionId; + state.autoHomeEligible = true; if (qc) { state.hostInfo = { host: qc.host, username: qc.username, port: qc.port }; } - this.socket.emit('get_home_directory', { session_id: connectionId }); - this.socket.emit('list_directory', { session_id: connectionId, remote_path: '/' }); + this.requestHomeDirectory(pane, connectionId); + this.requestDirectory(pane, '/'); this.updatePaneBadge(pane); this.setLoadingTimeout(pane); } } + nextRequestId(pane, operation) { + this.requestSequence = (this.requestSequence || 0) + 1; + return `${pane}:${operation}:${this.requestSequence}`; + } + + requestHomeDirectory(pane, sessionId) { + const state = this.panes[pane]; + const requestId = this.nextRequestId(pane, 'home'); + state.pendingHomeRequestId = requestId; + this.socket.emit('get_home_directory', { session_id: sessionId, request_id: requestId }); + return requestId; + } + + requestDirectory(pane, path) { + const state = this.panes[pane]; + const requestId = this.nextRequestId(pane, 'directory'); + state.pendingDirectoryRequestId = requestId; + state.pendingDirectoryPath = path; + this.socket.emit('list_directory', { + session_id: state.sessionId || state.connectionId, + remote_path: path, + request_id: requestId, + }); + return requestId; + } + setLoadingTimeout(pane, timeout = 10000) { const state = this.panes[pane]; @@ -1038,14 +1190,15 @@ class SFTPFileManager { state.sessionId = null; state.connectionId = data.connection_id; state.hostInfo = { host: data.host, username: data.username, port: data.port }; + state.autoHomeEligible = true; const select = document.getElementById(`fm${this.capitalize(pane)}Source`); select.value = `qc:${data.connection_id}`; state.loading = true; this.renderPane(pane); - this.socket.emit('get_home_directory', { session_id: data.connection_id }); - this.socket.emit('list_directory', { session_id: data.connection_id, remote_path: '/' }); + this.requestHomeDirectory(pane, data.connection_id); + this.requestDirectory(pane, '/'); this.setLoadingTimeout(pane); this.updatePaneBadge(pane); @@ -1062,6 +1215,7 @@ class SFTPFileManager { return; } + state.autoHomeEligible = false; state.selected.clear(); state.loading = true; this.renderPane(pane); @@ -1080,8 +1234,7 @@ class SFTPFileManager { this.renderPane(pane); } } else if (state.type === 'ssh') { - const sessionId = state.sessionId || state.connectionId; - this.socket.emit('list_directory', { session_id: sessionId, remote_path: path }); + this.requestDirectory(pane, path); this.setLoadingTimeout(pane); } } @@ -1156,9 +1309,10 @@ class SFTPFileManager { } } + state.autoHomeEligible = false; state.loading = true; this.renderPane(pane); - this.socket.emit('list_directory', { session_id: sessionId, remote_path: state.path }); + this.requestDirectory(pane, state.path); this.setLoadingTimeout(pane); } } @@ -1188,6 +1342,7 @@ class SFTPFileManager { if (state.loadingTimeout) { clearTimeout(state.loadingTimeout); } + Object.keys(state).forEach(key => delete state[key]); Object.assign(state, this.createEmptyPaneState()); const select = document.getElementById(`fm${this.capitalize(pane)}Source`); if (select) select.value = ''; @@ -2364,34 +2519,7 @@ class SFTPFileManager { const menu = document.createElement('div'); menu.className = 'fm-context-menu'; - let items = []; - - if (file) { - if (file.is_dir) { - items.push({ action: 'open', icon: '📂', text: this.t('fm.ctx.open', 'Open') }); - if (state.type === 'ssh' || state.type === 'quick-connect') { - items.push({ action: 'download', icon: '⬇️', text: this.t('fm.ctx.download', 'Download') }); - } - } else { - if (state.type === 'ssh' || state.type === 'quick-connect') { - items.push({ action: 'preview', icon: '👁️', text: this.t('fm.ctx.preview', 'Preview') }); - items.push({ action: 'download', icon: '⬇️', text: this.t('fm.ctx.download', 'Download') }); - } - } - if (!this.isMobile()) { - items.push({ action: 'transfer', icon: '↔️', text: this.t('fm.ctx.transferToOther', 'Transfer to other pane') }); - } - items.push({ divider: true }); - items.push({ action: 'rename', icon: '✏️', text: this.t('fm.rename', 'Rename') }); - } - - items.push({ action: 'newfolder', icon: '📁', text: this.t('fm.newFolder', 'New Folder') }); - items.push({ action: 'refresh', icon: '↻', text: this.t('fm.refresh', 'Refresh') }); - - if (file) { - items.push({ divider: true }); - items.push({ action: 'delete', icon: '🗑️', text: this.t('fm.delete', 'Delete'), danger: true }); - } + const items = this.getContextMenuItems(file, state); menu.innerHTML = items.map(item => { if (item.divider) { @@ -2419,6 +2547,36 @@ class SFTPFileManager { }); } + getContextMenuItems(file, state) { + const items = []; + + if (file) { + if (file.is_dir) { + items.push({ action: 'open', icon: '📂', text: this.t('fm.ctx.open', 'Open') }); + if (state.type === 'ssh' || state.type === 'quick-connect') { + items.push({ action: 'download', icon: '⬇️', text: this.t('fm.ctx.download', 'Download') }); + } + } else if (state.type === 'ssh' || state.type === 'quick-connect') { + items.push({ action: 'preview', icon: '👁️', text: this.t('fm.ctx.preview', 'Preview') }); + items.push({ action: 'download', icon: '⬇️', text: this.t('fm.ctx.download', 'Download') }); + } + if (this.displayMode !== 'embedded' && !this.isMobile()) { + items.push({ action: 'transfer', icon: '↔️', text: this.t('fm.ctx.transferToOther', 'Transfer to other pane') }); + } + items.push({ divider: true }); + items.push({ action: 'rename', icon: '✏️', text: this.t('fm.rename', 'Rename') }); + } + + items.push({ action: 'newfolder', icon: '📁', text: this.t('fm.newFolder', 'New Folder') }); + items.push({ action: 'refresh', icon: '↻', text: this.t('fm.refresh', 'Refresh') }); + + if (file) { + items.push({ divider: true }); + items.push({ action: 'delete', icon: '🗑️', text: this.t('fm.delete', 'Delete'), danger: true }); + } + return items; + } + handleContextAction(action, pane, index) { const state = this.panes[pane]; this.activePane = pane; @@ -2774,13 +2932,18 @@ class SFTPFileManager { let sftpFileManager = null; -function openFileManager() { +function getSFTPFileManager() { if (!sftpFileManager) { sftpFileManager = new SFTPFileManager(); window.sftpFileManager = sftpFileManager; } - sftpFileManager.open(); + return sftpFileManager; +} + +function openFileManager() { + getSFTPFileManager().open(); } window.SFTPFileManager = SFTPFileManager; +window.getSFTPFileManager = getSFTPFileManager; window.openFileManager = openFileManager; diff --git a/static/js/terminal-manager.js b/static/js/terminal-manager.js index 0315d5e..b21d6c1 100644 --- a/static/js/terminal-manager.js +++ b/static/js/terminal-manager.js @@ -297,6 +297,11 @@ const TerminalManager = { fitTerminal(sessionId) { const terminalKeys = this.sessionTerminals[sessionId] || []; terminalKeys.forEach(key => { + const terminal = this.terminals[key]; + const wrapper = terminal?.element?.closest?.('.terminal-wrapper'); + if (wrapper?.classList.contains('unassigned')) { + return; + } const fitAddon = this.fitAddons[key]; if (fitAddon) { try { @@ -529,20 +534,12 @@ const TerminalManager = { }, updateFontSize(newSize) { - Object.keys(this.terminals).forEach(key => { - const terminal = this.terminals[key]; + Object.values(this.terminals).forEach(terminal => { if (terminal) { terminal.options.fontSize = newSize; - const fitAddon = this.fitAddons[key]; - if (fitAddon) { - try { - fitAddon.fit(); - } catch (e) { - console.error('Error fitting terminal after font change:', e); - } - } } }); + this.fitAllTerminals(); }, handleOrientationChange() { diff --git a/templates/index.html b/templates/index.html index 2693116..9122e46 100644 --- a/templates/index.html +++ b/templates/index.html @@ -19,6 +19,7 @@ + @@ -112,6 +113,9 @@

Web SSH Terminal

+
- +
+ +
@@ -863,16 +907,20 @@

File Preview

- + + + + + - + @@ -882,7 +930,7 @@

File Preview

- +