From fedf91f04977894cff654fd2a10e9cc2811f81a2 Mon Sep 17 00:00:00 2001 From: bifrost0x Date: Thu, 23 Jul 2026 07:24:33 +0200 Subject: [PATCH] Add flexible post-connect command profiles --- README.md | 33 +- app/command_manager.py | 8 +- app/command_set_manager.py | 58 ++- app/post_connect_manager.py | 166 +++++++++ app/profile_manager.py | 179 +++++---- app/socket_events.py | 58 +-- static/css/style.css | 174 ++++++++- static/js/app.js | 74 +--- static/js/command-library.js | 29 +- static/js/command-set-manager.js | 3 +- static/js/command-workspace.js | 6 +- static/js/connection-command-manager.js | 272 ++++++++++++++ static/js/i18n.js | 108 ++++++ static/js/jump-host-manager.js | 1 + static/js/profile-manager.js | 380 +++++++++++++++++++- templates/index.html | 180 ++++++++-- tests/js/connection-command-manager.test.js | 62 ++++ tests/test_command_set_manager.py | 29 ++ tests/test_command_set_socket_events.py | 178 ++++++++- tests/test_command_set_ui.py | 129 +++++-- tests/test_post_connect_manager.py | 226 ++++++++++++ tests/test_profile_launcher_ui.py | 16 +- tests/test_profile_manager.py | 145 ++++++++ tests/test_startup_command_ui_state.py | 2 +- tests/test_startup_commands.py | 21 +- 25 files changed, 2267 insertions(+), 270 deletions(-) create mode 100644 app/post_connect_manager.py create mode 100644 static/js/connection-command-manager.js create mode 100644 tests/js/connection-command-manager.test.js create mode 100644 tests/test_post_connect_manager.py diff --git a/README.md b/README.md index 8fb2948..51fc8e0 100644 --- a/README.md +++ b/README.md @@ -230,9 +230,15 @@ session from the WebSSH interface terminates its remote tmux session. ### Commands After Connecting -The connection dialog lets you select one optional, named **command set**. Use -**Create new** directly beside the selector, or open **Commands** and switch to -the **Command Sets** tab to manage all sets. The builder can search the complete +The connection dialog offers four explicit choices under **Run after +connecting**: nothing, one reusable **Command Set**, one saved **Command**, or +one-off **Free text**. Only the active mode is sent to the server. An exact +preview shows what will run before the connection is started. A selected +Command can use its saved parameters, an override, or an intentionally empty +override without modifying the library entry. + +Open **Commands** to manage both reusable commands and sets. The **Command +Sets** tab is first and opens by default. The builder can search the complete command library by name, command text, parameters, description, or category and filter the results by operating system. @@ -247,19 +253,24 @@ Steps can be reordered by drag and drop or by the accessible up/down buttons. Command workflow showing full-text search, user commands, sudo-enabled command sets, multiline steps, ordering, and profile assignment

-New command sets enable **Run commands with sudo** by default. When enabled, -WebSSH prefixes each non-empty resolved command line unless it already starts -with `sudo`; blank and comment-only lines remain unchanged. Existing command sets from an earlier version and sets produced by legacy conversion keep sudo disabled, so upgrading or converting does not change what runs. +**Run commands with sudo** is opt-in for new command sets. When enabled, WebSSH +prefixes each non-empty resolved command line unless it already starts with +`sudo`; blank and comment-only lines remain unchanged. Existing command sets +from an earlier version and sets produced by legacy conversion keep their saved +sudo setting, so upgrading or converting does not change what runs. WebSSH does not store or answer a sudo password. If the remote account requires one, its normal prompt appears in the terminal. The added prefixes count toward the existing maximum 4096 characters for the resolved command text. -Profiles store only the selected command-set ID. Editing a set or one of its -referenced library commands therefore updates every profile that uses it. A set -cannot be deleted while a profile references it, and a user-created library -command cannot be deleted while a set references it. The UI reports the -profiles or sets that must be changed first. +Profiles are managed independently from connecting under the account menu. +They can be created, inspected, updated, or deleted without opening an SSH +session. A profile stores the selected post-connect mode and only its relevant +reference or free text; credentials are never stored. Editing a referenced set +or library command therefore updates every profile that uses its saved +definition. A set cannot be deleted while a profile references it, and a +user-created library command cannot be deleted while a set or profile +references it. The UI reports the profiles or sets that must be changed first. After a new SSH connection succeeds, WebSSH resolves the latest referenced commands on the server, validates the combined text (maximum 4096 characters), diff --git a/app/command_manager.py b/app/command_manager.py index 89e0f93..6f64b06 100644 --- a/app/command_manager.py +++ b/app/command_manager.py @@ -149,7 +149,13 @@ def delete_user_command(user_id, command_id): if error: return False, error, [] if usages: - noun = 'command set' if len(usages) == 1 else 'command sets' + usage_types = {usage.get('type') for usage in usages} + if usage_types == {'command_set'}: + noun = 'command set' if len(usages) == 1 else 'command sets' + elif usage_types == {'profile'}: + noun = 'profile' if len(usages) == 1 else 'profiles' + else: + noun = 'reference' if len(usages) == 1 else 'references' return False, f'Command is used by {len(usages)} {noun}', usages with storage_lock(f'commands:{user_id}'): diff --git a/app/command_set_manager.py b/app/command_set_manager.py index a2b6041..b3b042b 100644 --- a/app/command_set_manager.py +++ b/app/command_set_manager.py @@ -402,13 +402,8 @@ def duplicate_command_set(user_id, command_set_id): return (copy.deepcopy(duplicate), None) if saved else (None, error) -def resolve_command_set(user_id, command_set_id): - command_set, error = get_command_set(user_id, command_set_id) - if error: - return None, error - commands, error = _command_index(user_id) - if error: - return None, error +def _resolve_loaded_command_set(command_set, commands): + """Resolve one already-loaded set using an already-built command index.""" _steps, resolved_parts, error = _normalize_steps( command_set.get('steps'), commands ) @@ -432,6 +427,37 @@ def resolve_command_set(user_id, command_set_id): return resolved, None +def resolve_command_set(user_id, command_set_id): + command_set, error = get_command_set(user_id, command_set_id) + if error: + return None, error + commands, error = _command_index(user_id) + if error: + return None, error + return _resolve_loaded_command_set(command_set, commands) + + +def load_command_sets_with_resolution(user_id): + """Load sets with exact previews without repeating command-library reads.""" + command_sets, error = load_command_sets(user_id) + if error: + return None, error + commands, error = _command_index(user_id) + if error: + return None, error + + enriched = [] + for command_set in command_sets: + item = copy.deepcopy(command_set) + resolved, resolution_error = _resolve_loaded_command_set(item, commands) + if resolution_error: + item['resolution_error'] = resolution_error + else: + item['resolved_command'] = resolved + enriched.append(item) + return enriched, None + + def get_command_usage(user_id, command_id): command_sets, error = load_command_sets(user_id) if error: @@ -441,7 +467,23 @@ def get_command_usage(user_id, command_id): steps = command_set.get('steps', []) if isinstance(command_set, dict) else [] if any(step.get('type') == 'library' and step.get('command_id') == command_id for step in steps if isinstance(step, dict)): - usages.append({'id': command_set.get('id'), 'name': command_set.get('name', '')}) + usages.append({ + 'id': command_set.get('id'), + 'name': command_set.get('name', ''), + 'type': 'command_set', + }) + + profiles, error = _load_profile_references(user_id) + if error: + return None, error + for profile in profiles: + if (isinstance(profile, dict) + and profile.get('command_id') == command_id): + usages.append({ + 'id': profile.get('id'), + 'name': profile.get('name', ''), + 'type': 'profile', + }) return usages, None diff --git a/app/post_connect_manager.py b/app/post_connect_manager.py new file mode 100644 index 0000000..e96f077 --- /dev/null +++ b/app/post_connect_manager.py @@ -0,0 +1,166 @@ +"""Canonical validation and resolution for post-connect command modes.""" + +from . import command_manager, command_set_manager +from .startup_commands import normalize_startup_commands + + +VALID_MODES = {'none', 'free_text', 'command', 'command_set'} + + +def infer_mode(payload): + """Return the explicit mode or infer one for legacy profile data.""" + payload = payload if isinstance(payload, dict) else {} + explicit = payload.get('startup_mode') + if explicit is not None: + return explicit + if payload.get('command_set_id'): + return 'command_set' + if payload.get('command_id'): + return 'command' + if payload.get('startup_commands'): + return 'free_text' + return 'none' + + +def _command_index(user_id): + """Return commands visible to one user, keyed by their stable IDs.""" + try: + commands = command_manager.get_all_commands(user_id) + except (OSError, ValueError, TypeError): + return None, 'Command library is unreadable' + + index = {} + for command in commands: + if not isinstance(command, dict): + continue + command_id = command.get('id') + command_text = command.get('command') + if isinstance(command_id, str) and isinstance(command_text, str): + index[command_id] = command + return index, None + + +def _get_owned_command(user_id, command_id): + if not isinstance(command_id, str) or not command_id: + return None, 'Command not found' + commands, error = _command_index(user_id) + if error: + return None, error + command = commands.get(command_id) + return (command, None) if command else (None, 'Command not found') + + +def _resolve_command(command, parameters_override=None, override_present=False): + parameters = parameters_override if override_present else command.get('parameters', '') + if parameters is None: + parameters = command.get('parameters', '') + if not isinstance(parameters, str): + return None, 'Command parameters must be a string' + if '\x00' in parameters: + return None, 'Commands cannot contain NUL bytes' + + command_text = command['command'] + resolved = command_text + (f' {parameters}' if parameters else '') + return normalize_startup_commands(resolved) + + +def _has_conflicting_references(mode, payload): + command_id = payload.get('command_id') + command_set_id = payload.get('command_set_id') + if mode == 'command': + return bool(command_set_id) + if mode == 'command_set': + return bool(command_id) + if mode == 'free_text': + return bool(command_id or command_set_id) + return False + + +def validate_configuration(user_id, payload): + """Validate one mode and return only the fields safe to persist.""" + if not isinstance(payload, dict): + return None, 'Invalid post-connect command configuration' + + mode = infer_mode(payload) + if mode not in VALID_MODES: + return None, 'Invalid post-connect command mode' + if _has_conflicting_references(mode, payload): + return None, 'Conflicting post-connect command configuration' + + if mode == 'none': + return {'startup_mode': 'none'}, None + + if mode == 'free_text': + normalized, error = normalize_startup_commands( + payload.get('startup_commands', '') + ) + if error: + return None, error + return { + 'startup_mode': mode, + 'startup_commands': normalized, + }, None + + if mode == 'command': + command, error = _get_owned_command(user_id, payload.get('command_id')) + if error: + return None, error + override_present = 'parameters_override' in payload + override = payload.get('parameters_override') + if override is not None and not isinstance(override, str): + return None, 'Command parameters must be a string' + _resolved, error = _resolve_command( + command, override, override_present=override_present + ) + if error: + return None, error + result = { + 'startup_mode': mode, + 'command_id': command['id'], + } + if override_present and override is not None: + result['parameters_override'] = override + return result, None + + command_set, error = command_set_manager.get_command_set( + user_id, payload.get('command_set_id') + ) + if error: + return None, error + return { + 'startup_mode': mode, + 'command_set_id': command_set['id'], + }, None + + +def resolve_configuration(user_id, payload): + """Resolve validated configuration to the exact terminal command string.""" + if not isinstance(payload, dict): + return None, 'Invalid post-connect command configuration' + mode = infer_mode(payload) + if mode not in VALID_MODES: + return None, 'Invalid post-connect command mode' + if _has_conflicting_references(mode, payload): + return None, 'Conflicting post-connect command configuration' + + if mode == 'none': + return '', None + if mode == 'free_text': + return normalize_startup_commands(payload.get('startup_commands', '')) + if mode == 'command_set': + return command_set_manager.resolve_command_set( + user_id, payload.get('command_set_id') + ) + + command, error = _get_owned_command(user_id, payload.get('command_id')) + if error: + return None, error + override_present = 'parameters_override' in payload + override = payload.get('parameters_override') + if override is not None and not isinstance(override, str): + return None, 'Command parameters must be a string' + return _resolve_command( + command, + override, + override_present=override_present, + ) diff --git a/app/profile_manager.py b/app/profile_manager.py index 8cf654e..45df946 100644 --- a/app/profile_manager.py +++ b/app/profile_manager.py @@ -1,11 +1,11 @@ import json -import uuid import re import ipaddress -from datetime import datetime -from pathlib import Path -import config -from .audit_logger import log_info, log_warning, log_error, log_debug +import uuid +from datetime import datetime, timezone + +from .audit_logger import log_error +from .post_connect_manager import validate_configuration from .storage_utils import storage_lock, atomic_write_json from .startup_commands import normalize_startup_commands @@ -82,84 +82,136 @@ def save_profiles(user_id, profiles): log_error(f"Error saving profiles", user_id=user_id, error=str(e)) return False -def add_profile(user_id, name, host, port, username, auth_type, key_id=None, - jump_host_id=None, startup_commands=None, command_set_id=None): - """Add a new connection profile for a specific user. +def _validate_profile_payload(user_id, payload): + """Validate storable profile fields without accepting credentials.""" + if not isinstance(payload, dict): + return None, 'Invalid profile data' + + name = payload.get('name') + host = payload.get('host') + username = payload.get('username') + auth_type = payload.get('auth_type') + if not all([name, host, username, auth_type]): + return None, 'Missing required fields' + if not isinstance(name, str) or not name.strip(): + return None, 'Invalid profile name' + name = name.strip()[:128] + + host = str(host).strip() + if not _is_valid_host(host): + return None, 'Invalid host format' - jump_host_id (optional): reference to a saved jump host (bastion). Only the id - is stored; the jump host details live in jump_hosts.json. - """ try: - if not all([name, host, username, auth_type]): - return None, "Missing required fields" + port = int(payload.get('port') or 22) + if not 1 <= port <= 65535: + return None, 'Port must be between 1 and 65535' + except (ValueError, TypeError): + return None, 'Invalid port number' - host = str(host).strip() - if not _is_valid_host(host): - return None, "Invalid host format" + username = str(username).strip() + if not re.match(r'^[a-zA-Z0-9_\-\.]{1,32}$', username): + return None, 'Invalid username format' + if auth_type not in {'password', 'key', 'tailscale'}: + return None, 'Invalid auth_type' - try: - port = int(port) if port else 22 - if not (1 <= port <= 65535): - return None, "Port must be between 1 and 65535" - except (ValueError, TypeError): - return None, "Invalid port number" + key_id = payload.get('key_id') + if auth_type == 'key' and not key_id: + return None, 'key_id required for key authentication' - username = str(username).strip() - if not re.match(r'^[a-zA-Z0-9_\-\.]{1,32}$', username): - return None, "Invalid username format" + post_connect, error = validate_configuration(user_id, payload) + if error: + return None, error - if auth_type not in ['password', 'key', 'tailscale']: - return None, "Invalid auth_type" + result = { + 'name': name, + 'host': host, + 'port': port, + 'username': username, + 'auth_type': auth_type, + 'key_id': key_id if auth_type == 'key' else None, + **post_connect, + } + jump_host_id = payload.get('jump_host_id') + if jump_host_id: + result['jump_host_id'] = str(jump_host_id)[:64] + return result, None - if auth_type == 'key' and not key_id: - return None, "key_id required for key authentication" - normalized_startup_commands = None - if startup_commands is not None: - normalized_startup_commands, error = normalize_startup_commands(startup_commands) +def upsert_profile(user_id, payload, preserve_legacy_fallback=False): + """Create or update a profile under the per-user coordinator lock.""" + try: + with storage_lock(f'command-config:{user_id}'): + validated, error = _validate_profile_payload(user_id, payload) if error: return None, error - with storage_lock(f'command-config:{user_id}'): - if command_set_id is not None: - from .command_set_manager import get_command_set - - command_set, error = get_command_set(user_id, command_set_id) + if preserve_legacy_fallback and payload.get('startup_commands'): + legacy, error = normalize_startup_commands( + payload['startup_commands'] + ) if error: return None, error - command_set_id = command_set['id'] - - profile = { - 'id': str(uuid.uuid4()), - 'name': name[:128], - 'host': host, - 'port': port, - 'username': username, - 'auth_type': auth_type, - 'key_id': key_id, - 'created_at': datetime.utcnow().isoformat() - } - - # Optional reference to a saved jump host (bastion). - if jump_host_id: - profile['jump_host_id'] = str(jump_host_id)[:64] - - if normalized_startup_commands: - profile['startup_commands'] = normalized_startup_commands - if command_set_id: - profile['command_set_id'] = command_set_id + if legacy: + validated['startup_commands'] = legacy with storage_lock(f'profiles:{user_id}'): profiles, error = _load_profiles_for_write(user_id) if error: return None, error - profiles.append(profile) + + profile_id = payload.get('id') + now = datetime.now(timezone.utc).isoformat() + if profile_id: + for index, existing in enumerate(profiles): + if existing.get('id') == profile_id: + result = { + **validated, + 'id': profile_id, + 'created_at': existing.get('created_at', now), + 'updated_at': now, + } + profiles[index] = result + break + else: + return None, 'Profile not found' + else: + result = { + **validated, + 'id': str(uuid.uuid4()), + 'created_at': now, + 'updated_at': now, + } + profiles.append(result) if save_profiles(user_id, profiles): - return profile, None - return None, "Failed to save profile" - except Exception as e: - return None, str(e) + return result, None + return None, 'Failed to save profile' + except Exception as exc: + log_error('Error saving profile', user_id=user_id, error=str(exc)) + return None, 'Failed to save profile' + + +def add_profile(user_id, name, host, port, username, auth_type, key_id=None, + jump_host_id=None, startup_commands=None, command_set_id=None): + """Compatibility wrapper for callers that create legacy profile payloads.""" + payload = { + 'name': name, + 'host': host, + 'port': port, + 'username': username, + 'auth_type': auth_type, + 'key_id': key_id, + 'jump_host_id': jump_host_id, + } + if startup_commands is not None: + payload['startup_commands'] = startup_commands + if command_set_id is not None: + payload['command_set_id'] = command_set_id + return upsert_profile( + user_id, + payload, + preserve_legacy_fallback=bool(command_set_id and startup_commands), + ) def get_profile(user_id, profile_id): """Get a specific profile by ID for a specific user.""" @@ -200,6 +252,7 @@ def assign_command_set(user_id, profile_id, command_set_id): return None, error for profile in profiles: if profile.get('id') == profile_id: + profile['startup_mode'] = 'command_set' profile['command_set_id'] = command_set['id'] if not save_profiles(user_id, profiles): return None, 'Failed to save profile' diff --git a/app/socket_events.py b/app/socket_events.py index 8cade2d..b241ee4 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 from flask_login import current_user -from . import socketio, ssh_manager, profile_manager, key_manager, sftp_handler, jump_host_manager +from . import (socketio, ssh_manager, profile_manager, key_manager, + sftp_handler, jump_host_manager, post_connect_manager) 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 @@ -15,7 +16,6 @@ profile_is_authorized_for_launch, validate_tailscale_ssh_access, ) -from .startup_commands import normalize_startup_commands from .storage_utils import storage_lock from . import binary_transfer, connection_pool import base64 @@ -268,21 +268,11 @@ def handle_ssh_connect(data, current_user=None): def emit_error(message): emit('ssh_error', {'error': message, 'client_request_id': client_request_id}) - command_set_id = data.get('command_set_id') - if command_set_id is not None: - from . import command_set_manager - - with storage_lock(f'command-config:{current_user.id}'): - startup_commands, startup_commands_error = command_set_manager.resolve_command_set( - current_user.id, command_set_id - ) - if not startup_commands_error: - startup_commands, startup_commands_error = normalize_startup_commands( - startup_commands + with storage_lock(f'command-config:{current_user.id}'): + startup_commands, startup_commands_error = ( + post_connect_manager.resolve_configuration( + current_user.id, data ) - else: - startup_commands, startup_commands_error = normalize_startup_commands( - data.get('startup_commands', '') ) if startup_commands_error: emit_error(startup_commands_error) @@ -596,46 +586,34 @@ def handle_list_profiles(current_user=None): @socketio.on('save_profile') @socket_login_required def handle_save_profile(data, current_user=None): - """Save a new connection profile for this user.""" + """Create or update a connection profile without starting SSH.""" try: - name = data.get('name') + data = data if isinstance(data, dict) else {} + auth_type = data.get('auth_type') host = data.get('host') - port = data.get('port', 22) username = data.get('username') - auth_type = data.get('auth_type') - key_id = data.get('key_id') - jump_host_id = data.get('jump_host_id') - startup_commands = data.get('startup_commands') - command_set_id = data.get('command_set_id') if auth_type == 'tailscale': access_error = validate_tailscale_ssh_access(current_user, host, username) if access_error: emit('error', {'error': access_error}) - return + return {'success': False, 'error': access_error} - profile, error = profile_manager.add_profile( - user_id=current_user.id, - name=name, - host=host, - port=port, - username=username, - auth_type=auth_type, - key_id=key_id, - jump_host_id=jump_host_id, - startup_commands=startup_commands, - command_set_id=command_set_id, - ) + profile, error = profile_manager.upsert_profile(current_user.id, data) if error: emit('error', {'error': error}) + return {'success': False, 'error': error} else: - emit('profile_saved', {'profile': profile}) + payload = {'success': True, 'profile': profile} + emit('profile_saved', payload) handle_list_profiles(current_user=current_user) + return payload except Exception as e: log_error("Failed to save profile", error=str(e)) emit('error', {'error': 'Failed to save profile'}) + return {'success': False, 'error': 'Failed to save profile'} @socketio.on('delete_profile') @socket_login_required @@ -1124,7 +1102,9 @@ def handle_list_command_sets(data=None, current_user=None): """Return all named command sets owned by the current user.""" from . import command_set_manager - command_sets, error = command_set_manager.load_command_sets(current_user.id) + command_sets, error = command_set_manager.load_command_sets_with_resolution( + current_user.id + ) if error: return _command_set_error(error) payload = {'success': True, 'command_sets': command_sets} diff --git a/static/css/style.css b/static/css/style.css index 47017d8..c329246 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -4109,6 +4109,168 @@ body.keyboard-open.notepad-focused .notepad-panel { } /* Reusable post-connect command sets */ +.post-connect-config { + display: flex; + flex-direction: column; + gap: 10px; +} + +.post-connect-label, +.post-connect-parameters-label, +.post-connect-preview-header { + position: relative; + display: flex; + align-items: center; + gap: 7px; + font-weight: 600; +} + +.post-connect-mode-selector { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 6px; + padding: 5px; + border: 1px solid var(--border-color); + border-radius: 10px; + background: var(--bg-secondary); +} + +.post-connect-mode { + padding: 8px 10px; + border: 1px solid transparent; + border-radius: 7px; + background: transparent; + color: var(--text-secondary); + font: inherit; + font-size: 12px; + font-weight: 600; + cursor: pointer; +} + +.post-connect-mode:hover, +.post-connect-mode:focus-visible { + background: var(--bg-hover); + color: var(--text-primary); +} + +.post-connect-mode.active { + border-color: var(--accent-primary); + background: color-mix(in srgb, var(--accent-primary) 14%, transparent); + color: var(--accent-primary); +} + +.post-connect-panel { + display: flex; + flex-direction: column; + gap: 8px; + padding: 12px; + border: 1px solid var(--border-color); + border-radius: 9px; + background: var(--bg-secondary); +} + +.post-connect-config #connectionCommandPreview, +#profileEditorCommandPreview { + display: block; + min-height: 42px; + margin: 0; + white-space: pre-wrap; + overflow-wrap: anywhere; + color: var(--accent-secondary); + font-family: monospace; +} + +.post-connect-config #connectionCommandPreview.error, +#profileEditorCommandPreview.error { + border-color: var(--danger-color, #f85149); + color: var(--danger-color, #f85149); +} + +.info-tooltip-trigger { + display: inline-grid; + width: 19px; + height: 19px; + place-items: center; + padding: 0; + border: 1px solid var(--border-color); + border-radius: 50%; + background: var(--bg-secondary); + color: var(--text-secondary); + font-size: 11px; + cursor: help; +} + +.info-tooltip { + position: absolute; + z-index: 20; + top: calc(100% + 7px); + left: 0; + width: min(300px, 80vw); + padding: 8px 10px; + border: 1px solid var(--border-color); + border-radius: 7px; + background: var(--bg-primary); + box-shadow: var(--shadow-lg); + color: var(--text-primary); + font-size: 12px; + font-weight: 400; + line-height: 1.4; + opacity: 0; + pointer-events: none; + transform: translateY(-3px); + transition: opacity 0.15s ease, transform 0.15s ease; +} + +.info-tooltip-trigger:hover + .info-tooltip, +.info-tooltip-trigger:focus-visible + .info-tooltip { + opacity: 1; + transform: translateY(0); +} + +.profile-management-toolbar, +.profile-management-item, +.profile-management-actions { + display: flex; + align-items: center; + gap: 12px; +} + +.profile-management-toolbar, +.profile-management-item { + justify-content: space-between; +} + +.profile-management-toolbar p { + margin: 0; + color: var(--text-secondary); +} + +.profile-management-list { + display: grid; + gap: 10px; + margin-top: 16px; +} + +.profile-management-item { + padding: 13px; + border: 1px solid var(--border-color); + border-radius: 9px; + background: var(--bg-secondary); +} + +.profile-management-info { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} + +.profile-management-info span { + color: var(--text-secondary); + font-size: 12px; + overflow-wrap: anywhere; +} + .command-set-selector-row { display: flex; gap: 10px; @@ -4437,16 +4599,26 @@ body.keyboard-open.notepad-focused .notepad-panel { } .command-set-management-item, - .command-set-management-toolbar { + .command-set-management-toolbar, + .profile-management-item, + .profile-management-toolbar { align-items: stretch; flex-direction: column; } + + .profile-management-actions { + flex-wrap: wrap; + } } @media (max-width: 520px) { .command-set-selector-row { flex-direction: column; } + + .post-connect-mode-selector { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } } .checkbox-group { diff --git a/static/js/app.js b/static/js/app.js index 5c73674..f09144d 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -117,7 +117,7 @@ }; window.clearConnectionProfileState = () => { - window.CommandSetManager?.selectForConnection(''); + window.ConnectionCommandManager?.clear(); ProfileManager.clearLegacyCommands(); const profileSelect = document.getElementById('profileSelect'); @@ -1095,25 +1095,6 @@ setConnectLoading(false); } - function clearSaveProfileState() { - const saveProfileCheck = document.getElementById('saveProfileCheck'); - const profileNameInput = document.getElementById('profileNameInput'); - const profileNameGroup = document.getElementById('profileNameGroup'); - const profileHint = document.getElementById('profileHint'); - - if (saveProfileCheck) { - saveProfileCheck.checked = false; - } - if (profileNameInput) { - profileNameInput.value = ''; - profileNameInput.required = false; - setFieldState(profileNameInput, profileHint, '', null); - } - if (profileNameGroup) { - profileNameGroup.classList.add('hidden'); - } - } - function selectConnectionProfile(profileId) { const profileSelect = document.getElementById('profileSelect'); const deleteBtn = document.getElementById('deleteProfileBtn'); @@ -1123,7 +1104,7 @@ if (!profileId) { ProfileManager.clearLegacyCommands(); - CommandSetManager.selectForConnection(''); + ConnectionCommandManager.clear(); deleteBtn.style.display = 'none'; delete deleteBtn.dataset.profileId; return null; @@ -1184,7 +1165,6 @@ const mode = ProfileManager.getLaunchMode(selected); const form = document.getElementById('connectionForm'); if (mode === 'connect' && isSelectedProfileReady(selected)) { - clearSaveProfileState(); form.requestSubmit(); return; } @@ -1200,6 +1180,11 @@ window.launchProfileForPane = launchProfileForPane; + window.openConnectionModalForProfile = (profileId) => { + openConnectionModalForPane(getDefaultPaneIndex()); + selectConnectionProfile(profileId); + }; + function queuePaneConnection(paneIndex) { if (paneIndex === null || paneIndex === undefined) { return; @@ -1914,6 +1899,8 @@ CommandWorkspace.init(); CommandLibrary.init(); window.CommandSetManager?.init(); + window.ConnectionCommandManager?.init(); + ProfileManager.init(); ProfileManager.loadProfiles(); ProfileManager.loadKeys(); @@ -1957,12 +1944,6 @@ const authType = document.getElementById('authTypeSelect').value; const password = document.getElementById('passwordInput').value; const keyId = document.getElementById('keySelect').value; - const saveProfile = document.getElementById('saveProfileCheck').checked; - const profileName = document.getElementById('profileNameInput').value; - const commandSetId = CommandSetManager.getSelectedId(); - const legacyStartupCommands = commandSetId - ? '' - : ProfileManager.getLegacyStartupCommands(); const targetPane = pendingPaneIndex; if (!host || !username) { @@ -2010,25 +1991,6 @@ } pendingPaneIndex = null; - - if (saveProfile && profileName) { - const profilePayload = { - name: profileName, - host: host, - port: parseInt(port), - username: username, - auth_type: authType, - key_id: authType === 'key' ? keyId : null - }; - if (commandSetId) { - profilePayload.command_set_id = commandSetId; - } - if (jumpHostId) { - profilePayload.jump_host_id = jumpHostId; - } - ProfileManager.saveProfile(profilePayload); - } - currentConnectRequestId = `req_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`; SessionManager.createPendingConnection(currentConnectRequestId, host, username, port); if (targetPane !== null && targetPane !== undefined) { @@ -2042,12 +2004,7 @@ client_request_id: currentConnectRequestId, auth_type: authType }; - if (commandSetId) { - connectionData.command_set_id = commandSetId; - } - if (legacyStartupCommands) { - connectionData.startup_commands = legacyStartupCommands; - } + Object.assign(connectionData, ConnectionCommandManager.getPayload()); if (authType === 'password') { connectionData.password = password; @@ -2122,17 +2079,6 @@ } }); - document.getElementById('saveProfileCheck').addEventListener('change', (e) => { - const profileNameGroup = document.getElementById('profileNameGroup'); - if (e.target.checked) { - profileNameGroup.classList.remove('hidden'); - document.getElementById('profileNameInput').required = true; - } else { - profileNameGroup.classList.add('hidden'); - document.getElementById('profileNameInput').required = false; - } - }); - document.getElementById('manageKeysBtn').addEventListener('click', () => { window.ModalManager.open(document.getElementById('keyManagementModal')); ProfileManager.loadKeys(); diff --git a/static/js/command-library.js b/static/js/command-library.js index 8720352..1892bc9 100644 --- a/static/js/command-library.js +++ b/static/js/command-library.js @@ -7,6 +7,7 @@ const CommandLibrary = { renderCursor: 0, chunkSize: 40, pendingSaveCallback: null, + returnToModalId: null, init() { this.loadCommands(); @@ -40,7 +41,9 @@ const CommandLibrary = { setupEventListeners() { const commandLibraryBtn = document.getElementById('commandLibraryBtn'); if (commandLibraryBtn) { - commandLibraryBtn.addEventListener('click', () => this.openLibrary()); + commandLibraryBtn.addEventListener('click', () => { + window.CommandSetManager?.openManagement(); + }); } const commandSearchInput = document.getElementById('commandSearchInput'); @@ -102,6 +105,7 @@ const CommandLibrary = { this.filteredCommands = commands; this.renderCommandsList(); window.CommandSetManager?.onCommandsChanged(); + window.ConnectionCommandManager?.onDataChanged(); const osDisplay = document.getElementById('currentOsDisplay'); if (osDisplay) { osDisplay.textContent = this.currentOs.charAt(0).toUpperCase() + this.currentOs.slice(1); @@ -109,6 +113,7 @@ const CommandLibrary = { }, openLibrary() { + this.returnToModalId = null; const activeSessionId = SessionManager.getActiveSession(); if (!activeSessionId) { @@ -123,11 +128,29 @@ const CommandLibrary = { }, 100); }, + openEditor(commandId, returnToModalId = null) { + const command = this.commands.find(item => item.id === commandId); + if (!command) return; + this.returnToModalId = returnToModalId; + window.CommandWorkspace.open('library'); + if (command.isSystem) this.copyCommand(commandId); + else this.editCommand(commandId); + }, + closeLibrary() { + const returnModalId = this.returnToModalId; window.CommandWorkspace.close(); document.getElementById('commandSearchInput').value = ''; this.filteredCommands = this.commands; this.renderCommandsList(); + if (returnModalId && window.ModalManager) { + const returnModal = document.getElementById(returnModalId); + if (returnModal?.classList.contains('show')) { + window.ModalManager.activeModal = returnModal; + document.getElementById('editSelectedCommandBtn')?.focus(); + } + } + this.returnToModalId = null; }, detectOs() { @@ -418,6 +441,10 @@ const CommandLibrary = { } this.editingCommandId = null; this.pendingSaveCallback = null; + const workspace = document.getElementById('commandWorkspaceModal'); + if (workspace?.classList.contains('show') && window.ModalManager) { + window.ModalManager.activeModal = workspace; + } }, escapeHtml(text) { diff --git a/static/js/command-set-manager.js b/static/js/command-set-manager.js index 2f1e377..709bc74 100644 --- a/static/js/command-set-manager.js +++ b/static/js/command-set-manager.js @@ -113,6 +113,7 @@ window.CommandSetManager = { this.renderSelect(); this.renderPreview(); this.renderManagementList(); + window.ConnectionCommandManager?.onDataChanged(); }, getById(id) { @@ -217,7 +218,7 @@ window.CommandSetManager = { document.getElementById('commandSetNameInput').value = source?.name || ''; document.getElementById('commandSetDescriptionInput').value = source?.description || ''; const sudoInput = document.getElementById('commandSetUseSudoInput'); - if (sudoInput) sudoInput.checked = source ? source.use_sudo === true : true; + if (sudoInput) sudoInput.checked = source ? source.use_sudo === true : false; document.getElementById('commandSetEditorTitle').textContent = source ? this.t('commandSets.edit', 'Edit command set') : this.t('commandSets.create', 'Create command set'); diff --git a/static/js/command-workspace.js b/static/js/command-workspace.js index 008db9e..3e5a2d0 100644 --- a/static/js/command-workspace.js +++ b/static/js/command-workspace.js @@ -1,6 +1,6 @@ /* Shared shell for the command library and reusable command sets. */ window.CommandWorkspace = { - activeSection: 'library', + activeSection: 'sets', init() { const libraryTab = document.getElementById('commandLibraryTab'); @@ -21,10 +21,10 @@ window.CommandWorkspace = { this.select(this.activeSection === 'library' ? 'sets' : 'library', true); })); - this.select('library'); + this.select('sets'); }, - open(section = 'library') { + open(section = 'sets') { this.select(section); const modal = document.getElementById('commandWorkspaceModal'); if (!modal) return; diff --git a/static/js/connection-command-manager.js b/static/js/connection-command-manager.js new file mode 100644 index 0000000..dd18945 --- /dev/null +++ b/static/js/connection-command-manager.js @@ -0,0 +1,272 @@ +(function (root, factory) { + const manager = factory(root); + if (typeof module === 'object' && module.exports) { + module.exports = manager; + } + if (root && root.document) { + root.ConnectionCommandManager = manager; + } +}(typeof globalThis !== 'undefined' ? globalThis : this, function (root) { + 'use strict'; + + const manager = { + mode: 'none', + freeText: '', + selectedCommandId: '', + parametersOverride: null, + selectedCommandSetId: '', + + t(key, fallback) { + if (root?.i18n) { + const value = root.i18n.t(key); + if (value && value !== key) return value; + } + return fallback; + }, + + init() { + const document = root.document; + if (!document) return; + + document.querySelectorAll('[data-command-mode]').forEach(button => { + button.addEventListener('click', () => this.setMode(button.dataset.commandMode)); + }); + document.getElementById('commandSetSelect')?.addEventListener('change', event => { + this.selectedCommandSetId = event.target.value || ''; + root.CommandSetManager?.selectForConnection(this.selectedCommandSetId); + this.renderPreview(); + }); + document.getElementById('connectionCommandSelect')?.addEventListener('change', event => { + this.selectedCommandId = event.target.value || ''; + this.parametersOverride = null; + this.render(); + }); + document.getElementById('connectionCommandParameters')?.addEventListener('input', event => { + this.parametersOverride = event.target.value; + this.renderPreview(); + }); + document.getElementById('startupCommandsInput')?.addEventListener('input', event => { + this.freeText = event.target.value; + this.renderPreview(); + }); + document.getElementById('manageCommandSetsBtn')?.addEventListener('click', () => { + root.CommandSetManager?.openManagement(); + }); + document.getElementById('editSelectedCommandSetBtn')?.addEventListener('click', () => { + if (this.selectedCommandSetId) { + root.CommandSetManager?.openBuilder(this.selectedCommandSetId, true); + } + }); + document.getElementById('editSelectedCommandBtn')?.addEventListener('click', () => { + if (this.selectedCommandId) { + root.CommandLibrary?.openEditor( + this.selectedCommandId, 'connectionModal' + ); + } + }); + root.addEventListener?.('languageChanged', () => this.render()); + this.render(); + }, + + inferProfileMode(profile) { + if (profile?.startup_mode) return profile.startup_mode; + if (profile?.command_set_id) return 'command_set'; + if (profile?.command_id) return 'command'; + if (profile?.startup_commands) return 'free_text'; + return 'none'; + }, + + setMode(mode, shouldRender = true) { + const valid = ['none', 'command_set', 'command', 'free_text']; + this.mode = valid.includes(mode) ? mode : 'none'; + if (shouldRender) this.render(); + }, + + applyProfile(profile) { + this.mode = this.inferProfileMode(profile); + this.freeText = profile?.startup_commands || ''; + this.selectedCommandId = profile?.command_id || ''; + this.parametersOverride = Object.prototype.hasOwnProperty.call( + profile || {}, 'parameters_override' + ) ? profile.parameters_override : null; + this.selectedCommandSetId = profile?.command_set_id || ''; + root.CommandSetManager?.selectForConnection(this.selectedCommandSetId); + this.render(); + }, + + clear() { + this.mode = 'none'; + this.freeText = ''; + this.selectedCommandId = ''; + this.parametersOverride = null; + this.selectedCommandSetId = ''; + root.CommandSetManager?.selectForConnection(''); + this.render(); + }, + + getPayload() { + if (this.mode === 'free_text') { + return { + startup_mode: 'free_text', + startup_commands: this.freeText, + }; + } + if (this.mode === 'command') { + const payload = { + startup_mode: 'command', + command_id: this.selectedCommandId, + }; + if (this.parametersOverride !== null) { + payload.parameters_override = this.parametersOverride; + } + return payload; + } + if (this.mode === 'command_set') { + return { + startup_mode: 'command_set', + command_set_id: this.selectedCommandSetId, + }; + } + return { startup_mode: 'none' }; + }, + + commands() { + return Array.isArray(root.CommandLibrary?.commands) + ? root.CommandLibrary.commands + : []; + }, + + commandSets() { + return Array.isArray(root.CommandSetManager?.commandSets) + ? root.CommandSetManager.commandSets + : []; + }, + + selectedCommand() { + return this.commands().find(command => command.id === this.selectedCommandId) || null; + }, + + selectedCommandSet() { + return this.commandSets().find(commandSet => ( + commandSet.id === this.selectedCommandSetId + )) || null; + }, + + renderOptions() { + const document = root.document; + if (!document) return; + + const commandSelect = document.getElementById('connectionCommandSelect'); + if (commandSelect) { + commandSelect.replaceChildren(); + const placeholder = document.createElement('option'); + placeholder.value = ''; + placeholder.textContent = this.t('commandModes.selectCommand', 'Select a Command'); + commandSelect.appendChild(placeholder); + this.commands().forEach(command => { + const option = document.createElement('option'); + option.value = command.id; + option.textContent = command.name; + commandSelect.appendChild(option); + }); + commandSelect.value = this.selectedCommandId; + } + + const commandSetSelect = document.getElementById('commandSetSelect'); + if (commandSetSelect) { + commandSetSelect.value = this.selectedCommandSetId; + } + }, + + render() { + const document = root.document; + if (!document) return; + this.renderOptions(); + + document.querySelectorAll('[data-command-mode]').forEach(button => { + const active = button.dataset.commandMode === this.mode; + button.classList.toggle('active', active); + button.setAttribute('aria-pressed', String(active)); + }); + const panels = { + command_set: 'connectionCommandSetPanel', + command: 'connectionSingleCommandPanel', + free_text: 'connectionFreeTextPanel', + }; + Object.entries(panels).forEach(([mode, id]) => { + document.getElementById(id)?.classList.toggle('hidden', this.mode !== mode); + }); + + const freeText = document.getElementById('startupCommandsInput'); + if (freeText && freeText.value !== this.freeText) freeText.value = this.freeText; + const parameters = document.getElementById('connectionCommandParameters'); + const command = this.selectedCommand(); + if (parameters) { + const value = this.parametersOverride === null + ? (command?.parameters || '') + : this.parametersOverride; + if (parameters.value !== value) parameters.value = value; + parameters.placeholder = command?.parameters || ''; + parameters.disabled = !command; + } + + document.getElementById('editSelectedCommandBtn')?.toggleAttribute( + 'disabled', !command + ); + document.getElementById('editSelectedCommandSetBtn')?.toggleAttribute( + 'disabled', !this.selectedCommandSet() + ); + this.renderPreview(); + }, + + renderPreview() { + const document = root.document; + if (!document) return; + const preview = document.getElementById('connectionCommandPreview'); + if (!preview) return; + + let text = ''; + let error = false; + if (this.mode === 'none') { + text = this.t('commandSets.noSelectionHint', 'No commands will run after connecting.'); + } else if (this.mode === 'free_text') { + text = this.freeText || this.t('commandModes.emptyFreeText', 'Enter commands to preview them.'); + } else if (this.mode === 'command') { + const command = this.selectedCommand(); + if (!command) { + text = this.t('commandModes.missingCommand', 'Select an available Command.'); + error = Boolean(this.selectedCommandId); + } else { + const parameters = this.parametersOverride === null + ? (command.parameters || '') + : this.parametersOverride; + text = command.command + (parameters ? ` ${parameters}` : ''); + } + } else { + const commandSet = this.selectedCommandSet(); + if (!commandSet) { + text = this.t('commandSets.missingSetHint', 'Select an available Command Set.'); + error = Boolean(this.selectedCommandSetId); + } else if (commandSet.resolution_error) { + text = commandSet.resolution_error; + error = true; + } else { + text = commandSet.resolved_command || commandSet.steps + .map(step => root.CommandSetManager?.stepSummary(step) || '') + .join(' && '); + } + } + + preview.textContent = text; + preview.classList.toggle('empty', this.mode === 'none' || !text); + preview.classList.toggle('error', error); + }, + + onDataChanged() { + this.render(); + root.ProfileManager?.renderEditorSelects(); + }, + }; + + return manager; +})); diff --git a/static/js/i18n.js b/static/js/i18n.js index fbc3a71..9f8a219 100644 --- a/static/js/i18n.js +++ b/static/js/i18n.js @@ -62,6 +62,24 @@ const translations = { 'connection.commandSetHint': 'Runs on the remote host after a successful connection, not in WebSSH. Not run again when reconnecting to an existing tmux session.', 'commandSets.manage': 'Command Sets', 'commandSets.manageHint': 'Build reusable command sequences and assign one to any connection profile.', + 'connection.runAfterConnect': 'Run after connecting', + 'connection.runAfterConnectTooltip': 'Choose exactly what WebSSH sends to the remote terminal after a new SSH connection is ready.', + 'commandModes.none': 'Nothing', + 'commandModes.commandSet': 'Command Set', + 'commandModes.command': 'Command', + 'commandModes.freeText': 'Free text', + 'commandModes.selectCommand': '-- Select command --', + 'commandModes.parametersTooltip': 'Keep the saved parameters or replace them only for this connection or profile.', + 'commandModes.exactPreview': 'Exact command preview', + 'commandModes.emptyFreeText': 'Enter at least one command.', + 'commandModes.missingCommand': 'Select a command first.', + 'commandModes.useSavedParameters': 'Use saved parameters', + 'profiles.manage': 'Profiles', + 'profiles.manageHint': 'Create, inspect, update, and connect saved profiles independently.', + 'profiles.create': 'Create profile', + 'profiles.none': 'No profiles saved.', + 'profiles.saveFailed': 'Could not save the profile.', + 'profiles.saved': 'Profile saved.', 'commandSets.none': 'None', 'commandSets.create': 'Create command set', 'commandSets.createNew': 'Create new', @@ -420,6 +438,24 @@ const translations = { 'connection.commandSetHint': 'Chạy trên máy chủ từ xa sau khi kết nối thành công, không chạy trong WebSSH. Không chạy lại khi kết nối lại với một phiên tmux hiện có.', 'commandSets.manage': 'Bộ lệnh', 'commandSets.manageHint': 'Tạo chuỗi lệnh có thể tái sử dụng và gán một bộ cho mỗi hồ sơ kết nối.', + 'connection.runAfterConnect': 'Chạy sau khi kết nối', + 'connection.runAfterConnectTooltip': 'Chọn chính xác nội dung WebSSH gửi đến terminal từ xa sau khi kết nối SSH mới sẵn sàng.', + 'commandModes.none': 'Không chạy', + 'commandModes.commandSet': 'Bộ lệnh', + 'commandModes.command': 'Lệnh', + 'commandModes.freeText': 'Văn bản tự do', + 'commandModes.selectCommand': '-- Chọn lệnh --', + 'commandModes.parametersTooltip': 'Giữ tham số đã lưu hoặc chỉ thay thế cho kết nối hay hồ sơ này.', + 'commandModes.exactPreview': 'Xem trước lệnh chính xác', + 'commandModes.emptyFreeText': 'Nhập ít nhất một lệnh.', + 'commandModes.missingCommand': 'Trước tiên hãy chọn một lệnh.', + 'commandModes.useSavedParameters': 'Dùng tham số đã lưu', + 'profiles.manage': 'Hồ sơ', + 'profiles.manageHint': 'Tạo, xem, cập nhật và kết nối hồ sơ đã lưu một cách độc lập.', + 'profiles.create': 'Tạo hồ sơ', + 'profiles.none': 'Chưa có hồ sơ nào.', + 'profiles.saveFailed': 'Không thể lưu hồ sơ.', + 'profiles.saved': 'Đã lưu hồ sơ.', 'commandSets.none': 'Không dùng', 'commandSets.create': 'Tạo bộ lệnh', 'commandSets.createNew': 'Tạo mới', @@ -776,6 +812,24 @@ const translations = { 'connection.commandSetHint': 'Wird nach erfolgreicher Verbindung auf dem Remote-Host ausgeführt, nicht in WebSSH. Bei der Wiederverbindung mit einer bestehenden tmux-Sitzung nicht erneut ausgeführt.', 'commandSets.manage': 'Befehlssätze', 'commandSets.manageHint': 'Erstelle wiederverwendbare Befehlsfolgen und ordne jeder Verbindung optional einen Satz zu.', + 'connection.runAfterConnect': 'Nach dem Verbinden ausführen', + 'connection.runAfterConnectTooltip': 'Lege genau fest, was WebSSH nach Aufbau einer neuen SSH-Verbindung an das entfernte Terminal sendet.', + 'commandModes.none': 'Nichts', + 'commandModes.commandSet': 'Befehlssatz', + 'commandModes.command': 'Befehl', + 'commandModes.freeText': 'Freitext', + 'commandModes.selectCommand': '-- Befehl auswählen --', + 'commandModes.parametersTooltip': 'Nutze die gespeicherten Parameter oder ersetze sie nur für diese Verbindung beziehungsweise dieses Profil.', + 'commandModes.exactPreview': 'Exakte Befehlsvorschau', + 'commandModes.emptyFreeText': 'Gib mindestens einen Befehl ein.', + 'commandModes.missingCommand': 'Wähle zuerst einen Befehl aus.', + 'commandModes.useSavedParameters': 'Gespeicherte Parameter verwenden', + 'profiles.manage': 'Profile', + 'profiles.manageHint': 'Erstelle, prüfe, aktualisiere und verbinde gespeicherte Profile unabhängig voneinander.', + 'profiles.create': 'Profil erstellen', + 'profiles.none': 'Keine Profile gespeichert.', + 'profiles.saveFailed': 'Das Profil konnte nicht gespeichert werden.', + 'profiles.saved': 'Profil gespeichert.', 'commandSets.none': 'Keiner', 'commandSets.create': 'Befehlssatz erstellen', 'commandSets.createNew': 'Neu erstellen', @@ -1114,6 +1168,24 @@ const translations = { 'connection.commandSetHint': "Exécutées sur l'hôte distant après une connexion réussie, et non dans WebSSH. Elles ne sont pas réexécutées lors de la reconnexion à une session tmux existante.", 'commandSets.manage': 'Ensembles de commandes', 'commandSets.manageHint': 'Créez des séquences réutilisables et associez-en une à chaque profil de connexion.', + 'connection.runAfterConnect': 'Exécuter après la connexion', + 'connection.runAfterConnectTooltip': 'Choisissez précisément ce que WebSSH envoie au terminal distant lorsqu’une nouvelle connexion SSH est prête.', + 'commandModes.none': 'Rien', + 'commandModes.commandSet': 'Ensemble de commandes', + 'commandModes.command': 'Commande', + 'commandModes.freeText': 'Texte libre', + 'commandModes.selectCommand': '-- Sélectionner une commande --', + 'commandModes.parametersTooltip': 'Conservez les paramètres enregistrés ou remplacez-les uniquement pour cette connexion ou ce profil.', + 'commandModes.exactPreview': 'Aperçu exact de la commande', + 'commandModes.emptyFreeText': 'Saisissez au moins une commande.', + 'commandModes.missingCommand': 'Sélectionnez d’abord une commande.', + 'commandModes.useSavedParameters': 'Utiliser les paramètres enregistrés', + 'profiles.manage': 'Profils', + 'profiles.manageHint': 'Créez, vérifiez, mettez à jour et connectez les profils enregistrés indépendamment.', + 'profiles.create': 'Créer un profil', + 'profiles.none': 'Aucun profil enregistré.', + 'profiles.saveFailed': 'Impossible d’enregistrer le profil.', + 'profiles.saved': 'Profil enregistré.', 'commandSets.none': 'Aucun', 'commandSets.create': 'Créer un ensemble', 'commandSets.createNew': 'Créer', @@ -1439,6 +1511,24 @@ const translations = { 'connection.commandSetHint': 'Se ejecutan en el host remoto después de una conexión correcta, no en WebSSH. No se vuelven a ejecutar al reconectar con una sesión tmux existente.', 'commandSets.manage': 'Conjuntos de comandos', 'commandSets.manageHint': 'Crea secuencias reutilizables y asigna una a cada perfil de conexión.', + 'connection.runAfterConnect': 'Ejecutar después de conectar', + 'connection.runAfterConnectTooltip': 'Elige exactamente qué envía WebSSH al terminal remoto cuando una nueva conexión SSH está lista.', + 'commandModes.none': 'Nada', + 'commandModes.commandSet': 'Conjunto de comandos', + 'commandModes.command': 'Comando', + 'commandModes.freeText': 'Texto libre', + 'commandModes.selectCommand': '-- Seleccionar comando --', + 'commandModes.parametersTooltip': 'Conserva los parámetros guardados o sustitúyelos solo para esta conexión o perfil.', + 'commandModes.exactPreview': 'Vista previa exacta del comando', + 'commandModes.emptyFreeText': 'Introduce al menos un comando.', + 'commandModes.missingCommand': 'Selecciona primero un comando.', + 'commandModes.useSavedParameters': 'Usar parámetros guardados', + 'profiles.manage': 'Perfiles', + 'profiles.manageHint': 'Crea, revisa, actualiza y conecta perfiles guardados de forma independiente.', + 'profiles.create': 'Crear perfil', + 'profiles.none': 'No hay perfiles guardados.', + 'profiles.saveFailed': 'No se pudo guardar el perfil.', + 'profiles.saved': 'Perfil guardado.', 'commandSets.none': 'Ninguno', 'commandSets.create': 'Crear conjunto', 'commandSets.createNew': 'Crear nuevo', @@ -1764,6 +1854,24 @@ const translations = { 'connection.commandSetHint': '连接成功后在远程主机上运行,而不是在 WebSSH 中运行。重新连接到现有 tmux 会话时不会再次运行。', 'commandSets.manage': '命令集', 'commandSets.manageHint': '创建可重复使用的命令序列,并为每个连接配置文件分配一个命令集。', + 'connection.runAfterConnect': '连接后运行', + 'connection.runAfterConnectTooltip': '准确选择 WebSSH 在新的 SSH 连接就绪后发送到远程终端的内容。', + 'commandModes.none': '不运行', + 'commandModes.commandSet': '命令集', + 'commandModes.command': '命令', + 'commandModes.freeText': '自由文本', + 'commandModes.selectCommand': '-- 选择命令 --', + 'commandModes.parametersTooltip': '使用已保存的参数,或仅为此次连接或此配置文件替换参数。', + 'commandModes.exactPreview': '准确命令预览', + 'commandModes.emptyFreeText': '请至少输入一条命令。', + 'commandModes.missingCommand': '请先选择一条命令。', + 'commandModes.useSavedParameters': '使用已保存的参数', + 'profiles.manage': '配置文件', + 'profiles.manageHint': '独立创建、检查、更新和连接已保存的配置文件。', + 'profiles.create': '创建配置文件', + 'profiles.none': '尚未保存配置文件。', + 'profiles.saveFailed': '无法保存配置文件。', + 'profiles.saved': '配置文件已保存。', 'commandSets.none': '无', 'commandSets.create': '创建命令集', 'commandSets.createNew': '新建', diff --git a/static/js/jump-host-manager.js b/static/js/jump-host-manager.js index 9765885..3698275 100644 --- a/static/js/jump-host-manager.js +++ b/static/js/jump-host-manager.js @@ -16,6 +16,7 @@ window.JumpHostManager = { this.jumpHosts = Array.isArray(list) ? list : []; this.renderSelect(); this.renderList(); + window.ProfileManager?.renderEditorSelects(); if (typeof SessionManager !== 'undefined') { SessionManager.refreshEmptyPanes(); } diff --git a/static/js/profile-manager.js b/static/js/profile-manager.js index bc9dc7c..92309e7 100644 --- a/static/js/profile-manager.js +++ b/static/js/profile-manager.js @@ -3,6 +3,60 @@ const ProfileManager = { keys: [], profilesLoaded: false, selectedLegacyStartupCommands: '', + editingProfileId: null, + + init() { + document.getElementById('manageProfilesBtn')?.addEventListener('click', () => { + this.openManagement(); + }); + document.getElementById('closeProfileManagementModal')?.addEventListener('click', () => { + window.ModalManager?.close(document.getElementById('profileManagementModal')); + }); + document.getElementById('newProfileBtn')?.addEventListener('click', () => { + this.openEditor(); + }); + document.getElementById('cancelProfileEditorBtn')?.addEventListener('click', () => { + this.showManagementList(); + }); + document.getElementById('profileEditorForm')?.addEventListener('submit', event => { + event.preventDefault(); + this.saveFromEditor(); + }); + document.getElementById('profileEditorAuthType')?.addEventListener('change', () => { + this.updateEditorVisibility(); + }); + document.getElementById('profileEditorPostConnectMode')?.addEventListener('change', () => { + this.updateEditorVisibility(); + }); + document.getElementById('profileEditorUseDefaultParameters')?.addEventListener('change', () => { + this.updateEditorVisibility(); + }); + [ + 'profileEditorCommandSelect', + 'profileEditorCommandSetSelect', + 'profileEditorCommandParameters', + 'profileEditorStartupCommands', + ].forEach(id => { + document.getElementById(id)?.addEventListener('input', () => { + this.renderEditorCommandPreview(); + }); + document.getElementById(id)?.addEventListener('change', () => { + this.renderEditorCommandPreview(); + }); + }); + document.getElementById('profileManagementList')?.addEventListener('click', event => { + const button = event.target.closest('[data-profile-action]'); + if (!button) return; + const profileId = button.dataset.profileId; + if (button.dataset.profileAction === 'connect') this.connect(profileId); + if (button.dataset.profileAction === 'edit') this.openEditor(profileId); + if (button.dataset.profileAction === 'delete') this.deleteProfile(profileId); + }); + window.addEventListener('languageChanged', () => { + this.renderProfileSelect(); + this.renderManagementList(); + }); + }, loadProfiles() { if (window.socket) { @@ -20,6 +74,7 @@ const ProfileManager = { this.profiles = Array.isArray(profiles) ? profiles : []; this.profilesLoaded = true; this.renderProfileSelect(); + this.renderManagementList(); this.refreshEmptyPanes(); }, @@ -27,6 +82,7 @@ const ProfileManager = { this.keys = Array.isArray(keys) ? keys : []; this.renderKeySelect(); this.renderKeysList(); + this.renderEditorSelects(); this.refreshEmptyPanes(); }, @@ -136,6 +192,7 @@ const ProfileManager = { const select = document.getElementById('profileSelect'); if (!select) return; + const current = select.value; select.innerHTML = ''; this.profiles.forEach(profile => { @@ -144,6 +201,9 @@ const ProfileManager = { option.textContent = profile.name; select.appendChild(option); }); + if (current && this.profiles.some(profile => profile.id === current)) { + select.value = current; + } }, renderKeySelect() { @@ -221,12 +281,11 @@ const ProfileManager = { document.getElementById('hostInput').value = profile.host; document.getElementById('portInput').value = profile.port; document.getElementById('usernameInput').value = profile.username; - if (window.CommandSetManager) { - CommandSetManager.selectForConnection(profile.command_set_id); - } + window.ConnectionCommandManager?.applyProfile(profile); const legacyNotice = document.getElementById('legacyCommandsNotice'); const convertButton = document.getElementById('convertLegacyCommandsBtn'); - const hasLegacyCommands = !profile.command_set_id + const hasLegacyCommands = !profile.startup_mode + && !profile.command_set_id && typeof profile.startup_commands === 'string' && profile.startup_commands.trim(); this.selectedLegacyStartupCommands = hasLegacyCommands @@ -289,6 +348,317 @@ const ProfileManager = { } }, + t(key, fallback) { + if (window.i18n) { + const translated = window.i18n.t(key); + if (translated && translated !== key) return translated; + } + return fallback; + }, + + inferPostConnectMode(profile) { + return window.ConnectionCommandManager?.inferProfileMode(profile) + || (profile.command_set_id ? 'command_set' + : profile.command_id ? 'command' + : profile.startup_commands ? 'free_text' : 'none'); + }, + + openManagement() { + this.editingProfileId = null; + this.showManagementList(); + this.loadProfiles(); + this.loadKeys(); + window.JumpHostManager?.load(); + window.ModalManager?.open(document.getElementById('profileManagementModal')); + }, + + showManagementList() { + document.getElementById('profileEditorView')?.classList.add('hidden'); + document.getElementById('profileManagementView')?.classList.remove('hidden'); + this.renderManagementList(); + }, + + renderManagementList() { + const container = document.getElementById('profileManagementList'); + if (!container) return; + container.replaceChildren(); + if (!this.profiles.length) { + const empty = document.createElement('p'); + empty.className = 'no-items'; + empty.textContent = this.t('profiles.none', 'No profiles saved.'); + container.appendChild(empty); + return; + } + + this.profiles.forEach(profile => { + const card = document.createElement('article'); + card.className = 'profile-management-item'; + const info = document.createElement('div'); + info.className = 'profile-management-info'; + const name = document.createElement('strong'); + name.textContent = profile.name; + const target = document.createElement('span'); + target.textContent = `${profile.username}@${profile.host}:${profile.port}`; + const details = document.createElement('span'); + const mode = this.inferPostConnectMode(profile); + const modeKey = { + none: 'commandModes.none', + command_set: 'commandModes.commandSet', + command: 'commandModes.command', + free_text: 'commandModes.freeText', + }[mode]; + let modeLabel = this.t( + modeKey, mode.replace('_', ' ') + ); + if (mode === 'command') { + const command = (window.CommandLibrary?.commands || []).find( + item => item.id === profile.command_id + ); + if (command) modeLabel += `: ${command.name}`; + } + if (mode === 'command_set') { + const commandSet = (window.CommandSetManager?.commandSets || []).find( + item => item.id === profile.command_set_id + ); + if (commandSet) modeLabel += `: ${commandSet.name}`; + } + details.textContent = `${profile.auth_type} · ${modeLabel}`; + info.append(name, target, details); + + const actions = document.createElement('div'); + actions.className = 'profile-management-actions'; + [ + ['connect', this.t('connection.connect', 'Connect'), 'btn-primary'], + ['edit', this.t('common.edit', 'Edit'), 'btn-secondary'], + ['delete', this.t('common.delete', 'Delete'), 'btn-danger'], + ].forEach(([action, label, style]) => { + const button = document.createElement('button'); + button.type = 'button'; + button.className = `btn btn-sm ${style}`; + button.dataset.profileAction = action; + button.dataset.profileId = profile.id; + button.textContent = label; + actions.appendChild(button); + }); + card.append(info, actions); + container.appendChild(card); + }); + }, + + _fillSelect(select, items, placeholder, labelBuilder) { + if (!select) return; + const current = select.value; + select.replaceChildren(); + const none = document.createElement('option'); + none.value = ''; + none.textContent = placeholder; + select.appendChild(none); + items.forEach(item => { + const option = document.createElement('option'); + option.value = item.id; + option.textContent = labelBuilder(item); + select.appendChild(option); + }); + select.value = current; + }, + + renderEditorSelects() { + this._fillSelect( + document.getElementById('profileEditorKeySelect'), + this.keys, + this.t('connection.selectSSHKey', 'Select SSH Key'), + key => `${key.name} (${key.key_type})`, + ); + this._fillSelect( + document.getElementById('profileEditorJumpHostSelect'), + window.JumpHostManager?.jumpHosts || [], + this.t('connection.noJumpHost', 'None (direct connection)'), + jump => `${jump.name} (${jump.username}@${jump.host}:${jump.port})`, + ); + this._fillSelect( + document.getElementById('profileEditorCommandSelect'), + window.CommandLibrary?.commands || [], + this.t('commandModes.selectCommand', 'Select a Command'), + command => command.name, + ); + this._fillSelect( + document.getElementById('profileEditorCommandSetSelect'), + window.CommandSetManager?.commandSets || [], + this.t('commandSets.none', 'Select a Command Set'), + commandSet => commandSet.name, + ); + this.renderEditorCommandPreview(); + }, + + openEditor(profileId = null) { + const profile = profileId + ? this.profiles.find(item => item.id === profileId) + : null; + if (profileId && !profile) return; + this.editingProfileId = profile?.id || null; + this.renderEditorSelects(); + + document.getElementById('profileEditorForm')?.reset(); + document.getElementById('profileEditorId').value = profile?.id || ''; + document.getElementById('profileEditorName').value = profile?.name || ''; + document.getElementById('profileEditorHost').value = profile?.host || ''; + document.getElementById('profileEditorPort').value = profile?.port || 22; + document.getElementById('profileEditorUsername').value = profile?.username || ''; + document.getElementById('profileEditorAuthType').value = profile?.auth_type || 'password'; + document.getElementById('profileEditorKeySelect').value = profile?.key_id || ''; + document.getElementById('profileEditorJumpHostSelect').value = profile?.jump_host_id || ''; + document.getElementById('profileEditorPostConnectMode').value = profile + ? this.inferPostConnectMode(profile) + : 'none'; + document.getElementById('profileEditorCommandSelect').value = profile?.command_id || ''; + document.getElementById('profileEditorCommandSetSelect').value = profile?.command_set_id || ''; + document.getElementById('profileEditorStartupCommands').value = profile?.startup_commands || ''; + + const hasOverride = Object.prototype.hasOwnProperty.call( + profile || {}, 'parameters_override' + ); + document.getElementById('profileEditorUseDefaultParameters').checked = !hasOverride; + document.getElementById('profileEditorCommandParameters').value = hasOverride + ? (profile.parameters_override || '') + : ''; + + this.updateEditorVisibility(); + document.getElementById('profileManagementView')?.classList.add('hidden'); + document.getElementById('profileEditorView')?.classList.remove('hidden'); + document.getElementById('profileEditorName')?.focus(); + }, + + updateEditorVisibility() { + const authType = document.getElementById('profileEditorAuthType')?.value; + document.getElementById('profileEditorKeyGroup')?.classList.toggle( + 'hidden', authType !== 'key' + ); + const mode = document.getElementById('profileEditorPostConnectMode')?.value || 'none'; + document.getElementById('profileEditorCommandSetGroup')?.classList.toggle( + 'hidden', mode !== 'command_set' + ); + document.getElementById('profileEditorCommandGroup')?.classList.toggle( + 'hidden', mode !== 'command' + ); + document.getElementById('profileEditorFreeTextGroup')?.classList.toggle( + 'hidden', mode !== 'free_text' + ); + const useDefault = document.getElementById('profileEditorUseDefaultParameters'); + const parameterInput = document.getElementById('profileEditorCommandParameters'); + if (parameterInput) parameterInput.disabled = useDefault?.checked !== false; + this.renderEditorCommandPreview(); + }, + + renderEditorCommandPreview() { + const preview = document.getElementById('profileEditorCommandPreview'); + if (!preview) return; + const mode = document.getElementById('profileEditorPostConnectMode')?.value || 'none'; + let text = ''; + let error = false; + + if (mode === 'none') { + text = this.t('commandSets.noSelectionHint', 'No commands will run after connecting.'); + } else if (mode === 'free_text') { + text = document.getElementById('profileEditorStartupCommands')?.value + || this.t('commandModes.emptyFreeText', 'Enter at least one command.'); + } else if (mode === 'command') { + const commandId = document.getElementById('profileEditorCommandSelect')?.value; + const command = (window.CommandLibrary?.commands || []).find( + item => item.id === commandId + ); + if (!command) { + text = this.t('commandModes.missingCommand', 'Select a command first.'); + error = Boolean(commandId); + } else { + const useDefault = document.getElementById( + 'profileEditorUseDefaultParameters' + )?.checked !== false; + const parameters = useDefault + ? (command.parameters || '') + : (document.getElementById('profileEditorCommandParameters')?.value || ''); + text = command.command + (parameters ? ` ${parameters}` : ''); + } + } else { + const commandSetId = document.getElementById( + 'profileEditorCommandSetSelect' + )?.value; + const commandSet = (window.CommandSetManager?.commandSets || []).find( + item => item.id === commandSetId + ); + if (!commandSet) { + text = this.t('commandSets.missingSetHint', 'Select a Command Set first.'); + error = Boolean(commandSetId); + } else if (commandSet.resolution_error) { + text = commandSet.resolution_error; + error = true; + } else { + text = commandSet.resolved_command || commandSet.steps + .map(step => window.CommandSetManager?.stepSummary(step) || '') + .join(' && '); + } + } + + preview.textContent = text; + preview.classList.toggle('empty', mode === 'none' || !text); + preview.classList.toggle('error', error); + }, + + saveFromEditor() { + if (!window.socket) return; + const mode = document.getElementById('profileEditorPostConnectMode').value; + const payload = { + name: document.getElementById('profileEditorName').value.trim(), + host: document.getElementById('profileEditorHost').value.trim(), + port: Number(document.getElementById('profileEditorPort').value) || 22, + username: document.getElementById('profileEditorUsername').value.trim(), + auth_type: document.getElementById('profileEditorAuthType').value, + key_id: document.getElementById('profileEditorKeySelect').value || null, + jump_host_id: document.getElementById('profileEditorJumpHostSelect').value || null, + startup_mode: mode, + }; + if (this.editingProfileId) payload.id = this.editingProfileId; + if (mode === 'command_set') { + payload.command_set_id = document.getElementById( + 'profileEditorCommandSetSelect' + ).value; + } + if (mode === 'command') { + payload.command_id = document.getElementById('profileEditorCommandSelect').value; + if (!document.getElementById('profileEditorUseDefaultParameters').checked) { + payload.parameters_override = document.getElementById( + 'profileEditorCommandParameters' + ).value; + } + } + if (mode === 'free_text') { + payload.startup_commands = document.getElementById( + 'profileEditorStartupCommands' + ).value; + } + + window.socket.emit('save_profile', payload, acknowledgement => { + if (!acknowledgement?.success) { + window.showNotification?.( + acknowledgement?.error || this.t('profiles.saveFailed', 'Failed to save profile'), + 'error', + ); + return; + } + const saved = acknowledgement.profile; + this.profiles = [ + ...this.profiles.filter(profile => profile.id !== saved.id), + saved, + ]; + this.renderProfileSelect(); + this.showManagementList(); + }); + }, + + connect(profileId) { + window.ModalManager?.close(document.getElementById('profileManagementModal')); + window.openConnectionModalForProfile?.(profileId); + }, + saveProfile(profileData) { if (window.socket) { window.socket.emit('save_profile', profileData); @@ -320,3 +690,5 @@ const ProfileManager = { } } }; + +window.ProfileManager = ProfileManager; diff --git a/templates/index.html b/templates/index.html index c2d83e0..feefa6b 100644 --- a/templates/index.html +++ b/templates/index.html @@ -34,7 +34,6 @@

Web SSH Terminal

-
@@ -73,8 +72,10 @@

Web SSH Terminal

- {% if current_user.is_admin %} Admin{% endif %} + + + {% if current_user.is_admin %} Admin{% endif %} @@ -256,27 +257,58 @@

New SSH Co
This bastion uses password auth — enter its password (never stored).
-
- -
- - +
+
+ Run after connecting (optional) + + Choose one source. The exact command is shown before connecting. +
+
+ + + + +
+ + + + + + + +
+ Exact command preview
-
+

                         
-                        
Runs on the remote host after a successful connection, not in WebSSH. Not run again when reconnecting to an existing tmux session.
-
- -
- +
Runs on the remote host after a successful connection, not in WebSSH. Not run again when reconnecting to an existing tmux session.
{% if tmux_enabled %} @@ -289,12 +321,6 @@

New SSH Co

{% endif %} - -
+ +