From d6e6eca28bf70bccc3cbb20acc23d48281c211d6 Mon Sep 17 00:00:00 2001 From: bifrost0x Date: Mon, 3 Aug 2026 22:38:02 +0200 Subject: [PATCH 01/14] feat: collect bounded linux session insights --- app/session_insights.py | 207 +++++++++++++++++++++++++++++ tests/test_session_insights.py | 235 +++++++++++++++++++++++++++++++++ 2 files changed, 442 insertions(+) create mode 100644 app/session_insights.py create mode 100644 tests/test_session_insights.py diff --git a/app/session_insights.py b/app/session_insights.py new file mode 100644 index 0000000..85e288a --- /dev/null +++ b/app/session_insights.py @@ -0,0 +1,207 @@ +"""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 + +_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 _lock_for_session(session_id): + with _collector_locks_guard: + return _collector_locks.setdefault(session_id, Lock()) + + +def _release_session_lock(session_id, collector_lock): + collector_lock.release() + with _collector_locks_guard: + if _collector_locks.get(session_id) is collector_lock: + _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 = _lock_for_session(session_id) + if not collector_lock.acquire(blocking=False): + 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/tests/test_session_insights.py b/tests/test_session_insights.py new file mode 100644 index 0000000..8529fc4 --- /dev/null +++ b/tests/test_session_insights.py @@ -0,0 +1,235 @@ +import pytest +from threading import Lock + + +from app import session_insights + + +VALID_PAYLOAD = """\ +cpu=100 5 50 800 20 10 5 10 +mem_total_kib=16384000 +mem_available_kib=6144000 +disk_total_kib=102400000 +disk_used_kib=61440000 +disk_available_kib=40960000 +disk_percent=60 +uptime_seconds=93784.75 +os_name=Ubuntu 24.04.2 LTS +""" + + +def test_parse_linux_stats_returns_normalized_numeric_values(): + result = session_insights.parse_linux_stats(VALID_PAYLOAD) + + assert result == { + 'cpu': [100, 5, 50, 800, 20, 10, 5, 10], + 'memory': { + 'total_kib': 16384000, + 'available_kib': 6144000, + 'used_kib': 10240000, + }, + 'disk': { + 'total_kib': 102400000, + 'used_kib': 61440000, + 'available_kib': 40960000, + 'percent': 60, + }, + 'uptime_seconds': 93784, + 'os_name': 'Ubuntu 24.04.2 LTS', + } + + +@pytest.mark.parametrize( + 'mutation', + [ + lambda value: value.replace('cpu=100 5 50 800 20 10 5 10\n', ''), + lambda value: value.replace('mem_total_kib=16384000', 'mem_total_kib=-1'), + lambda value: value.replace('mem_available_kib=6144000', 'mem_available_kib=20000000'), + lambda value: value.replace('disk_percent=60', 'disk_percent=101'), + lambda value: value.replace('uptime_seconds=93784.75', 'uptime_seconds=unknown'), + lambda value: value.replace('os_name=Ubuntu 24.04.2 LTS', 'os_name='), + ], +) +def test_parse_linux_stats_rejects_missing_or_invalid_linux_values(mutation): + with pytest.raises(ValueError): + session_insights.parse_linux_stats(mutation(VALID_PAYLOAD)) + + +def test_parse_linux_stats_rejects_output_above_bound(): + with pytest.raises(ValueError): + session_insights.parse_linux_stats( + VALID_PAYLOAD + ('x' * 20000), + max_bytes=16384, + ) + + +def test_parse_linux_stats_ignores_unknown_keys_without_exposing_them(): + result = session_insights.parse_linux_stats( + VALID_PAYLOAD + 'remote_secret=must-not-leak\n' + ) + + assert 'remote_secret' not in result + + +class FakeChannel: + def __init__(self, payload=VALID_PAYLOAD.encode(), *, status=0, + recv_error=None): + self.payload = bytearray(payload) + self.status = status + self.recv_error = recv_error + self.closed = False + + def recv_ready(self): + return bool(self.payload) + + def recv(self, size): + if self.recv_error: + raise self.recv_error + chunk = bytes(self.payload[:size]) + del self.payload[:size] + return chunk + + def exit_status_ready(self): + return not self.payload + + def recv_exit_status(self): + return self.status + + def close(self): + self.closed = True + + +class FakeTransport: + def __init__(self, active=True): + self.active = active + + def is_active(self): + return self.active + + +class FakeClient: + def __init__(self, transport): + self.transport = transport + + def get_transport(self): + return self.transport + + +def install_session(monkeypatch, *, connected=True, transport_active=True): + transport = FakeTransport(active=transport_active) + monkeypatch.setitem(session_insights.ssh_manager.sessions, 'owned-session', { + 'connected': connected, + 'client': FakeClient(transport), + }) + return transport + + +def test_collect_linux_stats_uses_separate_fixed_exec_channel(monkeypatch): + transport = install_session(monkeypatch) + channel = FakeChannel() + observed = {} + + def open_channel(actual_transport, command, *, timeout): + observed.update( + transport=actual_transport, + command=command, + timeout=timeout, + ) + return channel + + monkeypatch.setattr( + session_insights.ssh_manager, '_open_exec_channel', open_channel + ) + + stats, error = session_insights.collect_linux_stats( + 'owned-session', timeout=0.2 + ) + + assert error is None + assert stats['memory']['used_kib'] == 10240000 + assert observed == { + 'transport': transport, + 'command': session_insights.LINUX_STATS_COMMAND, + 'timeout': 0.2, + } + assert channel.closed is True + + +@pytest.mark.parametrize( + ('connected', 'transport_active', 'expected_error'), + [ + (False, True, 'unavailable'), + (True, False, 'unavailable'), + ], +) +def test_collect_linux_stats_rejects_inactive_sessions( + monkeypatch, connected, transport_active, expected_error): + install_session( + monkeypatch, + connected=connected, + transport_active=transport_active, + ) + + stats, error = session_insights.collect_linux_stats('owned-session') + + assert stats is None + assert error == expected_error + + +def test_collect_linux_stats_rejects_missing_session(monkeypatch): + monkeypatch.delitem( + session_insights.ssh_manager.sessions, 'owned-session', raising=False + ) + + stats, error = session_insights.collect_linux_stats('owned-session') + + assert stats is None + assert error == 'unavailable' + + +def test_collect_linux_stats_rejects_overlapping_collection(monkeypatch): + install_session(monkeypatch) + held_lock = Lock() + held_lock.acquire() + monkeypatch.setitem( + session_insights._collector_locks, 'owned-session', held_lock + ) + + stats, error = session_insights.collect_linux_stats('owned-session') + + assert stats is None + assert error == 'busy' + + +def test_collect_linux_stats_rejects_nonzero_remote_status(monkeypatch): + install_session(monkeypatch) + channel = FakeChannel(status=1) + monkeypatch.setattr( + session_insights.ssh_manager, + '_open_exec_channel', + lambda *_args, **_kwargs: channel, + ) + + stats, error = session_insights.collect_linux_stats('owned-session') + + assert stats is None + assert error == 'unavailable' + assert channel.closed is True + + +def test_collect_linux_stats_rejects_output_above_collection_bound(monkeypatch): + install_session(monkeypatch) + channel = FakeChannel(payload=b'x' * 129) + monkeypatch.setattr( + session_insights.ssh_manager, + '_open_exec_channel', + lambda *_args, **_kwargs: channel, + ) + + stats, error = session_insights.collect_linux_stats( + 'owned-session', max_bytes=128 + ) + + assert stats is None + assert error == 'unavailable' + assert channel.closed is True From 68066e6d9bf3928c91f7b95e30fa352e17e8cc97 Mon Sep 17 00:00:00 2001 From: bifrost0x Date: Mon, 3 Aug 2026 22:39:38 +0200 Subject: [PATCH 02/14] feat: expose owned session insights --- app/socket_events.py | 63 ++++++++++++- tests/test_session_insights_socket.py | 127 ++++++++++++++++++++++++++ 2 files changed, 189 insertions(+), 1 deletion(-) create mode 100644 tests/test_session_insights_socket.py diff --git a/app/socket_events.py b/app/socket_events.py index 319eb6e..8a5dd9d 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 @@ -1351,6 +1352,66 @@ 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 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): diff --git a/tests/test_session_insights_socket.py b/tests/test_session_insights_socket.py new file mode 100644 index 0000000..9289ec4 --- /dev/null +++ b/tests/test_session_insights_socket.py @@ -0,0 +1,127 @@ +from types import SimpleNamespace + +import pytest + +from app import socket_events + + +def invoke(monkeypatch, payload, *, owned=True, collector_result=None): + emitted = [] + collector_calls = [] + monkeypatch.setattr( + socket_events, + 'emit', + lambda event, data: emitted.append((event, data)), + ) + monkeypatch.setattr( + socket_events, + 'verify_session_ownership', + lambda session_id, user_id: owned, + ) + + def collect(session_id): + collector_calls.append(session_id) + return collector_result or ({'cpu': [1, 2, 3, 4]}, None) + + monkeypatch.setattr( + socket_events.session_insights, + 'collect_linux_stats', + collect, + ) + + socket_events.handle_request_session_insights.__wrapped__( + payload, + current_user=SimpleNamespace(id=7, username='operator'), + ) + return emitted, collector_calls + + +def test_session_insights_socket_returns_owned_sample_and_request_identity( + monkeypatch): + stats = { + 'cpu': [10, 0, 5, 85], + 'memory': { + 'total_kib': 1000, + 'available_kib': 400, + 'used_kib': 600, + }, + 'disk': { + 'total_kib': 2000, + 'used_kib': 500, + 'available_kib': 1500, + 'percent': 25, + }, + 'uptime_seconds': 3600, + 'os_name': 'Debian GNU/Linux 13', + } + emitted, calls = invoke( + monkeypatch, + {'session_id': 'owned-session', 'request_id': 'sample-1'}, + collector_result=(stats, None), + ) + + assert calls == ['owned-session'] + assert emitted == [('session_insights', { + 'success': True, + 'session_id': 'owned-session', + 'request_id': 'sample-1', + 'stats': stats, + })] + + +def test_session_insights_socket_rejects_unowned_session_before_collection( + monkeypatch): + emitted, calls = invoke( + monkeypatch, + {'session_id': 'foreign-session', 'request_id': 'sample-2'}, + owned=False, + ) + + assert calls == [] + assert emitted == [('session_insights', { + 'success': False, + 'session_id': 'foreign-session', + 'request_id': 'sample-2', + 'error': 'Session insights unavailable', + })] + + +@pytest.mark.parametrize( + 'payload', + [ + None, + {}, + {'session_id': 123, 'request_id': 'sample'}, + {'session_id': 'owned-session', 'request_id': None}, + {'session_id': 'x' * 129, 'request_id': 'sample'}, + {'session_id': 'owned-session', 'request_id': 'x' * 129}, + ], +) +def test_session_insights_socket_rejects_malformed_identifiers( + monkeypatch, payload): + emitted, calls = invoke(monkeypatch, payload) + + assert calls == [] + assert emitted == [('session_insights', { + 'success': False, + 'session_id': '', + 'request_id': '', + 'error': 'Session insights unavailable', + })] + + +def test_session_insights_socket_returns_generic_collector_failure(monkeypatch): + emitted, calls = invoke( + monkeypatch, + {'session_id': 'owned-session', 'request_id': 'sample-3'}, + collector_result=(None, 'unavailable'), + ) + + assert calls == ['owned-session'] + assert emitted == [('session_insights', { + 'success': False, + 'session_id': 'owned-session', + 'request_id': 'sample-3', + 'error': 'Session insights unavailable', + })] + From c097776ed1665a7b6d5e8c4e0556951e2a34cc75 Mon Sep 17 00:00:00 2001 From: bifrost0x Date: Mon, 3 Aug 2026 22:43:26 +0200 Subject: [PATCH 03/14] feat: poll active linux session insights --- static/js/session-insights.js | 203 ++++++++++++++++++++++++++++++ tests/js/session-insights.test.js | 190 ++++++++++++++++++++++++++++ 2 files changed, 393 insertions(+) create mode 100644 static/js/session-insights.js create mode 100644 tests/js/session-insights.test.js diff --git a/static/js/session-insights.js b/static/js/session-insights.js new file mode 100644 index 0000000..f187603 --- /dev/null +++ b/static/js/session-insights.js @@ -0,0 +1,203 @@ +(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/tests/js/session-insights.test.js b/tests/js/session-insights.test.js new file mode 100644 index 0000000..e931acc --- /dev/null +++ b/tests/js/session-insights.test.js @@ -0,0 +1,190 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const insights = require('../../static/js/session-insights.js'); + + +function fakeRuntime() { + const emitted = []; + const handlers = new Map(); + const intervals = new Map(); + const timeouts = new Map(); + let nextTimer = 1; + const renders = []; + + const socket = { + emit(event, payload) { + emitted.push({ event, payload }); + }, + on(event, handler) { + handlers.set(event, handler); + }, + off(event, handler) { + if (handlers.get(event) === handler) handlers.delete(event); + }, + }; + const controller = insights.createController({ + socket, + render: state => renders.push(state), + setIntervalFn(callback, delay) { + const id = nextTimer++; + intervals.set(id, { callback, delay }); + return id; + }, + clearIntervalFn(id) { + intervals.delete(id); + }, + setTimeoutFn(callback, delay) { + const id = nextTimer++; + timeouts.set(id, { callback, delay }); + return id; + }, + clearTimeoutFn(id) { + timeouts.delete(id); + }, + }); + + return { controller, emitted, handlers, intervals, timeouts, renders }; +} + + +test('calculates CPU percent from Linux counter deltas without counting idle time', () => { + assert.equal( + insights.calculateCpuPercent( + [100, 0, 50, 800, 20, 10, 5, 15], + [130, 0, 70, 840, 30, 15, 10, 25], + ), + 58, + ); + assert.equal(insights.calculateCpuPercent(null, [1, 2, 3, 4]), null); + assert.equal( + insights.calculateCpuPercent([10, 10, 10, 10], [1, 1, 1, 1]), + null, + ); +}); + + +test('formats KiB values and maps visual thresholds', () => { + assert.equal(insights.formatKib(1024), '1.0 MB'); + assert.equal(insights.formatKib(5 * 1024 * 1024), '5.0 GB'); + assert.equal(insights.severityForPercent(74), 'normal'); + assert.equal(insights.severityForPercent(75), 'warning'); + assert.equal(insights.severityForPercent(90), 'critical'); +}); + + +test('requests immediately and repeats on an exact four second interval', () => { + const runtime = fakeRuntime(); + + runtime.controller.setSession('session-a', true); + + assert.equal(runtime.emitted.length, 1); + assert.equal(runtime.emitted[0].event, 'request_session_insights'); + assert.equal(runtime.emitted[0].payload.session_id, 'session-a'); + assert.equal(runtime.intervals.size, 1); + assert.equal([...runtime.intervals.values()][0].delay, 4000); + assert.equal([...runtime.timeouts.values()][0].delay, 3500); + + const firstRequest = runtime.emitted[0].payload; + runtime.handlers.get('session_insights')({ + success: true, + session_id: 'session-a', + request_id: firstRequest.request_id, + stats: { + cpu: [100, 0, 50, 850], + memory: { total_kib: 1000, available_kib: 400, used_kib: 600 }, + disk: { total_kib: 2000, available_kib: 1500, used_kib: 500, percent: 25 }, + uptime_seconds: 3600, + os_name: 'Debian GNU/Linux 13', + }, + }); + + [...runtime.intervals.values()][0].callback(); + assert.equal(runtime.emitted.length, 2); +}); + + +test('never overlaps a pending request and ignores late responses', () => { + const runtime = fakeRuntime(); + runtime.controller.setSession('session-a', true); + const firstRequest = runtime.emitted[0].payload; + + [...runtime.intervals.values()][0].callback(); + assert.equal(runtime.emitted.length, 1); + + runtime.controller.setSession('session-b', true); + assert.equal(runtime.emitted.length, 2); + runtime.handlers.get('session_insights')({ + success: true, + session_id: 'session-a', + request_id: firstRequest.request_id, + stats: {}, + }); + + assert.equal(runtime.renders.at(-1).sessionId, 'session-b'); + assert.equal(runtime.renders.at(-1).status, 'loading'); +}); + + +test('pauses while hidden and resumes immediately for the same session', () => { + const runtime = fakeRuntime(); + runtime.controller.setSession('session-a', true); + runtime.controller.setVisible(false); + + assert.equal(runtime.intervals.size, 0); + assert.equal(runtime.timeouts.size, 0); + runtime.controller.setVisible(true); + + assert.equal(runtime.emitted.length, 2); + assert.equal(runtime.intervals.size, 1); +}); + + +test('marks one failed update stale and three consecutive failures unavailable', () => { + const runtime = fakeRuntime(); + runtime.controller.setSession('session-a', true); + + function failCurrentRequest() { + const request = runtime.emitted.at(-1).payload; + runtime.handlers.get('session_insights')({ + success: false, + session_id: 'session-a', + request_id: request.request_id, + }); + } + + failCurrentRequest(); + assert.equal(runtime.renders.at(-1).status, 'stale'); + [...runtime.intervals.values()][0].callback(); + failCurrentRequest(); + [...runtime.intervals.values()][0].callback(); + failCurrentRequest(); + + assert.equal(runtime.renders.at(-1).status, 'unavailable'); +}); + + +test('keeps at most fifteen CPU samples per session', () => { + const runtime = fakeRuntime(); + runtime.controller.setSession('session-a', true); + + for (let index = 0; index < 18; index += 1) { + const request = runtime.emitted.at(-1).payload; + runtime.handlers.get('session_insights')({ + success: true, + session_id: 'session-a', + request_id: request.request_id, + stats: { + cpu: [100 + index * 10, 0, 50 + index * 5, 850 + index * 5], + memory: { total_kib: 1000, available_kib: 400, used_kib: 600 }, + disk: { total_kib: 2000, available_kib: 1500, used_kib: 500, percent: 25 }, + uptime_seconds: 3600 + index * 4, + os_name: 'Linux', + }, + }); + [...runtime.intervals.values()][0].callback(); + } + + assert.equal(runtime.renders.at(-1).cpuHistory.length, 15); +}); + From 144f0595582452d512cb24d040ff77415113ed27 Mon Sep 17 00:00:00 2001 From: bifrost0x Date: Mon, 3 Aug 2026 22:47:20 +0200 Subject: [PATCH 04/14] feat: add active session sftp panel --- static/js/session-files-panel.js | 289 +++++++++++++++++++++++++++ tests/js/session-files-panel.test.js | 237 ++++++++++++++++++++++ 2 files changed, 526 insertions(+) create mode 100644 static/js/session-files-panel.js create mode 100644 tests/js/session-files-panel.test.js diff --git a/static/js/session-files-panel.js b/static/js/session-files-panel.js new file mode 100644 index 0000000..e8ac1e8 --- /dev/null +++ b/static/js/session-files-panel.js @@ -0,0 +1,289 @@ +(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 normalizePath(value) { + const raw = typeof value === 'string' ? value : '/'; + const segments = []; + raw.replace(/\\/g, '/').split('/').forEach(segment => { + if (!segment || segment === '.') return; + if (segment === '..') { + segments.pop(); + return; + } + segments.push(segment); + }); + return `/${segments.join('/')}` || '/'; + } + + function joinPath(base, name) { + return normalizePath(`${normalizePath(base)}/${name || ''}`); + } + + function parentPath(path) { + const normalized = normalizePath(path); + if (normalized === '/') return '/'; + const segments = normalized.split('/').filter(Boolean); + segments.pop(); + return segments.length ? `/${segments.join('/')}` : '/'; + } + + function validName(value) { + return ( + typeof value === 'string' + && value.trim().length > 0 + && value !== '.' + && value !== '..' + && !value.includes('/') + && !value.includes('\\') + && !value.includes('\0') + ); + } + + function createController(options) { + const socket = options.socket; + const render = options.render || (() => {}); + const transferClient = options.transferClient; + const filePreview = options.filePreview; + let opened = false; + let sessionId = null; + let sessionMeta = {}; + let homePath = '/'; + let path = '/'; + let requestedPath = null; + let files = []; + let selectedIndex = -1; + let status = 'closed'; + let error = null; + + function label() { + const username = sessionMeta?.username; + const host = sessionMeta?.host; + return username && host ? `${username}@${host}` : ''; + } + + function renderState() { + if (!opened && status !== 'closed') return; + render({ + open: opened, + status, + sessionId, + label: label(), + homePath, + path, + files: files.slice(), + selectedIndex, + error, + }); + } + + function requestHome() { + if (!opened || !sessionId) return; + status = 'loading'; + error = null; + renderState(); + socket.emit('get_home_directory', { session_id: sessionId }); + } + + function navigate(nextPath) { + if (!opened || !sessionId) return; + path = normalizePath(nextPath); + requestedPath = path; + selectedIndex = -1; + status = 'loading'; + error = null; + renderState(); + socket.emit('list_directory', { + session_id: sessionId, + remote_path: path, + }); + } + + function refresh() { + if (opened && sessionId) navigate(path); + } + + function handleHome(data) { + if (!opened || data?.session_id !== sessionId) return; + homePath = normalizePath(data.path); + navigate(homePath); + } + + function handleListing(data) { + if (!opened || data?.session_id !== sessionId) return; + const responsePath = normalizePath(data.path); + if (requestedPath !== responsePath) return; + path = responsePath; + files = Array.isArray(data.files) ? data.files.slice() : []; + selectedIndex = -1; + requestedPath = null; + status = 'ready'; + error = null; + renderState(); + } + + function handleMutation() { + refresh(); + } + + const listeners = { + home_directory: handleHome, + directory_listing: handleListing, + directory_created: handleMutation, + file_renamed: handleMutation, + item_deleted: handleMutation, + }; + Object.entries(listeners).forEach(([event, handler]) => { + socket.on(event, handler); + }); + + return { + open(nextSessionId, meta = {}) { + opened = true; + this.follow(nextSessionId, meta); + }, + + follow(nextSessionId, meta = {}) { + if (!opened) return; + sessionId = typeof nextSessionId === 'string' && nextSessionId + ? nextSessionId + : null; + sessionMeta = meta || {}; + homePath = '/'; + path = '/'; + requestedPath = null; + files = []; + selectedIndex = -1; + error = null; + status = sessionId ? 'loading' : 'disconnected'; + renderState(); + if (sessionId) requestHome(); + }, + + close() { + opened = false; + sessionId = null; + sessionMeta = {}; + files = []; + selectedIndex = -1; + requestedPath = null; + status = 'closed'; + error = null; + renderState(); + }, + + isOpen() { + return opened; + }, + + navigate, + + goHome() { + navigate(homePath); + }, + + goParent() { + navigate(parentPath(path)); + }, + + refresh, + + select(index) { + selectedIndex = Number.isInteger(index) && files[index] ? index : -1; + renderState(); + }, + + activate(index) { + const item = files[index]; + if (!item || !sessionId) return; + if (item.is_dir) { + navigate(joinPath(path, item.name)); + return; + } + filePreview?.open(sessionId, joinPath(path, item.name), item.name); + }, + + upload(fileList) { + if (!opened || !sessionId || !transferClient) return; + Array.from(fileList || []).forEach(file => { + if (!file?.name) return; + transferClient.uploadFile(file, joinPath(path, file.name), sessionId); + }); + }, + + downloadSelected() { + const item = files[selectedIndex]; + if (!item || item.is_dir || !sessionId || !transferClient) return; + transferClient.downloadFile(joinPath(path, item.name), sessionId); + }, + + createFolder(name) { + if (!validName(name) || !sessionId) return false; + socket.emit('create_directory', { + session_id: sessionId, + remote_path: joinPath(path, name.trim()), + }); + return true; + }, + + renameSelected(name) { + const item = files[selectedIndex]; + if (!item || !validName(name) || !sessionId) return false; + socket.emit('rename_item', { + session_id: sessionId, + old_path: joinPath(path, item.name), + new_path: joinPath(path, name.trim()), + }); + return true; + }, + + deleteSelected() { + const item = files[selectedIndex]; + if (!item || !sessionId) return false; + socket.emit('delete_item', { + session_id: sessionId, + path: joinPath(path, item.name), + }); + return true; + }, + + setDisconnected(disconnectedSessionId) { + if (!opened || disconnectedSessionId !== sessionId) return; + status = 'disconnected'; + requestedPath = null; + files = []; + selectedIndex = -1; + renderState(); + }, + + setError(message) { + if (!opened) return; + status = 'error'; + error = typeof message === 'string' ? message : 'SFTP unavailable'; + renderState(); + }, + + destroy() { + this.close(); + Object.entries(listeners).forEach(([event, handler]) => { + socket.off?.(event, handler); + }); + }, + }; + } + + return { + normalizePath, + joinPath, + parentPath, + createController, + }; +})); + diff --git a/tests/js/session-files-panel.test.js b/tests/js/session-files-panel.test.js new file mode 100644 index 0000000..8d6a4a2 --- /dev/null +++ b/tests/js/session-files-panel.test.js @@ -0,0 +1,237 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const filesPanel = require('../../static/js/session-files-panel.js'); + + +function runtime() { + const emitted = []; + const handlers = new Map(); + const renders = []; + const uploads = []; + const downloads = []; + const previews = []; + const socket = { + emit(event, payload) { + emitted.push({ event, payload }); + }, + on(event, handler) { + handlers.set(event, handler); + }, + off(event, handler) { + if (handlers.get(event) === handler) handlers.delete(event); + }, + }; + const controller = filesPanel.createController({ + socket, + render: state => renders.push(state), + transferClient: { + uploadFile(file, path, sessionId) { + uploads.push({ file, path, sessionId }); + }, + downloadFile(path, sessionId) { + downloads.push({ path, sessionId }); + }, + }, + filePreview: { + open(sessionId, path, name) { + previews.push({ sessionId, path, name }); + }, + }, + }); + return { + controller, socket, emitted, handlers, renders, + uploads, downloads, previews, + }; +} + + +test('normalizes remote paths without allowing relative traversal', () => { + assert.equal(filesPanel.normalizePath('/srv/app/../logs'), '/srv/logs'); + assert.equal(filesPanel.joinPath('/srv/app', 'config.yml'), '/srv/app/config.yml'); + assert.equal(filesPanel.parentPath('/srv/app'), '/srv'); + assert.equal(filesPanel.parentPath('/'), '/'); +}); + + +test('opens on the active session home and follows a replacement session', () => { + const state = runtime(); + state.controller.open('session-a', { username: 'ops', host: 'edge.example' }); + + assert.deepEqual(state.emitted, [{ + event: 'get_home_directory', + payload: { session_id: 'session-a' }, + }]); + assert.equal(state.renders.at(-1).status, 'loading'); + assert.equal(state.renders.at(-1).label, 'ops@edge.example'); + + state.handlers.get('home_directory')({ + session_id: 'session-a', + path: '/home/ops', + }); + assert.deepEqual(state.emitted.at(-1), { + event: 'list_directory', + payload: { session_id: 'session-a', remote_path: '/home/ops' }, + }); + + state.controller.follow('session-b', { username: 'deploy', host: 'build.example' }); + assert.equal(state.renders.at(-1).sessionId, 'session-b'); + assert.equal(state.renders.at(-1).selectedIndex, -1); + assert.deepEqual(state.emitted.at(-1), { + event: 'get_home_directory', + payload: { session_id: 'session-b' }, + }); +}); + + +test('ignores stale listings and renders only the current requested path', () => { + const state = runtime(); + state.controller.open('session-a', { username: 'ops', host: 'edge.example' }); + state.handlers.get('home_directory')({ session_id: 'session-a', path: '/home/ops' }); + state.controller.follow('session-b', { username: 'deploy', host: 'build.example' }); + + state.handlers.get('directory_listing')({ + session_id: 'session-a', + path: '/home/ops', + files: [{ name: 'secret', is_dir: false }], + }); + assert.equal(state.renders.at(-1).sessionId, 'session-b'); + + state.handlers.get('home_directory')({ session_id: 'session-b', path: '/srv/build' }); + state.controller.navigate('/srv/build/releases'); + state.handlers.get('directory_listing')({ + session_id: 'session-b', + path: '/srv/build', + files: [{ name: 'old', is_dir: false }], + }); + assert.notEqual(state.renders.at(-1).files?.[0]?.name, 'old'); + + state.handlers.get('directory_listing')({ + session_id: 'session-b', + path: '/srv/build/releases', + files: [{ name: 'v1', is_dir: true }], + }); + assert.equal(state.renders.at(-1).files[0].name, 'v1'); +}); + + +test('opens folders inside the panel and files in the existing preview', () => { + const state = runtime(); + state.controller.open('session-a', { username: 'ops', host: 'edge.example' }); + state.handlers.get('home_directory')({ session_id: 'session-a', path: '/home/ops' }); + state.handlers.get('directory_listing')({ + session_id: 'session-a', + path: '/home/ops', + files: [ + { name: 'logs', is_dir: true }, + { name: 'config.yml', is_dir: false, size: 100 }, + ], + }); + + state.controller.activate(0); + assert.deepEqual(state.emitted.at(-1), { + event: 'list_directory', + payload: { session_id: 'session-a', remote_path: '/home/ops/logs' }, + }); + state.controller.navigate('/home/ops'); + state.handlers.get('directory_listing')({ + session_id: 'session-a', + path: '/home/ops', + files: [ + { name: 'logs', is_dir: true }, + { name: 'config.yml', is_dir: false, size: 100 }, + ], + }); + state.controller.activate(1); + assert.deepEqual(state.previews, [{ + sessionId: 'session-a', + path: '/home/ops/config.yml', + name: 'config.yml', + }]); +}); + + +test('delegates uploads and downloads to the existing transfer client', () => { + const state = runtime(); + const file = { name: 'release.tgz', size: 42 }; + state.controller.open('session-a', { username: 'ops', host: 'edge.example' }); + state.handlers.get('home_directory')({ session_id: 'session-a', path: '/home/ops' }); + state.handlers.get('directory_listing')({ + session_id: 'session-a', + path: '/home/ops', + files: [{ name: 'report.txt', is_dir: false }], + }); + + state.controller.upload([file]); + state.controller.select(0); + state.controller.downloadSelected(); + + assert.deepEqual(state.uploads, [{ + file, + path: '/home/ops/release.tgz', + sessionId: 'session-a', + }]); + assert.deepEqual(state.downloads, [{ + path: '/home/ops/report.txt', + sessionId: 'session-a', + }]); +}); + + +test('uses existing mutation contracts and refreshes after completion', () => { + const state = runtime(); + state.controller.open('session-a', { username: 'ops', host: 'edge.example' }); + state.handlers.get('home_directory')({ session_id: 'session-a', path: '/home/ops' }); + state.handlers.get('directory_listing')({ + session_id: 'session-a', + path: '/home/ops', + files: [{ name: 'old.txt', is_dir: false }], + }); + state.controller.select(0); + + state.controller.createFolder('releases'); + state.controller.renameSelected('current.txt'); + state.controller.deleteSelected(); + + assert.deepEqual(state.emitted.slice(-3), [ + { + event: 'create_directory', + payload: { session_id: 'session-a', remote_path: '/home/ops/releases' }, + }, + { + event: 'rename_item', + payload: { + session_id: 'session-a', + old_path: '/home/ops/old.txt', + new_path: '/home/ops/current.txt', + }, + }, + { + event: 'delete_item', + payload: { session_id: 'session-a', path: '/home/ops/old.txt' }, + }, + ]); + + state.handlers.get('directory_created')({ path: '/home/ops/releases' }); + assert.deepEqual(state.emitted.at(-1), { + event: 'list_directory', + payload: { session_id: 'session-a', remote_path: '/home/ops' }, + }); +}); + + +test('closes into an inert state and reports disconnects', () => { + const state = runtime(); + state.controller.open('session-a', { username: 'ops', host: 'edge.example' }); + state.controller.setDisconnected('session-a'); + assert.equal(state.renders.at(-1).status, 'disconnected'); + + state.controller.close(); + assert.equal(state.controller.isOpen(), false); + const count = state.renders.length; + state.handlers.get('directory_listing')({ + session_id: 'session-a', path: '/', files: [], + }); + assert.equal(state.renders.length, count); +}); + From 2e45b1fab629a907ac482a819bd0621a932df940 Mon Sep 17 00:00:00 2001 From: bifrost0x Date: Mon, 3 Aug 2026 23:11:00 +0200 Subject: [PATCH 05/14] feat: add focused Linux session workspace --- README.md | 6 + assets/session-workspace.png | Bin 0 -> 152703 bytes site/index.html | 12 +- static/css/session-workspace.css | 430 +++++++++++++++++++++++++++ static/js/app.js | 6 + static/js/session-files-panel.js | 3 +- static/js/session-manager.js | 16 + static/js/session-workspace-ui.js | 273 +++++++++++++++++ static/js/session-workspace.js | 112 +++++++ templates/index.html | 67 ++++- tests/e2e/session-workspace.spec.js | 176 +++++++++++ tests/js/session-files-panel.test.js | 3 +- tests/js/session-workspace.test.js | 105 +++++++ tests/test_public_product_surface.py | 15 + 14 files changed, 1213 insertions(+), 11 deletions(-) create mode 100644 assets/session-workspace.png create mode 100644 static/css/session-workspace.css create mode 100644 static/js/session-workspace-ui.js create mode 100644 static/js/session-workspace.js create mode 100644 tests/e2e/session-workspace.spec.js create mode 100644 tests/js/session-workspace.test.js diff --git a/README.md b/README.md index 0d5cda1..41d50c5 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/assets/session-workspace.png b/assets/session-workspace.png new file mode 100644 index 0000000000000000000000000000000000000000..7de529965bcd468e8b1f819acdb052b382264c9d GIT binary patch literal 152703 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 T~uIh?)%WAl@ssiOWot0~Bv~FUV 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 aGC-*#-QUpf>nVwC=YDZKgFWr{>wf_5fV+SI literal 0 HcmV?d00001 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
- 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..7707874 --- /dev/null +++ b/static/css/session-workspace.css @@ -0,0 +1,430 @@ +.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-host { + padding: 0 16px 12px; + overflow: hidden; + color: var(--text-secondary); + font-family: var(--font-mono); + font-size: 11px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.session-files-toolbar { + display: flex; + align-items: center; + gap: 5px; + padding: 9px 12px; + border-top: 1px solid var(--border-color); + border-bottom: 1px solid var(--border-color); + background: var(--bg-tertiary); +} + +.session-files-toolbar button, +.session-files-actions button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 5px; + border: 1px solid var(--border-color); + border-radius: 6px; + background: var(--bg-secondary); + color: var(--text-secondary); + cursor: pointer; +} + +.session-files-toolbar button { + width: 31px; + height: 31px; + padding: 0; +} + +.session-files-toolbar button:hover, +.session-files-actions button:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +.session-files-toolbar .material-icons, +.session-files-actions .material-icons { + font-size: 16px; +} + +.session-files-toolbar-spacer { + flex: 1; +} + +.session-files-path { + margin: 10px 12px 0; + padding: 8px 10px; + border: 1px solid var(--border-color); + border-radius: 6px; + overflow: hidden; + background: var(--bg-tertiary); + color: var(--text-primary); + font-family: var(--font-mono); + font-size: 11px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.session-files-status { + min-height: 29px; + padding: 8px 13px 6px; + color: var(--text-muted); + font-size: 11px; +} + +.session-files-status.error { + color: var(--danger-color, #f85149); +} + +.session-files-list { + flex: 1; + min-height: 0; + overflow-y: auto; + border-top: 1px solid var(--border-color); +} + +.session-file-row { + display: grid; + grid-template-columns: 25px minmax(0, 1fr) auto; + align-items: center; + gap: 8px; + min-height: 38px; + padding: 6px 13px; + border-bottom: 1px solid color-mix(in srgb, var(--border-color) 55%, transparent); + color: var(--text-secondary); + cursor: default; + font-size: 12px; +} + +.session-file-row:hover { + background: var(--bg-hover); +} + +.session-file-row.selected { + background: var(--bg-active); + color: var(--text-primary); +} + +.session-file-row .material-icons { + color: var(--accent-primary); + font-size: 18px; +} + +.session-file-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.session-file-size { + color: var(--text-muted); + font-family: var(--font-mono); + font-size: 10px; +} + +.session-files-actions { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 6px; + padding: 10px 12px; + border-top: 1px solid var(--border-color); +} + +.session-files-actions button { + min-width: 0; + padding: 7px 5px; + font-size: 10px; +} + +.session-files-actions button:disabled { + cursor: not-allowed; + opacity: 0.42; +} + +.session-files-actions .danger:not(:disabled) { + color: var(--danger-color, #f85149); +} + +.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..16f7284 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -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/session-files-panel.js b/static/js/session-files-panel.js index e8ac1e8..532a969 100644 --- a/static/js/session-files-panel.js +++ b/static/js/session-files-panel.js @@ -236,7 +236,7 @@ renameSelected(name) { const item = files[selectedIndex]; if (!item || !validName(name) || !sessionId) return false; - socket.emit('rename_item', { + socket.emit('rename_file', { session_id: sessionId, old_path: joinPath(path, item.name), new_path: joinPath(path, name.trim()), @@ -286,4 +286,3 @@ 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..85b5cc7 --- /dev/null +++ b/static/js/session-workspace-ui.js @@ -0,0 +1,273 @@ +(function (root) { + 'use strict'; + + function formatFileSize(bytes) { + const value = Number(bytes); + if (!Number.isFinite(value) || value < 0) return ''; + if (value < 1024) return `${value} B`; + if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`; + if (value < 1024 * 1024 * 1024) return `${(value / (1024 * 1024)).toFixed(1)} MB`; + return `${(value / (1024 * 1024 * 1024)).toFixed(1)} GB`; + } + + 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; + if (!socket || !sessionManager || !insightsModule || !filesModule || !workspaceModule) { + return null; + } + + const elements = { + mainSplit: documentRef.getElementById('sessionMainSplit'), + filesPanel: documentRef.getElementById('sessionFilesPanel'), + toggle: documentRef.getElementById('sessionSftpToggleBtn'), + filesHost: documentRef.getElementById('sessionFilesHost'), + filesPath: documentRef.getElementById('sessionFilesPath'), + filesStatus: documentRef.getElementById('sessionFilesStatus'), + filesList: documentRef.getElementById('sessionFilesList'), + download: documentRef.getElementById('sessionFilesDownloadBtn'), + rename: documentRef.getElementById('sessionFilesRenameBtn'), + delete: documentRef.getElementById('sessionFilesDeleteBtn'), + uploadInput: documentRef.getElementById('sessionFilesUploadInput'), + 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) return null; + + let filesController = null; + let coordinator = null; + + function renderFiles(state) { + elements.filesPanel.classList.toggle('hidden', !state.open); + elements.filesHost.textContent = state.label || 'No active session'; + elements.filesPath.textContent = state.path || '/'; + const statusLabels = { + loading: 'Loading remote directory...', + ready: `${state.files.length} item${state.files.length === 1 ? '' : 's'}`, + disconnected: 'SSH session disconnected', + error: state.error || 'SFTP unavailable', + closed: '', + }; + elements.filesStatus.textContent = statusLabels[state.status] || ''; + elements.filesStatus.classList.toggle('error', state.status === 'error'); + elements.filesList.replaceChildren(); + state.files.forEach((item, index) => { + const row = documentRef.createElement('div'); + row.className = 'session-file-row'; + row.classList.toggle('selected', index === state.selectedIndex); + row.setAttribute('role', 'option'); + row.setAttribute('aria-selected', String(index === state.selectedIndex)); + row.tabIndex = 0; + + const icon = documentRef.createElement('span'); + icon.className = 'material-icons'; + icon.setAttribute('aria-hidden', 'true'); + icon.textContent = item.is_dir ? 'folder' : 'insert_drive_file'; + const name = documentRef.createElement('span'); + name.className = 'session-file-name'; + name.textContent = item.name || ''; + const size = documentRef.createElement('span'); + size.className = 'session-file-size'; + size.textContent = item.is_dir ? '' : formatFileSize(item.size); + row.append(icon, name, size); + row.addEventListener('click', () => filesController.select(index)); + row.addEventListener('dblclick', () => filesController.activate(index)); + row.addEventListener('keydown', event => { + if (event.key === 'Enter') filesController.activate(index); + }); + elements.filesList.appendChild(row); + }); + const selected = state.files[state.selectedIndex]; + elements.download.disabled = !selected || Boolean(selected.is_dir); + elements.rename.disabled = !selected; + elements.delete.disabled = !selected; + } + + 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({ + socket, + render: renderFiles, + transferClient: root.BinaryTransferClient?.forSocket(socket), + filePreview: root.FilePreview, + }); + 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()); + documentRef.getElementById('sessionFilesHomeBtn')?.addEventListener('click', () => filesController.goHome()); + documentRef.getElementById('sessionFilesUpBtn')?.addEventListener('click', () => filesController.goParent()); + documentRef.getElementById('sessionFilesRefreshBtn')?.addEventListener('click', () => filesController.refresh()); + documentRef.getElementById('sessionFilesNewFolderBtn')?.addEventListener('click', () => { + const name = root.prompt('New folder name'); + if (name) filesController.createFolder(name); + }); + documentRef.getElementById('sessionFilesUploadBtn')?.addEventListener('click', () => elements.uploadInput.click()); + elements.uploadInput.addEventListener('change', () => { + filesController.upload(elements.uploadInput.files); + elements.uploadInput.value = ''; + }); + elements.download.addEventListener('click', () => filesController.downloadSelected()); + elements.rename.addEventListener('click', () => { + const name = root.prompt('Rename selected item'); + if (name) filesController.renameSelected(name); + }); + elements.delete.addEventListener('click', () => { + if (root.confirm('Delete the selected remote item?')) filesController.deleteSelected(); + }); + + root.addEventListener('session-workspace-change', sync); + documentRef.addEventListener('visibilitychange', () => { + coordinator.setVisible(documentRef.visibilityState !== 'hidden'); + }); + const desktopQuery = root.matchMedia('(min-width: 851px)'); + desktopQuery.addEventListener?.('change', sync); + root.addEventListener('themeChanged', () => drawCpuHistory(elements.cpuChart, [])); + sync(); + return coordinator; + } + + root.SessionWorkspaceUI = { init, formatFileSize, 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/templates/index.html b/templates/index.html index 2693116..119312f 100644 --- a/templates/index.html +++ b/templates/index.html @@ -19,6 +19,7 @@ + @@ -112,6 +113,9 @@

Web SSH Terminal

+
- +
+ +
@@ -866,6 +927,10 @@

File Preview

+ + + + diff --git a/tests/e2e/session-workspace.spec.js b/tests/e2e/session-workspace.spec.js new file mode 100644 index 0000000..9b7cfe4 --- /dev/null +++ b/tests/e2e/session-workspace.spec.js @@ -0,0 +1,176 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const { test, expect } = require('playwright/test'); +const { login, assertNoExternalRequests } = require('./helpers'); + +test.use({ + viewport: { width: 1920, height: 1080 }, + deviceScaleFactor: 4 / 3, +}); + +function capturePath(testInfo) { + return process.env.WEBSSH_CAPTURE_ASSETS === '1' + ? path.resolve(__dirname, '..', '..', 'assets', 'session-workspace.png') + : testInfo.outputPath('session-workspace-2560x1440.png'); +} + +function pngSize(filePath) { + const png = fs.readFileSync(filePath); + return { width: png.readUInt32BE(16), height: png.readUInt32BE(20) }; +} + +async function seedLinuxSession(page) { + await page.evaluate(() => { + const originalEmit = window.socket.emit.bind(window.socket); + let insightSample = 0; + const fileRows = [ + { name: 'releases', is_dir: true, size: 0, permissions: 'drwxr-xr-x' }, + { name: 'compose.yaml', is_dir: false, size: 2841, permissions: '-rw-r--r--' }, + { name: 'healthcheck.sh', is_dir: false, size: 912, permissions: '-rwxr-xr-x' }, + { name: 'README.md', is_dir: false, size: 4876, permissions: '-rw-r--r--' }, + ]; + + function deliver(event, payload) { + queueMicrotask(() => window.socket.listeners(event).forEach(listener => listener(payload))); + } + + window.socket.emit = function workspaceTestEmit(event, payload, ...rest) { + if (payload?.session_id === 'workspace-linux') { + if (event === 'request_session_insights') { + insightSample += 1; + window.__workspaceInsightSample = insightSample; + const cpuSamples = [ + [200, 0, 150, 950], + [260, 0, 180, 1060], + [400, 0, 260, 1120], + ]; + deliver('session_insights', { + success: true, + session_id: payload.session_id, + request_id: payload.request_id, + stats: { + cpu: cpuSamples[Math.min(insightSample - 1, cpuSamples.length - 1)], + memory: { + total_kib: 16 * 1024 * 1024, + available_kib: 6 * 1024 * 1024, + used_kib: 10 * 1024 * 1024, + }, + disk: { + total_kib: 100 * 1024 * 1024, + available_kib: 39 * 1024 * 1024, + used_kib: 61 * 1024 * 1024, + percent: 61, + }, + uptime_seconds: 93784, + os_name: 'Ubuntu 24.04.2 LTS', + }, + }); + return window.socket; + } + if (event === 'get_home_directory') { + deliver('home_directory', { + session_id: payload.session_id, + path: '/srv/webssh/current', + }); + return window.socket; + } + if (event === 'list_directory') { + deliver('directory_listing', { + session_id: payload.session_id, + path: payload.remote_path, + files: fileRows, + }); + return window.socket; + } + if (['ssh_input', 'ssh_resize'].includes(event)) return window.socket; + } + return originalEmit(event, payload, ...rest); + }; + + SessionManager.createSession({ + session_id: 'workspace-linux', + host: 'edge-01.example', + port: 22, + username: 'ops', + display_name: 'Production Edge', + }); + SessionManager.assignSessionToPane('workspace-linux', 0); + document.querySelector('.account-name').textContent = 'operator'; + setTimeout(() => { + TerminalManager.writeOutput('workspace-linux', [ + '\u001b[1;36mProduction Edge - Ubuntu 24.04 LTS\u001b[0m', + 'ops@edge-01:~$ systemctl is-active webssh', + 'active', + 'ops@edge-01:~$ uptime', + ' 10:42:18 up 1 day, 2:03, load average: 0.18, 0.12, 0.09', + 'ops@edge-01:~$ ', + ].join('\r\n')); + }, 250); + document.getElementById('sessionNotepad').value = [ + 'Release checklist', + '', + '- Review service status', + '- Verify deployment health', + '- Archive audit notes', + ].join('\n'); + }); +} + +test('single-session workspace combines terminal, SFTP, live Linux stats, and notepad', async ({ page }, testInfo) => { + await login(page); + await seedLinuxSession(page); + + const toggle = page.locator('#sessionSftpToggleBtn'); + await expect(toggle).toBeEnabled(); + await toggle.click(); + + await expect(page.locator('#sessionMainSplit')).toHaveClass(/sftp-open/); + await expect(page.locator('#sessionFilesPanel')).toBeVisible(); + await expect(page.locator('#sessionFilesHost')).toHaveText('ops@edge-01.example'); + await expect(page.locator('.session-file-row')).toHaveCount(4); + await expect(page.locator('#sessionInsightsState')).toHaveText('Live'); + await expect(page.locator('#sessionRamValue')).toContainText('10.0 GB'); + await expect(page.locator('#sessionDiskValue')).toContainText('61.0 GB'); + await expect(page.locator('#sessionOsValue')).toHaveText('Ubuntu 24.04.2 LTS'); + await expect(page.locator('#sessionCpuValue')).toContainText('%', { timeout: 6000 }); + await expect.poll(() => page.evaluate(() => window.__workspaceInsightSample || 0), { + timeout: 10000, + }).toBeGreaterThanOrEqual(3); + + const geometry = await page.evaluate(() => { + const box = selector => { + const rect = document.querySelector(selector).getBoundingClientRect(); + return { left: rect.left, right: rect.right, top: rect.top, bottom: rect.bottom, width: rect.width }; + }; + return { + terminal: box('#terminalGrid'), + files: box('#sessionFilesPanel'), + insights: box('#sessionInsightsCard'), + notepad: box('.notepad-section'), + viewport: { width: innerWidth, height: innerHeight }, + }; + }); + expect(geometry.terminal.width).toBeGreaterThan(650); + expect(geometry.files.width).toBeGreaterThanOrEqual(360); + expect(geometry.terminal.right).toBeLessThanOrEqual(geometry.files.left + 1); + expect(geometry.insights.bottom).toBeLessThanOrEqual(geometry.notepad.top + 1); + expect(geometry.files.right).toBeLessThanOrEqual(geometry.insights.left + 1); + expect(geometry.files.bottom).toBeLessThanOrEqual(geometry.viewport.height); + + const screenshotPath = capturePath(testInfo); + await page.screenshot({ + path: screenshotPath, + animations: 'disabled', + caret: 'hide', + }); + expect(pngSize(screenshotPath)).toEqual({ width: 2560, height: 1440 }); + + await page.evaluate(() => SessionManager.setSplitLayout(2)); + await expect(page.locator('#sessionFilesPanel')).toBeHidden(); + await expect(toggle).toBeDisabled(); + await page.evaluate(() => SessionManager.setSplitLayout(1)); + await expect(toggle).toBeEnabled(); + await expect(toggle).toHaveAttribute('aria-pressed', 'false'); + await expect(page.locator('#sessionFilesPanel')).toBeHidden(); + await assertNoExternalRequests(page); +}); diff --git a/tests/js/session-files-panel.test.js b/tests/js/session-files-panel.test.js index 8d6a4a2..bc3b660 100644 --- a/tests/js/session-files-panel.test.js +++ b/tests/js/session-files-panel.test.js @@ -199,7 +199,7 @@ test('uses existing mutation contracts and refreshes after completion', () => { payload: { session_id: 'session-a', remote_path: '/home/ops/releases' }, }, { - event: 'rename_item', + event: 'rename_file', payload: { session_id: 'session-a', old_path: '/home/ops/old.txt', @@ -234,4 +234,3 @@ test('closes into an inert state and reports disconnects', () => { }); assert.equal(state.renders.length, count); }); - diff --git a/tests/js/session-workspace.test.js b/tests/js/session-workspace.test.js new file mode 100644 index 0000000..4ef28fe --- /dev/null +++ b/tests/js/session-workspace.test.js @@ -0,0 +1,105 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { createCoordinator } = require('../../static/js/session-workspace.js'); + +function createHarness() { + const calls = []; + const filesPanel = { + open(id, session) { calls.push(['files.open', id, session.host]); }, + follow(id, session) { calls.push(['files.follow', id, session.host]); }, + close() { calls.push(['files.close']); }, + setDisconnected(id) { calls.push(['files.disconnected', id]); }, + }; + const insights = { + setSession(id, connected) { calls.push(['insights.session', id, connected]); }, + setVisible(visible) { calls.push(['insights.visible', visible]); }, + }; + const renders = []; + const coordinator = createCoordinator({ + filesPanel, + insights, + isDesktop: () => true, + render: state => renders.push(state), + }); + return { coordinator, calls, renders }; +} + +test('opens embedded SFTP only for a connected session in layout 1', () => { + const { coordinator, calls } = createHarness(); + coordinator.update({ layout: 1, sessionId: 's1', session: { host: 'alpha', connected: true } }); + + assert.equal(coordinator.toggleSftp(), true); + assert.deepEqual(calls.slice(-1), [['files.open', 's1', 'alpha']]); + assert.equal(coordinator.getState().sftpOpen, true); +}); + +test('refuses SFTP without a connected session or on a narrow viewport', () => { + const { coordinator } = createHarness(); + coordinator.update({ layout: 1, sessionId: null, session: null }); + assert.equal(coordinator.toggleSftp(), false); + + const filesPanel = { open() { throw new Error('must not open'); }, close() {} }; + const narrow = createCoordinator({ filesPanel, insights: {}, isDesktop: () => false }); + narrow.update({ layout: 1, sessionId: 's1', session: { connected: true } }); + assert.equal(narrow.toggleSftp(), false); +}); + +test('follows the active session while SFTP is open and always retargets insights', () => { + const { coordinator, calls } = createHarness(); + coordinator.update({ layout: 1, sessionId: 's1', session: { host: 'alpha', connected: true } }); + coordinator.toggleSftp(); + calls.length = 0; + + coordinator.update({ layout: 1, sessionId: 's2', session: { host: 'beta', connected: true } }); + + assert.deepEqual(calls, [ + ['insights.session', 's2', true], + ['files.follow', 's2', 'beta'], + ]); +}); + +test('switching to 2 or 4 closes SFTP and returning to 1 keeps it closed', () => { + const { coordinator, calls } = createHarness(); + coordinator.update({ layout: 1, sessionId: 's1', session: { host: 'alpha', connected: true } }); + coordinator.toggleSftp(); + calls.length = 0; + + coordinator.update({ layout: 2, sessionId: 's1', session: { host: 'alpha', connected: true } }); + coordinator.update({ layout: 1, sessionId: 's1', session: { host: 'alpha', connected: true } }); + + assert.deepEqual(calls.filter(call => call[0].startsWith('files.')), [['files.close']]); + assert.equal(coordinator.getState().sftpOpen, false); +}); + +test('disconnect closes SFTP and marks insights disconnected', () => { + const { coordinator, calls } = createHarness(); + coordinator.update({ layout: 1, sessionId: 's1', session: { host: 'alpha', connected: true } }); + coordinator.toggleSftp(); + calls.length = 0; + + coordinator.update({ layout: 1, sessionId: 's1', session: { host: 'alpha', connected: false } }); + + assert.deepEqual(calls, [ + ['insights.session', 's1', false], + ['files.disconnected', 's1'], + ['files.close'], + ]); + assert.equal(coordinator.getState().sftpOpen, false); +}); + +test('visibility pauses and resumes live insights without changing SFTP state', () => { + const { coordinator, calls } = createHarness(); + coordinator.update({ layout: 1, sessionId: 's1', session: { host: 'alpha', connected: true } }); + coordinator.toggleSftp(); + calls.length = 0; + + coordinator.setVisible(false); + coordinator.setVisible(true); + + assert.deepEqual(calls, [ + ['insights.visible', false], + ['insights.visible', true], + ]); + assert.equal(coordinator.getState().sftpOpen, true); +}); diff --git a/tests/test_public_product_surface.py b/tests/test_public_product_surface.py index 003b76d..28b9db7 100644 --- a/tests/test_public_product_surface.py +++ b/tests/test_public_product_surface.py @@ -9,6 +9,7 @@ README = ROOT / "README.md" PAGES_WORKFLOW = ROOT / ".github" / "workflows" / "graph-pages.yml" LANDING_PAGE = ROOT / "site" / "index.html" +SESSION_WORKSPACE_IMAGE = ROOT / "assets" / "session-workspace.png" @pytest.mark.parametrize( @@ -40,3 +41,17 @@ def test_product_landing_page_uses_current_connection_terminology(): landing_page = LANDING_PAGE.read_text(encoding="utf-8") for obsolete_label in ("New Connection", "New SSH Connection", "Profiles"): assert obsolete_label not in landing_page + + +def test_public_surfaces_use_the_large_real_session_workspace_capture(): + """The new workspace is shown with a desktop-sized product capture.""" + image_reference = "assets/session-workspace.png" + assert image_reference in README.read_text(encoding="utf-8") + assert image_reference in LANDING_PAGE.read_text(encoding="utf-8") + + png = SESSION_WORKSPACE_IMAGE.read_bytes() + assert png[:8] == b"\x89PNG\r\n\x1a\n" + width = int.from_bytes(png[16:20], "big") + height = int.from_bytes(png[20:24], "big") + assert width >= 1920 + assert height >= 1080 From 87a07d8a0a28e481ef02ff335dbb793f3b70b5ec Mon Sep 17 00:00:00 2001 From: bifrost0x Date: Mon, 3 Aug 2026 23:35:32 +0200 Subject: [PATCH 06/14] fix: isolate session workspace state --- app/session_insights.py | 34 ++++++++--- app/socket_events.py | 83 ++++++++++++++++++--------- static/js/session-files-panel.js | 30 ++++++++-- static/js/session-workspace-ui.js | 24 ++++++-- static/js/sftp-file-manager.js | 7 ++- tests/e2e/session-workspace.spec.js | 18 ++++++ tests/js/session-files-panel.test.js | 80 ++++++++++++++++++++++---- tests/js/sftp-transfer-queue.test.js | 70 ++++++++++++++++++++++ tests/test_session_insights.py | 50 +++++++++++++++- tests/test_session_insights_socket.py | 26 ++++++++- tests/test_sftp_consumer.py | 49 ++++++++++++++++ 11 files changed, 415 insertions(+), 56 deletions(-) create mode 100644 tests/test_sftp_consumer.py diff --git a/app/session_insights.py b/app/session_insights.py index 85e288a..db06cad 100644 --- a/app/session_insights.py +++ b/app/session_insights.py @@ -9,6 +9,7 @@ DEFAULT_MAX_BYTES = 16 * 1024 DEFAULT_TIMEOUT = 2.0 +REQUEST_RATE_LIMIT = '30 per minute' _collector_locks = {} _collector_locks_guard = Lock() @@ -121,16 +122,35 @@ def parse_linux_stats(text, *, max_bytes=DEFAULT_MAX_BYTES): } -def _lock_for_session(session_id): +def _acquire_session_lock(session_id): with _collector_locks_guard: - return _collector_locks.setdefault(session_id, Lock()) + 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): - collector_lock.release() with _collector_locks_guard: - if _collector_locks.get(session_id) is collector_lock: - _collector_locks.pop(session_id, None) + 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, @@ -139,8 +159,8 @@ def collect_linux_stats(session_id, *, timeout=DEFAULT_TIMEOUT, if not isinstance(session_id, str) or not session_id: return None, 'unavailable' - collector_lock = _lock_for_session(session_id) - if not collector_lock.acquire(blocking=False): + collector_lock = _acquire_session_lock(session_id) + if collector_lock is None: return None, 'busy' channel = None diff --git a/app/socket_events.py b/app/socket_events.py index 8a5dd9d..4843c77 100644 --- a/app/socket_events.py +++ b/app/socket_events.py @@ -31,6 +31,22 @@ STORAGE_ERROR_MESSAGE = ( 'Stored data is unreadable. Please restore or remove it.' ) +SESSION_FILES_CONSUMER = 'session-panel' + + +def _with_sftp_consumer(data, payload): + """Correlate compact session-panel replies without changing legacy SFTP.""" + if isinstance(data, dict) and data.get('consumer') == SESSION_FILES_CONSUMER: + correlated = {**payload, 'consumer': SESSION_FILES_CONSUMER} + session_id = data.get('session_id') + if isinstance(session_id, str) and session_id: + correlated.setdefault('session_id', session_id) + return correlated + return payload + + +def _emit_sftp_error(data, message): + emit('error', _with_sftp_consumer(data, {'error': message})) class _CombinedCancellation: @@ -911,7 +927,7 @@ def handle_list_directory(data, current_user=None): remote_path = data.get('remote_path', '.') if not session_id: - emit('error', {'error': 'Session ID required'}) + _emit_sftp_error(data, 'Session ID required') return authorized = False @@ -925,7 +941,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_sftp_error(data, 'Unauthorized access to session') return files, error = sftp_handler.list_directory(session_id, remote_path) @@ -934,19 +950,19 @@ 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_sftp_error(data, f'Failed to list directory: {error}') 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', { + emit('directory_listing', _with_sftp_consumer(data, { 'session_id': session_id, 'path': remote_path, 'files': files - }) + })) 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_sftp_error(data, 'Failed to list directory') @socketio.on('set_theme') @socket_login_required @@ -1380,6 +1396,13 @@ def emit_unavailable(): 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', @@ -1625,25 +1648,27 @@ def handle_create_directory(data, current_user=None): remote_path = data.get('remote_path') if not all([session_id, remote_path]): - emit('error', {'error': 'Missing required fields'}) + _emit_sftp_error(data, 'Missing required fields') 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_sftp_error(data, 'Unauthorized access') return success, error = sftp_handler.create_directory(session_id, remote_path) if error: - emit('error', {'error': f'Failed to create directory: {error}'}) + _emit_sftp_error(data, f'Failed to create directory: {error}') else: - emit('directory_created', {'path': remote_path}) + emit('directory_created', _with_sftp_consumer( + data, {'path': remote_path} + )) except Exception as e: log_error("Create directory failed", error=str(e)) - emit('error', {'error': 'Failed to create directory'}) + _emit_sftp_error(data, 'Failed to create directory') @socketio.on('rename_file') @socket_login_required @@ -1655,26 +1680,29 @@ def handle_rename_file(data, current_user=None): new_path = data.get('new_path') if not all([session_id, old_path, new_path]): - emit('error', {'error': 'Missing required fields'}) + _emit_sftp_error(data, 'Missing required fields') 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_sftp_error(data, 'Unauthorized access') return success, error = sftp_handler.rename_item(session_id, old_path, new_path) if error: - emit('error', {'error': f'Failed to rename: {error}'}) + _emit_sftp_error(data, f'Failed to rename: {error}') else: - emit('file_renamed', {'old_path': old_path, 'new_path': new_path}) + emit('file_renamed', _with_sftp_consumer(data, { + 'old_path': old_path, + 'new_path': new_path, + })) log_info(f"Renamed: {old_path} -> {new_path}", user=current_user.username) except Exception as e: log_error("Rename failed", error=str(e)) - emit('error', {'error': 'Failed to rename'}) + _emit_sftp_error(data, 'Failed to rename') @socketio.on('delete_item') @socket_login_required @@ -1685,26 +1713,26 @@ def handle_delete_item(data, current_user=None): path = data.get('path') if not all([session_id, path]): - emit('error', {'error': 'Missing required fields'}) + _emit_sftp_error(data, 'Missing required fields') 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_sftp_error(data, 'Unauthorized access') return success, error = sftp_handler.delete_directory_recursive(session_id, path) if error: - emit('error', {'error': f'Failed to delete: {error}'}) + _emit_sftp_error(data, f'Failed to delete: {error}') else: - emit('item_deleted', {'path': path}) + emit('item_deleted', _with_sftp_consumer(data, {'path': path})) log_info(f"Deleted: {path}", user=current_user.username) except Exception as e: log_error("Delete failed", error=str(e)) - emit('error', {'error': 'Failed to delete'}) + _emit_sftp_error(data, 'Failed to delete') @socketio.on('get_home_directory') @socket_login_required @@ -1716,13 +1744,13 @@ def handle_get_home_directory(data, current_user=None): session_id = data.get('session_id') if not session_id: - emit('error', {'error': 'Session ID required'}) + _emit_sftp_error(data, 'Session ID required') 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_sftp_error(data, 'Unauthorized access') return _t1 = _time.time() @@ -1732,16 +1760,19 @@ 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_sftp_error(data, f'Failed to get home directory: {error}') 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', _with_sftp_consumer(data, { + 'session_id': session_id, + 'path': home_path, + })) 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_sftp_error(data, 'Failed to get home directory') @socketio.on('check_exists') @socket_login_required diff --git a/static/js/session-files-panel.js b/static/js/session-files-panel.js index 532a969..d836671 100644 --- a/static/js/session-files-panel.js +++ b/static/js/session-files-panel.js @@ -8,6 +8,7 @@ } }(typeof globalThis !== 'undefined' ? globalThis : this, function () { 'use strict'; + const CONSUMER = 'session-panel'; function normalizePath(value) { const raw = typeof value === 'string' ? value : '/'; @@ -89,7 +90,10 @@ status = 'loading'; error = null; renderState(); - socket.emit('get_home_directory', { session_id: sessionId }); + socket.emit('get_home_directory', { + session_id: sessionId, + consumer: CONSUMER, + }); } function navigate(nextPath) { @@ -103,6 +107,7 @@ socket.emit('list_directory', { session_id: sessionId, remote_path: path, + consumer: CONSUMER, }); } @@ -111,13 +116,13 @@ } function handleHome(data) { - if (!opened || data?.session_id !== sessionId) return; + if (!opened || data?.consumer !== CONSUMER || data?.session_id !== sessionId) return; homePath = normalizePath(data.path); navigate(homePath); } function handleListing(data) { - if (!opened || data?.session_id !== sessionId) return; + if (!opened || data?.consumer !== CONSUMER || data?.session_id !== sessionId) return; const responsePath = normalizePath(data.path); if (requestedPath !== responsePath) return; path = responsePath; @@ -129,16 +134,30 @@ renderState(); } - function handleMutation() { + function handleMutation(data) { + if (data?.consumer !== CONSUMER || data?.session_id !== sessionId) return; refresh(); } + function handleError(data) { + if ( + !opened + || data?.consumer !== CONSUMER + || data?.session_id !== sessionId + ) return; + status = 'error'; + error = typeof data.error === 'string' ? data.error : 'SFTP unavailable'; + requestedPath = null; + renderState(); + } + const listeners = { home_directory: handleHome, directory_listing: handleListing, directory_created: handleMutation, file_renamed: handleMutation, item_deleted: handleMutation, + error: handleError, }; Object.entries(listeners).forEach(([event, handler]) => { socket.on(event, handler); @@ -229,6 +248,7 @@ socket.emit('create_directory', { session_id: sessionId, remote_path: joinPath(path, name.trim()), + consumer: CONSUMER, }); return true; }, @@ -240,6 +260,7 @@ session_id: sessionId, old_path: joinPath(path, item.name), new_path: joinPath(path, name.trim()), + consumer: CONSUMER, }); return true; }, @@ -250,6 +271,7 @@ socket.emit('delete_item', { session_id: sessionId, path: joinPath(path, item.name), + consumer: CONSUMER, }); return true; }, diff --git a/static/js/session-workspace-ui.js b/static/js/session-workspace-ui.js index 85b5cc7..525b684 100644 --- a/static/js/session-workspace-ui.js +++ b/static/js/session-workspace-ui.js @@ -89,6 +89,7 @@ rename: documentRef.getElementById('sessionFilesRenameBtn'), delete: documentRef.getElementById('sessionFilesDeleteBtn'), uploadInput: documentRef.getElementById('sessionFilesUploadInput'), + notepadPanel: documentRef.getElementById('notepadPanel'), insightsHost: documentRef.getElementById('sessionInsightsHost'), insightsState: documentRef.getElementById('sessionInsightsState'), cpuGauge: documentRef.getElementById('sessionCpuGauge'), @@ -258,13 +259,28 @@ if (root.confirm('Delete the selected remote item?')) filesController.deleteSelected(); }); + 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', () => { - coordinator.setVisible(documentRef.visibilityState !== 'hidden'); + documentRef.addEventListener('visibilitychange', syncInsightsVisibility); + desktopQuery.addEventListener?.('change', () => { + syncInsightsVisibility(); + sync(); }); - const desktopQuery = root.matchMedia('(min-width: 851px)'); - desktopQuery.addEventListener?.('change', 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; } diff --git a/static/js/sftp-file-manager.js b/static/js/sftp-file-manager.js index 743a4f6..ccb8d4a 100644 --- a/static/js/sftp-file-manager.js +++ b/static/js/sftp-file-manager.js @@ -499,7 +499,7 @@ class SFTPFileManager { if (!this.socket) return; this.socket.on('directory_listing', (data) => { - if (!this.isOpen) return; + if (!this.isOpen || data?.consumer) return; ['left', 'right'].forEach(pane => { const state = this.panes[pane]; @@ -520,6 +520,7 @@ class SFTPFileManager { }); this.socket.on('home_directory', (data) => { + if (data?.consumer) return; ['left', 'right'].forEach(pane => { const state = this.panes[pane]; if (state.type === 'ssh' && @@ -535,17 +536,20 @@ class SFTPFileManager { }); this.socket.on('directory_created', (data) => { + if (data?.consumer) return; if (this.uploadBatches?.size > 0) return; this.showNotification(`${this.t('fm.folderCreated', 'Folder created')}: ${data.path}`, 'success'); this.refreshBothPanes(); }); this.socket.on('file_renamed', (data) => { + if (data?.consumer) return; this.showNotification(this.t('fm.renamedSuccess', 'Renamed successfully'), 'success'); this.refreshBothPanes(); }); this.socket.on('item_deleted', (data) => { + if (data?.consumer) return; this.showNotification(`${this.t('fm.deleted', 'Deleted')}: ${data.path}`, 'success'); this.refreshBothPanes(); }); @@ -584,6 +588,7 @@ class SFTPFileManager { }); this.socket.on('error', (data) => { + if (data?.consumer) return; const errorMsg = data.error || data.message || 'Unknown error'; console.error('[FM] SFTP Error received:', errorMsg, data); diff --git a/tests/e2e/session-workspace.spec.js b/tests/e2e/session-workspace.spec.js index 9b7cfe4..22bbce1 100644 --- a/tests/e2e/session-workspace.spec.js +++ b/tests/e2e/session-workspace.spec.js @@ -71,6 +71,7 @@ async function seedLinuxSession(page) { deliver('home_directory', { session_id: payload.session_id, path: '/srv/webssh/current', + consumer: 'session-panel', }); return window.socket; } @@ -79,6 +80,7 @@ async function seedLinuxSession(page) { session_id: payload.session_id, path: payload.remote_path, files: fileRows, + consumer: 'session-panel', }); return window.socket; } @@ -165,6 +167,11 @@ test('single-session workspace combines terminal, SFTP, live Linux stats, and no }); expect(pngSize(screenshotPath)).toEqual({ width: 2560, height: 1440 }); + const samplesBeforeCollapse = await page.evaluate(() => window.__workspaceInsightSample); + await page.locator('#notepadToggle').click(); + await page.waitForTimeout(4500); + expect(await page.evaluate(() => window.__workspaceInsightSample)).toBe(samplesBeforeCollapse); + await page.evaluate(() => SessionManager.setSplitLayout(2)); await expect(page.locator('#sessionFilesPanel')).toBeHidden(); await expect(toggle).toBeDisabled(); @@ -174,3 +181,14 @@ test('single-session workspace combines terminal, SFTP, live Linux stats, and no await expect(page.locator('#sessionFilesPanel')).toBeHidden(); await assertNoExternalRequests(page); }); + +test('mobile workspace does not poll hidden Linux telemetry', async ({ page }) => { + await page.setViewportSize({ width: 800, height: 900 }); + await login(page); + await seedLinuxSession(page); + await page.waitForTimeout(500); + + expect(await page.evaluate(() => window.__workspaceInsightSample || 0)).toBe(0); + await expect(page.locator('#sessionInsightsCard')).toBeHidden(); + await assertNoExternalRequests(page); +}); diff --git a/tests/js/session-files-panel.test.js b/tests/js/session-files-panel.test.js index bc3b660..c561889 100644 --- a/tests/js/session-files-panel.test.js +++ b/tests/js/session-files-panel.test.js @@ -7,6 +7,7 @@ const filesPanel = require('../../static/js/session-files-panel.js'); function runtime() { const emitted = []; const handlers = new Map(); + const rawHandlers = new Map(); const renders = []; const uploads = []; const downloads = []; @@ -16,7 +17,11 @@ function runtime() { emitted.push({ event, payload }); }, on(event, handler) { - handlers.set(event, handler); + rawHandlers.set(event, handler); + handlers.set(event, payload => handler({ + ...payload, + consumer: 'session-panel', + })); }, off(event, handler) { if (handlers.get(event) === handler) handlers.delete(event); @@ -40,7 +45,7 @@ function runtime() { }, }); return { - controller, socket, emitted, handlers, renders, + controller, socket, emitted, handlers, rawHandlers, renders, uploads, downloads, previews, }; } @@ -60,7 +65,7 @@ test('opens on the active session home and follows a replacement session', () => assert.deepEqual(state.emitted, [{ event: 'get_home_directory', - payload: { session_id: 'session-a' }, + payload: { session_id: 'session-a', consumer: 'session-panel' }, }]); assert.equal(state.renders.at(-1).status, 'loading'); assert.equal(state.renders.at(-1).label, 'ops@edge.example'); @@ -71,7 +76,11 @@ test('opens on the active session home and follows a replacement session', () => }); assert.deepEqual(state.emitted.at(-1), { event: 'list_directory', - payload: { session_id: 'session-a', remote_path: '/home/ops' }, + payload: { + session_id: 'session-a', + remote_path: '/home/ops', + consumer: 'session-panel', + }, }); state.controller.follow('session-b', { username: 'deploy', host: 'build.example' }); @@ -79,7 +88,7 @@ test('opens on the active session home and follows a replacement session', () => assert.equal(state.renders.at(-1).selectedIndex, -1); assert.deepEqual(state.emitted.at(-1), { event: 'get_home_directory', - payload: { session_id: 'session-b' }, + payload: { session_id: 'session-b', consumer: 'session-panel' }, }); }); @@ -131,7 +140,11 @@ test('opens folders inside the panel and files in the existing preview', () => { state.controller.activate(0); assert.deepEqual(state.emitted.at(-1), { event: 'list_directory', - payload: { session_id: 'session-a', remote_path: '/home/ops/logs' }, + payload: { + session_id: 'session-a', + remote_path: '/home/ops/logs', + consumer: 'session-panel', + }, }); state.controller.navigate('/home/ops'); state.handlers.get('directory_listing')({ @@ -196,7 +209,11 @@ test('uses existing mutation contracts and refreshes after completion', () => { assert.deepEqual(state.emitted.slice(-3), [ { event: 'create_directory', - payload: { session_id: 'session-a', remote_path: '/home/ops/releases' }, + payload: { + session_id: 'session-a', + remote_path: '/home/ops/releases', + consumer: 'session-panel', + }, }, { event: 'rename_file', @@ -204,18 +221,30 @@ test('uses existing mutation contracts and refreshes after completion', () => { session_id: 'session-a', old_path: '/home/ops/old.txt', new_path: '/home/ops/current.txt', + consumer: 'session-panel', }, }, { event: 'delete_item', - payload: { session_id: 'session-a', path: '/home/ops/old.txt' }, + payload: { + session_id: 'session-a', + path: '/home/ops/old.txt', + consumer: 'session-panel', + }, }, ]); - state.handlers.get('directory_created')({ path: '/home/ops/releases' }); + state.handlers.get('directory_created')({ + session_id: 'session-a', + path: '/home/ops/releases', + }); assert.deepEqual(state.emitted.at(-1), { event: 'list_directory', - payload: { session_id: 'session-a', remote_path: '/home/ops' }, + payload: { + session_id: 'session-a', + remote_path: '/home/ops', + consumer: 'session-panel', + }, }); }); @@ -234,3 +263,34 @@ test('closes into an inert state and reports disconnects', () => { }); assert.equal(state.renders.length, count); }); + + +test('renders only correlated session panel errors', () => { + const state = runtime(); + state.controller.open('session-a', { username: 'ops', host: 'edge.example' }); + + state.rawHandlers.get('error')({ error: 'Unrelated failure' }); + assert.equal(state.renders.at(-1).status, 'loading'); + + state.handlers.get('error')({ + session_id: 'session-a', + error: 'Failed to list directory', + }); + assert.equal(state.renders.at(-1).status, 'error'); + assert.equal(state.renders.at(-1).error, 'Failed to list directory'); +}); + + +test('ignores a delayed correlated error after following another session', () => { + const state = runtime(); + state.controller.open('session-a', { username: 'ops', host: 'edge.example' }); + state.controller.follow('session-b', { username: 'deploy', host: 'build.example' }); + + state.handlers.get('error')({ + session_id: 'session-a', + error: 'Old session failed', + }); + + assert.equal(state.renders.at(-1).sessionId, 'session-b'); + assert.equal(state.renders.at(-1).status, 'loading'); +}); diff --git a/tests/js/sftp-transfer-queue.test.js b/tests/js/sftp-transfer-queue.test.js index a2f4cef..b993f6e 100644 --- a/tests/js/sftp-transfer-queue.test.js +++ b/tests/js/sftp-transfer-queue.test.js @@ -190,11 +190,81 @@ test('directory creation events do not refresh panes in the middle of an upload assert.equal(notifications, 0); manager.uploadBatches.clear(); + listeners.directory_created({ + path: '/session-panel-folder', + consumer: 'session-panel', + }); + assert.equal(refreshes, 0); + assert.equal(notifications, 0); + listeners.directory_created({ path: '/manual-folder' }); assert.equal(refreshes, 1); assert.equal(notifications, 1); }); +test('dual-pane listings ignore embedded session panel responses', () => { + const listeners = {}; + let renders = 0; + let pathUpdates = 0; + const manager = Object.create(SFTPFileManager.prototype); + Object.assign(manager, { + socket: { on(event, callback) { listeners[event] = callback; } }, + isOpen: true, + panes: { + left: { + type: 'ssh', + sessionId: 'shared-session', + connectionId: null, + path: '/dual-pane', + files: [{ name: 'original.txt' }], + }, + right: { type: 'browser' }, + }, + updatePathInput() { pathUpdates += 1; }, + renderPane() { renders += 1; }, + }); + manager.setupSocketListeners(); + + listeners.directory_listing({ + session_id: 'shared-session', + path: '/embedded-panel', + files: [{ name: 'foreign.txt' }], + consumer: 'session-panel', + }); + + assert.equal(manager.panes.left.path, '/dual-pane'); + assert.equal(manager.panes.left.files[0].name, 'original.txt'); + assert.equal(pathUpdates, 0); + assert.equal(renders, 0); +}); + +test('dual-pane error handling ignores embedded session panel failures', () => { + const listeners = {}; + let renders = 0; + let notifications = 0; + const manager = Object.create(SFTPFileManager.prototype); + Object.assign(manager, { + socket: { on(event, callback) { listeners[event] = callback; } }, + panes: { + left: { type: 'ssh', loading: true, error: null }, + right: { type: 'browser', loading: false }, + }, + renderPane() { renders += 1; }, + showNotification() { notifications += 1; }, + }); + manager.setupSocketListeners(); + + listeners.error({ + error: 'Embedded panel failed', + consumer: 'session-panel', + }); + + assert.equal(manager.panes.left.loading, true); + assert.equal(manager.panes.left.error, null); + assert.equal(renders, 0); + assert.equal(notifications, 0); +}); + test('upload batch finishes on complete error and cancel then refreshes its session pane once', async () => { const listeners = {}; const refreshed = []; diff --git a/tests/test_session_insights.py b/tests/test_session_insights.py index 8529fc4..d45595c 100644 --- a/tests/test_session_insights.py +++ b/tests/test_session_insights.py @@ -1,5 +1,5 @@ import pytest -from threading import Lock +from threading import Event, Lock, Thread from app import session_insights @@ -192,7 +192,9 @@ def test_collect_linux_stats_rejects_overlapping_collection(monkeypatch): held_lock = Lock() held_lock.acquire() monkeypatch.setitem( - session_insights._collector_locks, 'owned-session', held_lock + session_insights._collector_locks, + 'owned-session', + {'lock': held_lock, 'references': 1}, ) stats, error = session_insights.collect_linux_stats('owned-session') @@ -201,6 +203,50 @@ def test_collect_linux_stats_rejects_overlapping_collection(monkeypatch): assert error == 'busy' +def test_session_collector_lock_survives_release_to_waiter_handoff(monkeypatch): + class ControlledLock: + def __init__(self): + self.inner = Lock() + self.acquire_calls = 0 + self.waiter_ready = Event() + self.allow_waiter = Event() + + def acquire(self, blocking=False): + self.acquire_calls += 1 + if self.acquire_calls == 2: + self.waiter_ready.set() + assert self.allow_waiter.wait(timeout=1) + return self.inner.acquire(blocking=blocking) + + def release(self): + self.inner.release() + + controlled = ControlledLock() + monkeypatch.setitem( + session_insights._collector_locks, + 'handoff-session', + {'lock': controlled, 'references': 0}, + ) + first = session_insights._acquire_session_lock('handoff-session') + waiter_result = [] + waiter = Thread( + target=lambda: waiter_result.append( + session_insights._acquire_session_lock('handoff-session') + ) + ) + waiter.start() + assert controlled.waiter_ready.wait(timeout=1) + + session_insights._release_session_lock('handoff-session', first) + controlled.allow_waiter.set() + waiter.join(timeout=1) + + assert waiter_result == [controlled] + assert session_insights._acquire_session_lock('handoff-session') is None + session_insights._release_session_lock('handoff-session', controlled) + assert 'handoff-session' not in session_insights._collector_locks + + def test_collect_linux_stats_rejects_nonzero_remote_status(monkeypatch): install_session(monkeypatch) channel = FakeChannel(status=1) diff --git a/tests/test_session_insights_socket.py b/tests/test_session_insights_socket.py index 9289ec4..9624c07 100644 --- a/tests/test_session_insights_socket.py +++ b/tests/test_session_insights_socket.py @@ -5,7 +5,8 @@ from app import socket_events -def invoke(monkeypatch, payload, *, owned=True, collector_result=None): +def invoke(monkeypatch, payload, *, owned=True, collector_result=None, + rate_limited=False): emitted = [] collector_calls = [] monkeypatch.setattr( @@ -18,6 +19,11 @@ def invoke(monkeypatch, payload, *, owned=True, collector_result=None): 'verify_session_ownership', lambda session_id, user_id: owned, ) + monkeypatch.setattr( + socket_events, + 'check_socket_rate_limit', + lambda user_id, endpoint, limit: rate_limited, + ) def collect(session_id): collector_calls.append(session_id) @@ -86,6 +92,23 @@ def test_session_insights_socket_rejects_unowned_session_before_collection( })] +def test_session_insights_socket_rate_limits_before_ownership_and_collection( + monkeypatch): + emitted, calls = invoke( + monkeypatch, + {'session_id': 'owned-session', 'request_id': 'sample-limited'}, + rate_limited=True, + ) + + assert calls == [] + assert emitted == [('session_insights', { + 'success': False, + 'session_id': 'owned-session', + 'request_id': 'sample-limited', + 'error': 'Session insights unavailable', + })] + + @pytest.mark.parametrize( 'payload', [ @@ -124,4 +147,3 @@ def test_session_insights_socket_returns_generic_collector_failure(monkeypatch): 'request_id': 'sample-3', 'error': 'Session insights unavailable', })] - diff --git a/tests/test_sftp_consumer.py b/tests/test_sftp_consumer.py new file mode 100644 index 0000000..dd62eb4 --- /dev/null +++ b/tests/test_sftp_consumer.py @@ -0,0 +1,49 @@ +from app import socket_events + + +def test_session_panel_consumer_is_correlated_on_sftp_response(): + payload = socket_events._with_sftp_consumer( + {'consumer': 'session-panel'}, + {'session_id': 'owned', 'path': '/srv/app'}, + ) + + assert payload == { + 'session_id': 'owned', + 'path': '/srv/app', + 'consumer': 'session-panel', + } + + +def test_legacy_sftp_response_remains_unchanged(): + original = {'session_id': 'owned', 'path': '/srv/app'} + + assert socket_events._with_sftp_consumer({}, original) is original + + +def test_arbitrary_consumer_value_is_not_reflected(): + payload = socket_events._with_sftp_consumer( + {'consumer': ' - + @@ -947,7 +930,7 @@

File Preview

- + - + diff --git a/tests/js/terminal-manager-layout.test.js b/tests/js/terminal-manager-layout.test.js new file mode 100644 index 0000000..6c951fe --- /dev/null +++ b/tests/js/terminal-manager-layout.test.js @@ -0,0 +1,89 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +global.window = { + addEventListener() {}, + visualViewport: null, +}; +global.document = {}; +global.navigator = {}; + +require('../../static/js/terminal-manager.js'); +const TerminalManager = global.window.TerminalManager; + +function terminalInWrapper(unassigned) { + return { + cols: 120, + rows: 40, + element: { + closest(selector) { + assert.equal(selector, '.terminal-wrapper'); + return { + classList: { + contains(name) { + return name === 'unassigned' && unassigned; + }, + }, + }; + }, + }, + }; +} + +test('fitAllTerminals does not reflow unassigned hidden sessions', () => { + const visible = terminalInWrapper(false); + const hidden = terminalInWrapper(true); + TerminalManager.sessionTerminals = { + visibleSession: ['visibleTerminal'], + hiddenSession: ['hiddenTerminal'], + }; + TerminalManager.terminals = { + visibleTerminal: visible, + hiddenTerminal: hidden, + }; + TerminalManager.fitAddons = { + visibleTerminal: { + fit() { + visible.cols = 100; + visible.rows = 32; + }, + }, + hiddenTerminal: { + fit() { + hidden.cols = 2; + hidden.rows = 1; + }, + }, + }; + + TerminalManager.fitAllTerminals(); + + assert.deepEqual({ cols: visible.cols, rows: visible.rows }, { cols: 100, rows: 32 }); + assert.deepEqual({ cols: hidden.cols, rows: hidden.rows }, { cols: 120, rows: 40 }); +}); + +test('font size changes update hidden options without fitting hidden terminals', () => { + const visible = terminalInWrapper(false); + const hidden = terminalInWrapper(true); + visible.options = { fontSize: 14 }; + hidden.options = { fontSize: 14 }; + TerminalManager.sessionTerminals = { + visibleSession: ['visibleTerminal'], + hiddenSession: ['hiddenTerminal'], + }; + TerminalManager.terminals = { + visibleTerminal: visible, + hiddenTerminal: hidden, + }; + TerminalManager.fitAddons = { + visibleTerminal: { fit() { visible.cols = 96; } }, + hiddenTerminal: { fit() { hidden.cols = 2; } }, + }; + + TerminalManager.updateFontSize(16); + + assert.equal(visible.options.fontSize, 16); + assert.equal(hidden.options.fontSize, 16); + assert.equal(visible.cols, 96); + assert.equal(hidden.cols, 120); +}); diff --git a/tests/test_profile_launcher_ui.py b/tests/test_profile_launcher_ui.py index abae4f3..c7ea1ba 100644 --- a/tests/test_profile_launcher_ui.py +++ b/tests/test_profile_launcher_ui.py @@ -31,6 +31,7 @@ def test_merged_profile_frontend_assets_have_distinct_cache_versions(): "filename='js/connection-launcher.js'": '?v=1', "filename='js/profile-manager.js'": '?v=6', "filename='js/session-manager.js'": '?v=4', + "filename='js/terminal-manager.js'": '?v=4', "filename='js/jump-host-manager.js'": '?v=4', "filename='js/command-library.js'": '?v=3', "filename='js/command-set-manager.js'": '?v=2',