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.
-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
-
+
This profile still uses legacy free-text commands.
-
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 %}
-
-
-
-
-
-
+
+
+
+
Profiles
+ ×
+
+
+
+
+
Save and update connection settings without connecting.
+
+
+
+
+
+
+
+
+
+
+
+
@@ -346,11 +469,11 @@
Commands
×
-
-
+
+
-
+
@@ -382,7 +505,7 @@
Commands
-
+
Build reusable command sequences and assign one to any connection profile.