sshplex 1.6.1__tar.gz → 1.6.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {sshplex-1.6.1/sshplex.egg-info → sshplex-1.6.2}/PKG-INFO +6 -112
- {sshplex-1.6.1 → sshplex-1.6.2}/README.md +5 -111
- {sshplex-1.6.1 → sshplex-1.6.2}/pyproject.toml +1 -1
- {sshplex-1.6.1 → sshplex-1.6.2}/sshplex/__init__.py +1 -1
- {sshplex-1.6.1 → sshplex-1.6.2}/sshplex/lib/multiplexer/iterm2_native.py +19 -2
- {sshplex-1.6.1 → sshplex-1.6.2}/sshplex/lib/multiplexer/tmux.py +16 -2
- {sshplex-1.6.1 → sshplex-1.6.2}/sshplex/lib/ui/config_editor.py +91 -15
- {sshplex-1.6.1 → sshplex-1.6.2}/sshplex/lib/ui/host_selector.py +6 -7
- {sshplex-1.6.1 → sshplex-1.6.2}/sshplex/lib/ui/session_manager.py +144 -45
- {sshplex-1.6.1 → sshplex-1.6.2}/sshplex/main.py +3 -3
- {sshplex-1.6.1 → sshplex-1.6.2}/sshplex/sshplex_connector.py +1 -0
- {sshplex-1.6.1 → sshplex-1.6.2/sshplex.egg-info}/PKG-INFO +6 -112
- {sshplex-1.6.1 → sshplex-1.6.2}/LICENSE +0 -0
- {sshplex-1.6.1 → sshplex-1.6.2}/MANIFEST.in +0 -0
- {sshplex-1.6.1 → sshplex-1.6.2}/setup.cfg +0 -0
- {sshplex-1.6.1 → sshplex-1.6.2}/sshplex/cli.py +0 -0
- {sshplex-1.6.1 → sshplex-1.6.2}/sshplex/config-template.yaml +0 -0
- {sshplex-1.6.1 → sshplex-1.6.2}/sshplex/lib/__init__.py +0 -0
- {sshplex-1.6.1 → sshplex-1.6.2}/sshplex/lib/cache.py +0 -0
- {sshplex-1.6.1 → sshplex-1.6.2}/sshplex/lib/config.py +0 -0
- {sshplex-1.6.1 → sshplex-1.6.2}/sshplex/lib/logger.py +0 -0
- {sshplex-1.6.1 → sshplex-1.6.2}/sshplex/lib/multiplexer/__init__.py +0 -0
- {sshplex-1.6.1 → sshplex-1.6.2}/sshplex/lib/multiplexer/base.py +0 -0
- {sshplex-1.6.1 → sshplex-1.6.2}/sshplex/lib/onboarding/__init__.py +0 -0
- {sshplex-1.6.1 → sshplex-1.6.2}/sshplex/lib/onboarding/wizard.py +0 -0
- {sshplex-1.6.1 → sshplex-1.6.2}/sshplex/lib/sot/__init__.py +0 -0
- {sshplex-1.6.1 → sshplex-1.6.2}/sshplex/lib/sot/ansible.py +0 -0
- {sshplex-1.6.1 → sshplex-1.6.2}/sshplex/lib/sot/base.py +0 -0
- {sshplex-1.6.1 → sshplex-1.6.2}/sshplex/lib/sot/consul.py +0 -0
- {sshplex-1.6.1 → sshplex-1.6.2}/sshplex/lib/sot/factory.py +0 -0
- {sshplex-1.6.1 → sshplex-1.6.2}/sshplex/lib/sot/netbox.py +0 -0
- {sshplex-1.6.1 → sshplex-1.6.2}/sshplex/lib/sot/static.py +0 -0
- {sshplex-1.6.1 → sshplex-1.6.2}/sshplex/lib/ui/__init__.py +0 -0
- {sshplex-1.6.1 → sshplex-1.6.2}/sshplex/lib/utils/__init__.py +0 -0
- {sshplex-1.6.1 → sshplex-1.6.2}/sshplex/lib/utils/iterm2.py +0 -0
- {sshplex-1.6.1 → sshplex-1.6.2}/sshplex.egg-info/SOURCES.txt +0 -0
- {sshplex-1.6.1 → sshplex-1.6.2}/sshplex.egg-info/dependency_links.txt +0 -0
- {sshplex-1.6.1 → sshplex-1.6.2}/sshplex.egg-info/entry_points.txt +0 -0
- {sshplex-1.6.1 → sshplex-1.6.2}/sshplex.egg-info/requires.txt +0 -0
- {sshplex-1.6.1 → sshplex-1.6.2}/sshplex.egg-info/top_level.txt +0 -0
- {sshplex-1.6.1 → sshplex-1.6.2}/tests/test_cache.py +0 -0
- {sshplex-1.6.1 → sshplex-1.6.2}/tests/test_config.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sshplex
|
|
3
|
-
Version: 1.6.
|
|
3
|
+
Version: 1.6.2
|
|
4
4
|
Summary: Multiplex your SSH connections with style
|
|
5
5
|
Author-email: MJAHED Sabri <contact@sabrimjahed.com>
|
|
6
6
|
License: MIT
|
|
@@ -61,8 +61,8 @@ SSHplex is a Python-based SSH connection multiplexer with a modern TUI. Connect
|
|
|
61
61
|
|
|
62
62
|
- 🖥️ **Modern TUI** - Textual-based host selector with search, sort, and multi-select
|
|
63
63
|
- 🔌 **Multiple Sources** - NetBox, Ansible, Consul, static lists - use them together
|
|
64
|
-
- 📦 **3 Backends** - tmux standalone, tmux + iTerm2, or iTerm2 native (macOS)
|
|
65
|
-
- ✏️ **Config Editor** - Built-in YAML editor with validation
|
|
64
|
+
- 📦 **3 Mux Backends** - tmux standalone, tmux + iTerm2, or iTerm2 native (macOS)
|
|
65
|
+
- ✏️ **Config Editor** - Built-in YAML editor with validation
|
|
66
66
|
- 🔄 **Broadcast Input** - Sync commands across multiple SSH sessions
|
|
67
67
|
- 🔐 **SSH Security** - Configurable host key checking and retry logic
|
|
68
68
|
- 🚀 **Fast Startup** - Intelligent caching with configurable TTL
|
|
@@ -83,24 +83,9 @@ sshplex
|
|
|
83
83
|
### Prerequisites
|
|
84
84
|
|
|
85
85
|
- Python 3.8+
|
|
86
|
-
- tmux (Linux/macOS) or iTerm2 (macOS)
|
|
86
|
+
- tmux (Linux/macOS) and/or iTerm2 (macOS)
|
|
87
87
|
- SSH key configured for target hosts
|
|
88
88
|
|
|
89
|
-
## Usage
|
|
90
|
-
|
|
91
|
-
| Key | Action |
|
|
92
|
-
|-----|--------|
|
|
93
|
-
| `Space` | Toggle host selection |
|
|
94
|
-
| `a` / `d` | Select / Deselect all |
|
|
95
|
-
| `Enter` | Connect to selected hosts |
|
|
96
|
-
| `/` | Search/filter hosts |
|
|
97
|
-
| `p` | Toggle panes/tabs mode |
|
|
98
|
-
| `b` | Toggle broadcast mode |
|
|
99
|
-
| `e` | Open config editor |
|
|
100
|
-
| `s` | Open session manager |
|
|
101
|
-
| `h` | Show keyboard shortcuts |
|
|
102
|
-
| `q` | Quit |
|
|
103
|
-
|
|
104
89
|
## Multiplexer Backends
|
|
105
90
|
|
|
106
91
|
| Backend | Platform | Best For |
|
|
@@ -109,58 +94,6 @@ sshplex
|
|
|
109
94
|
| **tmux + iTerm2** | macOS | Native UI + persistence |
|
|
110
95
|
| **iTerm2 native** | macOS | Simple setup, no tmux dependency |
|
|
111
96
|
|
|
112
|
-
```yaml
|
|
113
|
-
# ~/.config/sshplex/sshplex.yaml
|
|
114
|
-
tmux:
|
|
115
|
-
backend: "tmux" # or "iterm2-native" on macOS
|
|
116
|
-
layout: "tiled"
|
|
117
|
-
max_panes_per_window: 5
|
|
118
|
-
```
|
|
119
|
-
|
|
120
|
-
## Sources of Truth
|
|
121
|
-
|
|
122
|
-
### Static Hosts
|
|
123
|
-
```yaml
|
|
124
|
-
sot:
|
|
125
|
-
import:
|
|
126
|
-
- name: "my-servers"
|
|
127
|
-
type: static
|
|
128
|
-
hosts:
|
|
129
|
-
- {name: "web-01", ip: "192.168.1.10", tags: ["web"]}
|
|
130
|
-
```
|
|
131
|
-
|
|
132
|
-
### NetBox
|
|
133
|
-
```yaml
|
|
134
|
-
sot:
|
|
135
|
-
import:
|
|
136
|
-
- name: "prod"
|
|
137
|
-
type: netbox
|
|
138
|
-
url: "https://netbox.example.com/"
|
|
139
|
-
token: "your-api-token"
|
|
140
|
-
```
|
|
141
|
-
|
|
142
|
-
### Ansible
|
|
143
|
-
```yaml
|
|
144
|
-
sot:
|
|
145
|
-
import:
|
|
146
|
-
- name: "inventory"
|
|
147
|
-
type: ansible
|
|
148
|
-
inventory_paths: ["/path/to/inventory.yml"]
|
|
149
|
-
```
|
|
150
|
-
|
|
151
|
-
### Consul
|
|
152
|
-
```bash
|
|
153
|
-
pip install "sshplex[consul]"
|
|
154
|
-
```
|
|
155
|
-
```yaml
|
|
156
|
-
sot:
|
|
157
|
-
import:
|
|
158
|
-
- name: "dc1"
|
|
159
|
-
type: consul
|
|
160
|
-
config:
|
|
161
|
-
host: "consul.example.com"
|
|
162
|
-
token: "your-token"
|
|
163
|
-
```
|
|
164
97
|
|
|
165
98
|
## Local Demo (Consul + Ansible)
|
|
166
99
|
|
|
@@ -179,45 +112,12 @@ Demo files:
|
|
|
179
112
|
- `demo/docker-compose.consul-demo.yml`
|
|
180
113
|
- `demo/sshplex.demo.yaml`
|
|
181
114
|
|
|
182
|
-
Example config snippet:
|
|
183
|
-
|
|
184
|
-
```yaml
|
|
185
|
-
sot:
|
|
186
|
-
providers: ["ansible", "consul"]
|
|
187
|
-
import:
|
|
188
|
-
- name: "demo-ansible"
|
|
189
|
-
type: ansible
|
|
190
|
-
inventory_paths:
|
|
191
|
-
- "demo/ansible-inventory-demo.yml"
|
|
192
|
-
|
|
193
|
-
- name: "demo-consul"
|
|
194
|
-
type: consul
|
|
195
|
-
config:
|
|
196
|
-
host: "127.0.0.1"
|
|
197
|
-
port: 8500
|
|
198
|
-
token: ""
|
|
199
|
-
scheme: "http"
|
|
200
|
-
verify: false
|
|
201
|
-
dc: "dc1"
|
|
202
|
-
```
|
|
203
|
-
|
|
204
115
|
Run with the bundled demo config:
|
|
205
116
|
|
|
206
117
|
```bash
|
|
207
118
|
sshplex --config demo/sshplex.demo.yaml
|
|
208
119
|
```
|
|
209
120
|
|
|
210
|
-
## CLI Reference
|
|
211
|
-
|
|
212
|
-
```bash
|
|
213
|
-
sshplex # Launch TUI
|
|
214
|
-
sshplex --onboarding # Interactive setup wizard
|
|
215
|
-
sshplex --debug # Test provider connectivity
|
|
216
|
-
sshplex --show-config # Show config paths
|
|
217
|
-
sshplex --clear-cache # Clear host cache
|
|
218
|
-
sshplex --config /path/to.yml # Use custom config
|
|
219
|
-
```
|
|
220
|
-
|
|
221
121
|
## Documentation
|
|
222
122
|
|
|
223
123
|
| Guide | Description |
|
|
@@ -232,14 +132,8 @@ sshplex --config /path/to.yml # Use custom config
|
|
|
232
132
|
# Basic (tmux only)
|
|
233
133
|
pip install sshplex
|
|
234
134
|
|
|
235
|
-
# With Consul support
|
|
236
|
-
pip install "sshplex[consul]"
|
|
237
|
-
|
|
238
|
-
# With iTerm2 native support (macOS)
|
|
239
|
-
pip install "sshplex[iterm2]"
|
|
240
|
-
|
|
241
|
-
# Development
|
|
242
|
-
pip install -e ".[dev]"
|
|
135
|
+
# With Consul,DEV,Iterm2 support
|
|
136
|
+
pip install "sshplex[dev,consul,iterm2]"
|
|
243
137
|
```
|
|
244
138
|
|
|
245
139
|
## Development
|
|
@@ -8,8 +8,8 @@ SSHplex is a Python-based SSH connection multiplexer with a modern TUI. Connect
|
|
|
8
8
|
|
|
9
9
|
- 🖥️ **Modern TUI** - Textual-based host selector with search, sort, and multi-select
|
|
10
10
|
- 🔌 **Multiple Sources** - NetBox, Ansible, Consul, static lists - use them together
|
|
11
|
-
- 📦 **3 Backends** - tmux standalone, tmux + iTerm2, or iTerm2 native (macOS)
|
|
12
|
-
- ✏️ **Config Editor** - Built-in YAML editor with validation
|
|
11
|
+
- 📦 **3 Mux Backends** - tmux standalone, tmux + iTerm2, or iTerm2 native (macOS)
|
|
12
|
+
- ✏️ **Config Editor** - Built-in YAML editor with validation
|
|
13
13
|
- 🔄 **Broadcast Input** - Sync commands across multiple SSH sessions
|
|
14
14
|
- 🔐 **SSH Security** - Configurable host key checking and retry logic
|
|
15
15
|
- 🚀 **Fast Startup** - Intelligent caching with configurable TTL
|
|
@@ -30,24 +30,9 @@ sshplex
|
|
|
30
30
|
### Prerequisites
|
|
31
31
|
|
|
32
32
|
- Python 3.8+
|
|
33
|
-
- tmux (Linux/macOS) or iTerm2 (macOS)
|
|
33
|
+
- tmux (Linux/macOS) and/or iTerm2 (macOS)
|
|
34
34
|
- SSH key configured for target hosts
|
|
35
35
|
|
|
36
|
-
## Usage
|
|
37
|
-
|
|
38
|
-
| Key | Action |
|
|
39
|
-
|-----|--------|
|
|
40
|
-
| `Space` | Toggle host selection |
|
|
41
|
-
| `a` / `d` | Select / Deselect all |
|
|
42
|
-
| `Enter` | Connect to selected hosts |
|
|
43
|
-
| `/` | Search/filter hosts |
|
|
44
|
-
| `p` | Toggle panes/tabs mode |
|
|
45
|
-
| `b` | Toggle broadcast mode |
|
|
46
|
-
| `e` | Open config editor |
|
|
47
|
-
| `s` | Open session manager |
|
|
48
|
-
| `h` | Show keyboard shortcuts |
|
|
49
|
-
| `q` | Quit |
|
|
50
|
-
|
|
51
36
|
## Multiplexer Backends
|
|
52
37
|
|
|
53
38
|
| Backend | Platform | Best For |
|
|
@@ -56,58 +41,6 @@ sshplex
|
|
|
56
41
|
| **tmux + iTerm2** | macOS | Native UI + persistence |
|
|
57
42
|
| **iTerm2 native** | macOS | Simple setup, no tmux dependency |
|
|
58
43
|
|
|
59
|
-
```yaml
|
|
60
|
-
# ~/.config/sshplex/sshplex.yaml
|
|
61
|
-
tmux:
|
|
62
|
-
backend: "tmux" # or "iterm2-native" on macOS
|
|
63
|
-
layout: "tiled"
|
|
64
|
-
max_panes_per_window: 5
|
|
65
|
-
```
|
|
66
|
-
|
|
67
|
-
## Sources of Truth
|
|
68
|
-
|
|
69
|
-
### Static Hosts
|
|
70
|
-
```yaml
|
|
71
|
-
sot:
|
|
72
|
-
import:
|
|
73
|
-
- name: "my-servers"
|
|
74
|
-
type: static
|
|
75
|
-
hosts:
|
|
76
|
-
- {name: "web-01", ip: "192.168.1.10", tags: ["web"]}
|
|
77
|
-
```
|
|
78
|
-
|
|
79
|
-
### NetBox
|
|
80
|
-
```yaml
|
|
81
|
-
sot:
|
|
82
|
-
import:
|
|
83
|
-
- name: "prod"
|
|
84
|
-
type: netbox
|
|
85
|
-
url: "https://netbox.example.com/"
|
|
86
|
-
token: "your-api-token"
|
|
87
|
-
```
|
|
88
|
-
|
|
89
|
-
### Ansible
|
|
90
|
-
```yaml
|
|
91
|
-
sot:
|
|
92
|
-
import:
|
|
93
|
-
- name: "inventory"
|
|
94
|
-
type: ansible
|
|
95
|
-
inventory_paths: ["/path/to/inventory.yml"]
|
|
96
|
-
```
|
|
97
|
-
|
|
98
|
-
### Consul
|
|
99
|
-
```bash
|
|
100
|
-
pip install "sshplex[consul]"
|
|
101
|
-
```
|
|
102
|
-
```yaml
|
|
103
|
-
sot:
|
|
104
|
-
import:
|
|
105
|
-
- name: "dc1"
|
|
106
|
-
type: consul
|
|
107
|
-
config:
|
|
108
|
-
host: "consul.example.com"
|
|
109
|
-
token: "your-token"
|
|
110
|
-
```
|
|
111
44
|
|
|
112
45
|
## Local Demo (Consul + Ansible)
|
|
113
46
|
|
|
@@ -126,45 +59,12 @@ Demo files:
|
|
|
126
59
|
- `demo/docker-compose.consul-demo.yml`
|
|
127
60
|
- `demo/sshplex.demo.yaml`
|
|
128
61
|
|
|
129
|
-
Example config snippet:
|
|
130
|
-
|
|
131
|
-
```yaml
|
|
132
|
-
sot:
|
|
133
|
-
providers: ["ansible", "consul"]
|
|
134
|
-
import:
|
|
135
|
-
- name: "demo-ansible"
|
|
136
|
-
type: ansible
|
|
137
|
-
inventory_paths:
|
|
138
|
-
- "demo/ansible-inventory-demo.yml"
|
|
139
|
-
|
|
140
|
-
- name: "demo-consul"
|
|
141
|
-
type: consul
|
|
142
|
-
config:
|
|
143
|
-
host: "127.0.0.1"
|
|
144
|
-
port: 8500
|
|
145
|
-
token: ""
|
|
146
|
-
scheme: "http"
|
|
147
|
-
verify: false
|
|
148
|
-
dc: "dc1"
|
|
149
|
-
```
|
|
150
|
-
|
|
151
62
|
Run with the bundled demo config:
|
|
152
63
|
|
|
153
64
|
```bash
|
|
154
65
|
sshplex --config demo/sshplex.demo.yaml
|
|
155
66
|
```
|
|
156
67
|
|
|
157
|
-
## CLI Reference
|
|
158
|
-
|
|
159
|
-
```bash
|
|
160
|
-
sshplex # Launch TUI
|
|
161
|
-
sshplex --onboarding # Interactive setup wizard
|
|
162
|
-
sshplex --debug # Test provider connectivity
|
|
163
|
-
sshplex --show-config # Show config paths
|
|
164
|
-
sshplex --clear-cache # Clear host cache
|
|
165
|
-
sshplex --config /path/to.yml # Use custom config
|
|
166
|
-
```
|
|
167
|
-
|
|
168
68
|
## Documentation
|
|
169
69
|
|
|
170
70
|
| Guide | Description |
|
|
@@ -179,14 +79,8 @@ sshplex --config /path/to.yml # Use custom config
|
|
|
179
79
|
# Basic (tmux only)
|
|
180
80
|
pip install sshplex
|
|
181
81
|
|
|
182
|
-
# With Consul support
|
|
183
|
-
pip install "sshplex[consul]"
|
|
184
|
-
|
|
185
|
-
# With iTerm2 native support (macOS)
|
|
186
|
-
pip install "sshplex[iterm2]"
|
|
187
|
-
|
|
188
|
-
# Development
|
|
189
|
-
pip install -e ".[dev]"
|
|
82
|
+
# With Consul,DEV,Iterm2 support
|
|
83
|
+
pip install "sshplex[dev,consul,iterm2]"
|
|
190
84
|
```
|
|
191
85
|
|
|
192
86
|
## Development
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "sshplex"
|
|
7
|
-
version = "1.6.
|
|
7
|
+
version = "1.6.2"
|
|
8
8
|
description = "Multiplex your SSH connections with style"
|
|
9
9
|
authors = [{name = "MJAHED Sabri", email = "contact@sabrimjahed.com"}]
|
|
10
10
|
readme = "README.md"
|
|
@@ -7,9 +7,11 @@ Backend options:
|
|
|
7
7
|
- backend: "iterm2-native" - Pure iTerm2 Python API (no tmux dependency)
|
|
8
8
|
"""
|
|
9
9
|
|
|
10
|
+
import contextlib
|
|
11
|
+
import io
|
|
10
12
|
import platform
|
|
11
13
|
import re
|
|
12
|
-
from typing import Any,
|
|
14
|
+
from typing import Any, List, Optional, Tuple
|
|
13
15
|
|
|
14
16
|
from ..logger import get_logger
|
|
15
17
|
from .base import MultiplexerBase
|
|
@@ -544,7 +546,22 @@ class ITerm2NativeManager(MultiplexerBase):
|
|
|
544
546
|
# Run with iTerm2's run_until_complete to maintain connection
|
|
545
547
|
try:
|
|
546
548
|
print(f"\n🚀 Creating iTerm2 session with {len(sessions_data)} SSH connections...")
|
|
547
|
-
|
|
549
|
+
noisy_stderr = io.StringIO()
|
|
550
|
+
with contextlib.redirect_stderr(noisy_stderr):
|
|
551
|
+
iterm2.run_until_complete(_create_sessions)
|
|
552
|
+
|
|
553
|
+
stderr_text = noisy_stderr.getvalue().strip()
|
|
554
|
+
if stderr_text:
|
|
555
|
+
benign_markers = [
|
|
556
|
+
"ConnectionClosedError",
|
|
557
|
+
"CancelledError",
|
|
558
|
+
"sent 1000 (OK)",
|
|
559
|
+
"no close frame received",
|
|
560
|
+
]
|
|
561
|
+
if any(marker in stderr_text for marker in benign_markers):
|
|
562
|
+
self.logger.debug("SSHplex: Suppressed benign iTerm2 websocket shutdown noise")
|
|
563
|
+
else:
|
|
564
|
+
self.logger.warning(f"SSHplex: iTerm2 stderr output: {stderr_text}")
|
|
548
565
|
self.logger.info("SSHplex: iTerm2 session created successfully")
|
|
549
566
|
except Exception as e:
|
|
550
567
|
error_msg = str(e)
|
|
@@ -87,10 +87,24 @@ class TmuxManager(MultiplexerBase):
|
|
|
87
87
|
@staticmethod
|
|
88
88
|
def _split_window(window: Any, vertical: bool = True) -> Any:
|
|
89
89
|
"""Split tmux window with libtmux version compatibility."""
|
|
90
|
+
split_window = getattr(window, "split_window", None)
|
|
91
|
+
if callable(split_window):
|
|
92
|
+
try:
|
|
93
|
+
return split_window(vertical=vertical)
|
|
94
|
+
except Exception as exc:
|
|
95
|
+
if "deprecated" not in str(exc).lower() and "removed" not in str(exc).lower():
|
|
96
|
+
raise
|
|
97
|
+
|
|
90
98
|
split = getattr(window, "split", None)
|
|
91
99
|
if callable(split):
|
|
92
|
-
|
|
93
|
-
|
|
100
|
+
try:
|
|
101
|
+
from libtmux.window import PaneDirection
|
|
102
|
+
direction = PaneDirection.Below if vertical else PaneDirection.Right
|
|
103
|
+
return split(direction=direction)
|
|
104
|
+
except Exception:
|
|
105
|
+
return split()
|
|
106
|
+
|
|
107
|
+
raise RuntimeError("No compatible tmux split method found")
|
|
94
108
|
|
|
95
109
|
def create_session(self) -> bool:
|
|
96
110
|
"""Create a new tmux session with SSHplex branding."""
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""SSHplex Configuration Editor Screen."""
|
|
2
2
|
|
|
3
|
+
from pathlib import Path
|
|
3
4
|
from typing import Any, Dict, List
|
|
4
5
|
|
|
5
6
|
import yaml
|
|
@@ -42,6 +43,7 @@ class ConfigEditorScreen(ModalScreen[bool]):
|
|
|
42
43
|
|
|
43
44
|
BINDINGS = [
|
|
44
45
|
Binding("escape", "cancel", "Cancel", show=True),
|
|
46
|
+
Binding("q", "cancel", "Cancel", show=False),
|
|
45
47
|
Binding("ctrl+s", "save", "Save", show=True, priority=True),
|
|
46
48
|
]
|
|
47
49
|
|
|
@@ -77,15 +79,17 @@ class ConfigEditorScreen(ModalScreen[bool]):
|
|
|
77
79
|
}
|
|
78
80
|
|
|
79
81
|
#editor-buttons {
|
|
80
|
-
height:
|
|
82
|
+
height: 5;
|
|
81
83
|
dock: bottom;
|
|
82
84
|
align: center middle;
|
|
83
|
-
|
|
85
|
+
padding: 1 0;
|
|
84
86
|
}
|
|
85
87
|
|
|
86
88
|
#editor-buttons Button {
|
|
89
|
+
height: 3;
|
|
87
90
|
min-width: 18;
|
|
88
91
|
margin: 0 2;
|
|
92
|
+
content-align: center middle;
|
|
89
93
|
}
|
|
90
94
|
|
|
91
95
|
TabbedContent {
|
|
@@ -153,13 +157,22 @@ class ConfigEditorScreen(ModalScreen[bool]):
|
|
|
153
157
|
}
|
|
154
158
|
"""
|
|
155
159
|
|
|
156
|
-
def __init__(self, config: Config) -> None:
|
|
160
|
+
def __init__(self, config: Config, config_path: str = "") -> None:
|
|
157
161
|
super().__init__()
|
|
158
162
|
self.config = config
|
|
163
|
+
self.config_path = config_path
|
|
159
164
|
self._proxy_counter = 0
|
|
160
165
|
self._import_counter = 0
|
|
161
166
|
self._import_types: Dict[str, str] = {} # idx -> current type
|
|
162
167
|
self._mux_backend: str = "tmux" # current mux backend
|
|
168
|
+
self._table_column_presets: Dict[str, List[str]] = {
|
|
169
|
+
"custom": [],
|
|
170
|
+
"minimal": ["name", "ip"],
|
|
171
|
+
"standard": ["name", "ip", "cluster", "role", "tags"],
|
|
172
|
+
"operational": ["name", "ip", "status", "role", "cluster", "source"],
|
|
173
|
+
"inventory": ["name", "ip", "site", "platform", "env", "role", "status"],
|
|
174
|
+
}
|
|
175
|
+
self._table_columns_hint = self._build_table_columns_hint()
|
|
163
176
|
|
|
164
177
|
@staticmethod
|
|
165
178
|
def _safe_select_initial(value: str, allowed: List[str], default: str) -> str:
|
|
@@ -168,6 +181,49 @@ class ConfigEditorScreen(ModalScreen[bool]):
|
|
|
168
181
|
return value
|
|
169
182
|
return default
|
|
170
183
|
|
|
184
|
+
def _detect_table_columns_preset(self) -> str:
|
|
185
|
+
"""Detect which preset matches current table columns."""
|
|
186
|
+
current = [str(c).strip() for c in self.config.ui.table_columns]
|
|
187
|
+
for preset, cols in self._table_column_presets.items():
|
|
188
|
+
if preset == "custom":
|
|
189
|
+
continue
|
|
190
|
+
if current == cols:
|
|
191
|
+
return preset
|
|
192
|
+
return "custom"
|
|
193
|
+
|
|
194
|
+
def _build_table_columns_hint(self) -> str:
|
|
195
|
+
"""Build a user-friendly hint for available table columns."""
|
|
196
|
+
common = ["name", "ip", "cluster", "role", "tags", "status", "source", "site", "platform", "env"]
|
|
197
|
+
metadata_keys: List[str] = []
|
|
198
|
+
|
|
199
|
+
try:
|
|
200
|
+
cache_dir = Path(str(getattr(self.config.cache, "cache_dir", "~/cache/sshplex"))).expanduser()
|
|
201
|
+
cache_file = cache_dir / "hosts.yaml"
|
|
202
|
+
if cache_file.exists():
|
|
203
|
+
with open(cache_file) as f:
|
|
204
|
+
hosts_data = yaml.safe_load(f) or []
|
|
205
|
+
|
|
206
|
+
keys: set[str] = set()
|
|
207
|
+
for host in hosts_data[:200]:
|
|
208
|
+
if not isinstance(host, dict):
|
|
209
|
+
continue
|
|
210
|
+
metadata = host.get("metadata", {})
|
|
211
|
+
if isinstance(metadata, dict):
|
|
212
|
+
keys.update(str(k) for k in metadata)
|
|
213
|
+
|
|
214
|
+
metadata_keys = sorted(k for k in keys if k not in {"name", "ip", "metadata"})
|
|
215
|
+
except Exception:
|
|
216
|
+
metadata_keys = []
|
|
217
|
+
|
|
218
|
+
if metadata_keys:
|
|
219
|
+
sample = ", ".join(metadata_keys[:8])
|
|
220
|
+
return (
|
|
221
|
+
"Presets available. Common: " + ", ".join(common) +
|
|
222
|
+
f" | Cached metadata keys: {sample}"
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
return "Presets available. Common columns: " + ", ".join(common)
|
|
226
|
+
|
|
171
227
|
def compose(self) -> ComposeResult:
|
|
172
228
|
with Vertical(id="config-editor-dialog"):
|
|
173
229
|
yield Static("SSHplex Configuration Editor", id="editor-title")
|
|
@@ -192,11 +248,26 @@ class ConfigEditorScreen(ModalScreen[bool]):
|
|
|
192
248
|
"Log Panel Height (%)",
|
|
193
249
|
Input(value=str(self.config.ui.log_panel_height)),
|
|
194
250
|
)
|
|
251
|
+
yield _form_field(
|
|
252
|
+
"cfg-ui-table_columns_preset",
|
|
253
|
+
"Table Columns Preset",
|
|
254
|
+
Select(
|
|
255
|
+
[
|
|
256
|
+
("Custom", "custom"),
|
|
257
|
+
("Minimal", "minimal"),
|
|
258
|
+
("Standard", "standard"),
|
|
259
|
+
("Operational", "operational"),
|
|
260
|
+
("Inventory", "inventory"),
|
|
261
|
+
],
|
|
262
|
+
value=self._detect_table_columns_preset(),
|
|
263
|
+
),
|
|
264
|
+
"Choose a preset or keep Custom",
|
|
265
|
+
)
|
|
195
266
|
yield _form_field(
|
|
196
267
|
"cfg-ui-table_columns",
|
|
197
268
|
"Table Columns",
|
|
198
269
|
Input(value=", ".join(self.config.ui.table_columns)),
|
|
199
|
-
|
|
270
|
+
self._table_columns_hint,
|
|
200
271
|
)
|
|
201
272
|
yield Static("Logging", classes="section-header")
|
|
202
273
|
yield _form_field(
|
|
@@ -343,6 +414,12 @@ class ConfigEditorScreen(ModalScreen[bool]):
|
|
|
343
414
|
Switch(value=self.config.tmux.broadcast),
|
|
344
415
|
"Start with broadcast enabled",
|
|
345
416
|
)
|
|
417
|
+
yield _form_field(
|
|
418
|
+
"cfg-mux-register_history",
|
|
419
|
+
"Register SSHPlex Commands in Shell History",
|
|
420
|
+
Switch(value=not bool(getattr(self.config.tmux, 'iterm2_native_hide_from_history', True))),
|
|
421
|
+
"iTerm2-native only. OFF means commands are hidden from history.",
|
|
422
|
+
)
|
|
346
423
|
yield _form_field(
|
|
347
424
|
"cfg-mux-window_name",
|
|
348
425
|
"Window Name",
|
|
@@ -469,13 +546,6 @@ class ConfigEditorScreen(ModalScreen[bool]):
|
|
|
469
546
|
),
|
|
470
547
|
"Pane split pattern",
|
|
471
548
|
))
|
|
472
|
-
children.append(_form_field(
|
|
473
|
-
"cfg-mux-iterm2_native_hide_from_history",
|
|
474
|
-
"Hide from Shell History",
|
|
475
|
-
Switch(value=bool(getattr(self.config.tmux, 'iterm2_native_hide_from_history', True))),
|
|
476
|
-
"Prefix dispatched commands with a leading space",
|
|
477
|
-
))
|
|
478
|
-
|
|
479
549
|
return Vertical(*children, id="mux-backend-fields-container")
|
|
480
550
|
|
|
481
551
|
# --- Dynamic proxy list ---
|
|
@@ -628,6 +698,14 @@ class ConfigEditorScreen(ModalScreen[bool]):
|
|
|
628
698
|
self.run_worker(self._rebuild_mux_backend_fields(new_backend))
|
|
629
699
|
return
|
|
630
700
|
|
|
701
|
+
# Handle table-column presets
|
|
702
|
+
if select_id == "cfg-ui-table_columns_preset":
|
|
703
|
+
preset = str(event.value)
|
|
704
|
+
if preset in self._table_column_presets and preset != "custom":
|
|
705
|
+
columns_input = self.query_one("#cfg-ui-table_columns", Input)
|
|
706
|
+
columns_input.value = ", ".join(self._table_column_presets[preset])
|
|
707
|
+
return
|
|
708
|
+
|
|
631
709
|
# Handle import type changes
|
|
632
710
|
if not select_id.startswith("import-") or not select_id.endswith("-type"):
|
|
633
711
|
return
|
|
@@ -785,6 +863,7 @@ class ConfigEditorScreen(ModalScreen[bool]):
|
|
|
785
863
|
"use_panes": self._get_select_value("cfg-mux-use_panes", "panes") == "panes",
|
|
786
864
|
"layout": self._get_select_value("cfg-mux-layout", "tiled"),
|
|
787
865
|
"broadcast": self._get_switch_value("cfg-mux-broadcast"),
|
|
866
|
+
"iterm2_native_hide_from_history": not self._get_switch_value("cfg-mux-register_history", False),
|
|
788
867
|
"window_name": self._get_input_value("cfg-mux-window_name", "sshplex"),
|
|
789
868
|
"max_panes_per_window": int(self._get_input_value("cfg-mux-max_panes_per_window", "5")),
|
|
790
869
|
}
|
|
@@ -800,9 +879,6 @@ class ConfigEditorScreen(ModalScreen[bool]):
|
|
|
800
879
|
mux_data["iterm2_native_target"] = self._get_select_value("cfg-mux-iterm2_native_target", "current-window")
|
|
801
880
|
mux_data["iterm2_profile"] = self._get_input_value("cfg-mux-iterm2_profile", "Default")
|
|
802
881
|
mux_data["iterm2_split_pattern"] = self._get_select_value("cfg-mux-iterm2_split_pattern", "alternate")
|
|
803
|
-
mux_data["iterm2_native_hide_from_history"] = self._get_switch_value(
|
|
804
|
-
"cfg-mux-iterm2_native_hide_from_history"
|
|
805
|
-
)
|
|
806
882
|
|
|
807
883
|
data["tmux"] = mux_data
|
|
808
884
|
|
|
@@ -946,7 +1022,7 @@ class ConfigEditorScreen(ModalScreen[bool]):
|
|
|
946
1022
|
# Remove None values for cleaner YAML
|
|
947
1023
|
yaml_data = _clean_none(yaml_data)
|
|
948
1024
|
|
|
949
|
-
config_path = get_default_config_path()
|
|
1025
|
+
config_path = get_default_config_path() if not self.config_path else Path(self.config_path)
|
|
950
1026
|
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
951
1027
|
|
|
952
1028
|
try:
|
|
@@ -235,7 +235,7 @@ class HostSelector(App):
|
|
|
235
235
|
use_panes: reactive[bool] = reactive(True) # True for panes, False for tabs
|
|
236
236
|
use_broadcast: reactive[bool] = reactive(False) # True for broadcast enabled, False for disabled
|
|
237
237
|
|
|
238
|
-
def __init__(self, config: Any) -> None:
|
|
238
|
+
def __init__(self, config: Any, config_path: str = "") -> None:
|
|
239
239
|
"""Initialize the host selector.
|
|
240
240
|
|
|
241
241
|
Args:
|
|
@@ -243,6 +243,7 @@ class HostSelector(App):
|
|
|
243
243
|
"""
|
|
244
244
|
super().__init__()
|
|
245
245
|
self.config = config
|
|
246
|
+
self.config_path = config_path
|
|
246
247
|
self.logger = get_logger()
|
|
247
248
|
self.hosts: List[Host] = []
|
|
248
249
|
self.filtered_hosts: List[Host] = []
|
|
@@ -693,8 +694,7 @@ class HostSelector(App):
|
|
|
693
694
|
control_with_iterm2 = bool(getattr(self.config.tmux, "control_with_iterm2", False))
|
|
694
695
|
if backend == "iterm2-native":
|
|
695
696
|
self.log_message("Opening iTerm2 native session manager...")
|
|
696
|
-
|
|
697
|
-
self.push_screen(session_manager)
|
|
697
|
+
self.push_screen(ITerm2SessionManager(self.config, self.latest_native_session_name))
|
|
698
698
|
return
|
|
699
699
|
|
|
700
700
|
if control_with_iterm2:
|
|
@@ -705,8 +705,7 @@ class HostSelector(App):
|
|
|
705
705
|
return
|
|
706
706
|
|
|
707
707
|
self.log_message("Opening tmux session manager...")
|
|
708
|
-
|
|
709
|
-
self.push_screen(session_manager)
|
|
708
|
+
self.push_screen(TmuxSessionManager(self.config))
|
|
710
709
|
|
|
711
710
|
def action_edit_config(self) -> None:
|
|
712
711
|
"""Open the configuration editor modal."""
|
|
@@ -716,14 +715,14 @@ class HostSelector(App):
|
|
|
716
715
|
if saved:
|
|
717
716
|
self.run_worker(self._reload_config_runtime(), name="reload_config_runtime")
|
|
718
717
|
|
|
719
|
-
editor = ConfigEditorScreen(self.config)
|
|
718
|
+
editor = ConfigEditorScreen(self.config, self.config_path)
|
|
720
719
|
self.push_screen(editor, callback=_on_editor_close)
|
|
721
720
|
|
|
722
721
|
async def _reload_config_runtime(self) -> None:
|
|
723
722
|
"""Reload configuration from disk and apply it live."""
|
|
724
723
|
try:
|
|
725
724
|
old_sot = self.config.sot.model_dump()
|
|
726
|
-
new_config = load_config()
|
|
725
|
+
new_config = load_config(self.config_path or None)
|
|
727
726
|
await self._apply_runtime_config(new_config)
|
|
728
727
|
self.log_message("Configuration reloaded successfully")
|
|
729
728
|
if old_sot != new_config.sot.model_dump():
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
"""SSHplex TUI tmux session manager widget."""
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
|
+
import contextlib
|
|
5
|
+
import io
|
|
4
6
|
from typing import Any, List, Optional
|
|
5
7
|
|
|
6
8
|
import libtmux
|
|
@@ -91,16 +93,31 @@ class ConfirmDialog(ModalScreen[bool]):
|
|
|
91
93
|
class TmuxSession:
|
|
92
94
|
"""Simple tmux session data structure."""
|
|
93
95
|
|
|
94
|
-
def __init__(
|
|
96
|
+
def __init__(
|
|
97
|
+
self,
|
|
98
|
+
name: str,
|
|
99
|
+
session_id: str,
|
|
100
|
+
created: str,
|
|
101
|
+
age: str,
|
|
102
|
+
windows: int,
|
|
103
|
+
panes: int,
|
|
104
|
+
clients: int,
|
|
105
|
+
active_cmd: str,
|
|
106
|
+
broadcast: bool = False,
|
|
107
|
+
):
|
|
95
108
|
self.name = name
|
|
96
109
|
self.session_id = session_id
|
|
97
110
|
self.created = created
|
|
111
|
+
self.age = age
|
|
98
112
|
self.windows = windows
|
|
99
|
-
self.
|
|
113
|
+
self.panes = panes
|
|
114
|
+
self.clients = clients
|
|
115
|
+
self.active_cmd = active_cmd
|
|
116
|
+
self.broadcast = broadcast
|
|
100
117
|
|
|
101
118
|
def __str__(self) -> str:
|
|
102
|
-
status = "
|
|
103
|
-
return f"{
|
|
119
|
+
status = "ON" if self.broadcast else "OFF"
|
|
120
|
+
return f"{self.name} ({self.windows} windows, broadcast {status})"
|
|
104
121
|
|
|
105
122
|
|
|
106
123
|
class ITerm2ManagedTab:
|
|
@@ -130,20 +147,20 @@ class ITerm2SessionManager(ModalScreen):
|
|
|
130
147
|
}
|
|
131
148
|
|
|
132
149
|
#session-dialog {
|
|
133
|
-
width:
|
|
134
|
-
height:
|
|
150
|
+
width: 96%;
|
|
151
|
+
height: 88%;
|
|
135
152
|
border: thick $primary 60%;
|
|
136
153
|
background: $surface;
|
|
137
154
|
}
|
|
138
155
|
|
|
139
156
|
#session-table {
|
|
140
157
|
height: 1fr;
|
|
141
|
-
margin: 1;
|
|
158
|
+
margin: 0 1;
|
|
142
159
|
}
|
|
143
160
|
|
|
144
161
|
#session-header {
|
|
145
162
|
height: 3;
|
|
146
|
-
margin: 1;
|
|
163
|
+
margin: 1 1 0 1;
|
|
147
164
|
text-align: center;
|
|
148
165
|
background: $primary;
|
|
149
166
|
color: $text;
|
|
@@ -151,7 +168,7 @@ class ITerm2SessionManager(ModalScreen):
|
|
|
151
168
|
|
|
152
169
|
#session-footer {
|
|
153
170
|
height: 3;
|
|
154
|
-
margin: 1;
|
|
171
|
+
margin: 0 1 1 1;
|
|
155
172
|
text-align: center;
|
|
156
173
|
background: $surface;
|
|
157
174
|
color: $text-muted;
|
|
@@ -237,7 +254,9 @@ class ITerm2SessionManager(ModalScreen):
|
|
|
237
254
|
)
|
|
238
255
|
|
|
239
256
|
try:
|
|
240
|
-
|
|
257
|
+
noisy_stderr = io.StringIO()
|
|
258
|
+
with contextlib.redirect_stderr(noisy_stderr):
|
|
259
|
+
iterm2.run_until_complete(_load)
|
|
241
260
|
except SystemExit as e:
|
|
242
261
|
raise RuntimeError("Failed to connect to iTerm2 API") from e
|
|
243
262
|
return loaded
|
|
@@ -322,7 +341,9 @@ class ITerm2SessionManager(ModalScreen):
|
|
|
322
341
|
return
|
|
323
342
|
|
|
324
343
|
try:
|
|
325
|
-
|
|
344
|
+
noisy_stderr = io.StringIO()
|
|
345
|
+
with contextlib.redirect_stderr(noisy_stderr):
|
|
346
|
+
iterm2.run_until_complete(_kill)
|
|
326
347
|
except SystemExit as e:
|
|
327
348
|
raise RuntimeError("Failed to connect to iTerm2 API") from e
|
|
328
349
|
|
|
@@ -381,36 +402,36 @@ class TmuxSessionManager(ModalScreen):
|
|
|
381
402
|
}
|
|
382
403
|
|
|
383
404
|
#session-dialog {
|
|
384
|
-
width:
|
|
385
|
-
height:
|
|
405
|
+
width: 96%;
|
|
406
|
+
height: 88%;
|
|
386
407
|
border: thick $primary 60%;
|
|
387
408
|
background: $surface;
|
|
388
409
|
}
|
|
389
410
|
|
|
390
411
|
#session-table {
|
|
391
412
|
height: 1fr;
|
|
392
|
-
margin: 1;
|
|
413
|
+
margin: 0 1;
|
|
393
414
|
}
|
|
394
415
|
|
|
395
416
|
#session-header {
|
|
396
|
-
height:
|
|
397
|
-
margin: 1;
|
|
417
|
+
height: 2;
|
|
418
|
+
margin: 1 1 0 1;
|
|
398
419
|
text-align: center;
|
|
399
420
|
background: $primary;
|
|
400
421
|
color: $text;
|
|
401
422
|
}
|
|
402
423
|
|
|
403
424
|
#broadcast-status {
|
|
404
|
-
height:
|
|
405
|
-
margin: 1;
|
|
425
|
+
height: 1;
|
|
426
|
+
margin: 0 1;
|
|
406
427
|
text-align: center;
|
|
407
428
|
background: $secondary;
|
|
408
429
|
color: $text;
|
|
409
430
|
}
|
|
410
431
|
|
|
411
432
|
#session-footer {
|
|
412
|
-
height:
|
|
413
|
-
margin: 1;
|
|
433
|
+
height: 2;
|
|
434
|
+
margin: 0 1 1 1;
|
|
414
435
|
text-align: center;
|
|
415
436
|
background: $surface;
|
|
416
437
|
color: $text-muted;
|
|
@@ -443,10 +464,24 @@ class TmuxSessionManager(ModalScreen):
|
|
|
443
464
|
@staticmethod
|
|
444
465
|
def _split_window(window: Any, vertical: bool = True) -> Any:
|
|
445
466
|
"""Split tmux window with libtmux version compatibility."""
|
|
467
|
+
split_window = getattr(window, "split_window", None)
|
|
468
|
+
if callable(split_window):
|
|
469
|
+
try:
|
|
470
|
+
return split_window(vertical=vertical)
|
|
471
|
+
except Exception as exc:
|
|
472
|
+
if "deprecated" not in str(exc).lower() and "removed" not in str(exc).lower():
|
|
473
|
+
raise
|
|
474
|
+
|
|
446
475
|
split = getattr(window, "split", None)
|
|
447
476
|
if callable(split):
|
|
448
|
-
|
|
449
|
-
|
|
477
|
+
try:
|
|
478
|
+
from libtmux.window import PaneDirection
|
|
479
|
+
direction = PaneDirection.Below if vertical else PaneDirection.Right
|
|
480
|
+
return split(direction=direction)
|
|
481
|
+
except Exception:
|
|
482
|
+
return split()
|
|
483
|
+
|
|
484
|
+
raise RuntimeError("No compatible tmux split method found")
|
|
450
485
|
|
|
451
486
|
def _find_tmux_session(self, session_name: str) -> Optional[Any]:
|
|
452
487
|
"""Find tmux session with libtmux compatibility fallbacks."""
|
|
@@ -484,10 +519,14 @@ class TmuxSessionManager(ModalScreen):
|
|
|
484
519
|
self.table = self.query_one("#session-table", DataTable)
|
|
485
520
|
|
|
486
521
|
# Setup table columns
|
|
487
|
-
self.table.add_column("
|
|
488
|
-
self.table.add_column("Session Name", width=
|
|
489
|
-
self.table.add_column("
|
|
490
|
-
self.table.add_column("
|
|
522
|
+
self.table.add_column("Broadcast", width=9)
|
|
523
|
+
self.table.add_column("Session Name", width=26)
|
|
524
|
+
self.table.add_column("Age", width=9)
|
|
525
|
+
self.table.add_column("Clients", width=8)
|
|
526
|
+
self.table.add_column("Active Cmd", width=18)
|
|
527
|
+
self.table.add_column("Created", width=16)
|
|
528
|
+
self.table.add_column("Windows", width=7)
|
|
529
|
+
self.table.add_column("Panes", width=6)
|
|
491
530
|
|
|
492
531
|
# Load sessions first
|
|
493
532
|
self.load_sessions()
|
|
@@ -510,22 +549,37 @@ class TmuxSessionManager(ModalScreen):
|
|
|
510
549
|
self.sessions.clear()
|
|
511
550
|
|
|
512
551
|
for session in tmux_sessions:
|
|
513
|
-
# Check if session is attached (use session.attached property or check windows)
|
|
514
|
-
try:
|
|
515
|
-
# libtmux sessions have an 'attached' property or we can check if it has windows
|
|
516
|
-
attached = hasattr(session, 'attached') and session.attached
|
|
517
|
-
if not hasattr(session, 'attached'):
|
|
518
|
-
# Fallback: check if session has active windows/panes
|
|
519
|
-
attached = len(session.windows) > 0 and any(len(w.panes) > 0 for w in session.windows)
|
|
520
|
-
except (AttributeError, RuntimeError):
|
|
521
|
-
attached = False
|
|
522
|
-
|
|
523
552
|
# Get window count safely
|
|
524
553
|
try:
|
|
525
554
|
window_count = len(session.windows) if hasattr(session, 'windows') else 0
|
|
526
555
|
except (AttributeError, RuntimeError):
|
|
527
556
|
window_count = 0
|
|
528
557
|
|
|
558
|
+
# Get pane count safely
|
|
559
|
+
try:
|
|
560
|
+
pane_count = sum(len(w.panes) for w in session.windows)
|
|
561
|
+
except (AttributeError, RuntimeError, TypeError):
|
|
562
|
+
pane_count = 0
|
|
563
|
+
|
|
564
|
+
# Read synchronize-panes state across session windows
|
|
565
|
+
try:
|
|
566
|
+
broadcast_on = any(
|
|
567
|
+
bool(window.show_options().get("synchronize-panes"))
|
|
568
|
+
for window in session.windows
|
|
569
|
+
)
|
|
570
|
+
except Exception:
|
|
571
|
+
broadcast_on = False
|
|
572
|
+
|
|
573
|
+
# Get attached client count
|
|
574
|
+
try:
|
|
575
|
+
attached_result = session.cmd('display-message', '-p', '#{session_attached}')
|
|
576
|
+
if attached_result and getattr(attached_result, 'stdout', None):
|
|
577
|
+
clients = int(attached_result.stdout[0])
|
|
578
|
+
else:
|
|
579
|
+
clients = 0
|
|
580
|
+
except Exception:
|
|
581
|
+
clients = 0
|
|
582
|
+
|
|
529
583
|
# Get creation time - libtmux doesn't provide session.created directly
|
|
530
584
|
try:
|
|
531
585
|
# Try to get session creation time from tmux itself
|
|
@@ -535,17 +589,54 @@ class TmuxSessionManager(ModalScreen):
|
|
|
535
589
|
timestamp = int(result.stdout[0])
|
|
536
590
|
created_dt = datetime.datetime.fromtimestamp(timestamp)
|
|
537
591
|
created = created_dt.strftime("%Y-%m-%d %H:%M:%S")
|
|
592
|
+
now_dt = datetime.datetime.now()
|
|
593
|
+
age_delta = max((now_dt - created_dt).total_seconds(), 0)
|
|
594
|
+
if age_delta < 60:
|
|
595
|
+
age = f"{int(age_delta)}s"
|
|
596
|
+
elif age_delta < 3600:
|
|
597
|
+
age = f"{int(age_delta // 60)}m"
|
|
598
|
+
elif age_delta < 86400:
|
|
599
|
+
age = f"{int(age_delta // 3600)}h"
|
|
600
|
+
else:
|
|
601
|
+
age = f"{int(age_delta // 86400)}d"
|
|
538
602
|
else:
|
|
539
603
|
created = "Unknown"
|
|
604
|
+
age = "-"
|
|
540
605
|
except (ValueError, AttributeError, IndexError, OSError):
|
|
541
606
|
created = "Unknown"
|
|
607
|
+
age = "-"
|
|
608
|
+
|
|
609
|
+
# Summarize active pane commands in this session
|
|
610
|
+
cmd_counts: dict[str, int] = {}
|
|
611
|
+
try:
|
|
612
|
+
for window in session.windows:
|
|
613
|
+
for pane in window.panes:
|
|
614
|
+
try:
|
|
615
|
+
pane_cmd = str(getattr(pane, 'pane_current_command', '') or '')
|
|
616
|
+
except Exception:
|
|
617
|
+
pane_cmd = ''
|
|
618
|
+
if not pane_cmd:
|
|
619
|
+
pane_cmd = '?'
|
|
620
|
+
cmd_counts[pane_cmd] = cmd_counts.get(pane_cmd, 0) + 1
|
|
621
|
+
except Exception:
|
|
622
|
+
pass
|
|
623
|
+
|
|
624
|
+
if cmd_counts:
|
|
625
|
+
top = sorted(cmd_counts.items(), key=lambda x: x[1], reverse=True)[:2]
|
|
626
|
+
active_cmd = ", ".join(f"{name}({count})" for name, count in top)
|
|
627
|
+
else:
|
|
628
|
+
active_cmd = "-"
|
|
542
629
|
|
|
543
630
|
tmux_session = TmuxSession(
|
|
544
631
|
name=session.session_name or "Unknown",
|
|
545
632
|
session_id=session.session_id or "Unknown",
|
|
546
633
|
created=created,
|
|
634
|
+
age=age,
|
|
547
635
|
windows=window_count,
|
|
548
|
-
|
|
636
|
+
panes=pane_count,
|
|
637
|
+
clients=clients,
|
|
638
|
+
active_cmd=active_cmd,
|
|
639
|
+
broadcast=broadcast_on
|
|
549
640
|
)
|
|
550
641
|
self.sessions.append(tmux_session)
|
|
551
642
|
|
|
@@ -559,7 +650,7 @@ class TmuxSessionManager(ModalScreen):
|
|
|
559
650
|
# Show error in table
|
|
560
651
|
if self.table is not None:
|
|
561
652
|
self.table.clear()
|
|
562
|
-
self.table.add_row("
|
|
653
|
+
self.table.add_row("-", "tmux error", "-", "-", "-", str(e), "0", "0")
|
|
563
654
|
|
|
564
655
|
def populate_table(self) -> None:
|
|
565
656
|
"""Populate the table with session data."""
|
|
@@ -570,19 +661,20 @@ class TmuxSessionManager(ModalScreen):
|
|
|
570
661
|
self.table.clear()
|
|
571
662
|
|
|
572
663
|
if not self.sessions:
|
|
573
|
-
self.table.add_row("
|
|
664
|
+
self.table.add_row("-", "No tmux sessions found", "-", "-", "-", "Create one with SSHplex", "0", "0")
|
|
574
665
|
return
|
|
575
666
|
|
|
576
667
|
# Add sessions to table
|
|
577
668
|
for session in self.sessions:
|
|
578
|
-
status_icon = "📎" if session.attached else "💤"
|
|
579
|
-
status_text = "Active" if session.attached else "Detached"
|
|
580
|
-
|
|
581
669
|
self.table.add_row(
|
|
582
|
-
|
|
670
|
+
"ON" if session.broadcast else "OFF",
|
|
583
671
|
session.name,
|
|
672
|
+
session.age,
|
|
673
|
+
str(session.clients),
|
|
674
|
+
session.active_cmd,
|
|
584
675
|
session.created,
|
|
585
676
|
str(session.windows),
|
|
677
|
+
str(session.panes),
|
|
586
678
|
key=session.name
|
|
587
679
|
)
|
|
588
680
|
|
|
@@ -755,8 +847,12 @@ class TmuxSessionManager(ModalScreen):
|
|
|
755
847
|
self.logger.error(f"SSHplex: Session '{session.name}' not found")
|
|
756
848
|
return
|
|
757
849
|
|
|
758
|
-
# Toggle
|
|
759
|
-
|
|
850
|
+
# Toggle from actual current state
|
|
851
|
+
current_enabled = any(
|
|
852
|
+
bool(window.show_options().get("synchronize-panes"))
|
|
853
|
+
for window in tmux_session.windows
|
|
854
|
+
)
|
|
855
|
+
self.broadcast_enabled = not current_enabled
|
|
760
856
|
|
|
761
857
|
if self.broadcast_enabled:
|
|
762
858
|
# Enable synchronize-panes for all windows in the session
|
|
@@ -778,6 +874,9 @@ class TmuxSessionManager(ModalScreen):
|
|
|
778
874
|
status_widget = self.query_one("#broadcast-status", Static)
|
|
779
875
|
status_widget.update("📡 Broadcast: OFF")
|
|
780
876
|
|
|
877
|
+
# Refresh table to update per-session broadcast column
|
|
878
|
+
self.load_sessions()
|
|
879
|
+
|
|
781
880
|
except Exception as e:
|
|
782
881
|
self.logger.error(f"SSHplex: Failed to toggle broadcast for session '{session.name}': {e}")
|
|
783
882
|
else:
|
|
@@ -108,7 +108,7 @@ Examples:
|
|
|
108
108
|
return debug_mode(config, logger)
|
|
109
109
|
else:
|
|
110
110
|
# TUI mode - main application
|
|
111
|
-
return tui_mode(config, logger)
|
|
111
|
+
return tui_mode(config, logger, args.config)
|
|
112
112
|
|
|
113
113
|
except FileNotFoundError as e:
|
|
114
114
|
print(f"Error: {e}")
|
|
@@ -236,13 +236,13 @@ def debug_mode(config: Any, logger: Any) -> int:
|
|
|
236
236
|
return 0
|
|
237
237
|
|
|
238
238
|
|
|
239
|
-
def tui_mode(config: Any, logger: Any) -> int:
|
|
239
|
+
def tui_mode(config: Any, logger: Any, config_path: Optional[str] = None) -> int:
|
|
240
240
|
"""Run in TUI mode for interactive host selection and connection."""
|
|
241
241
|
logger.info("Starting TUI mode - interactive host selection")
|
|
242
242
|
|
|
243
243
|
try:
|
|
244
244
|
# Start the host selector TUI
|
|
245
|
-
app = HostSelector(config=config)
|
|
245
|
+
app = HostSelector(config=config, config_path=config_path or "")
|
|
246
246
|
selected_hosts = app.run()
|
|
247
247
|
|
|
248
248
|
if not selected_hosts:
|
|
@@ -44,6 +44,7 @@ class SSHplexConnector:
|
|
|
44
44
|
self.backend = getattr(config.tmux, 'backend', 'tmux') if config else 'tmux'
|
|
45
45
|
self.last_success_count = 0
|
|
46
46
|
self.last_failed_hosts: List[str] = []
|
|
47
|
+
self.multiplexer: Any
|
|
47
48
|
|
|
48
49
|
# Initialize multiplexer based on backend config
|
|
49
50
|
if self.backend == "iterm2-native":
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sshplex
|
|
3
|
-
Version: 1.6.
|
|
3
|
+
Version: 1.6.2
|
|
4
4
|
Summary: Multiplex your SSH connections with style
|
|
5
5
|
Author-email: MJAHED Sabri <contact@sabrimjahed.com>
|
|
6
6
|
License: MIT
|
|
@@ -61,8 +61,8 @@ SSHplex is a Python-based SSH connection multiplexer with a modern TUI. Connect
|
|
|
61
61
|
|
|
62
62
|
- 🖥️ **Modern TUI** - Textual-based host selector with search, sort, and multi-select
|
|
63
63
|
- 🔌 **Multiple Sources** - NetBox, Ansible, Consul, static lists - use them together
|
|
64
|
-
- 📦 **3 Backends** - tmux standalone, tmux + iTerm2, or iTerm2 native (macOS)
|
|
65
|
-
- ✏️ **Config Editor** - Built-in YAML editor with validation
|
|
64
|
+
- 📦 **3 Mux Backends** - tmux standalone, tmux + iTerm2, or iTerm2 native (macOS)
|
|
65
|
+
- ✏️ **Config Editor** - Built-in YAML editor with validation
|
|
66
66
|
- 🔄 **Broadcast Input** - Sync commands across multiple SSH sessions
|
|
67
67
|
- 🔐 **SSH Security** - Configurable host key checking and retry logic
|
|
68
68
|
- 🚀 **Fast Startup** - Intelligent caching with configurable TTL
|
|
@@ -83,24 +83,9 @@ sshplex
|
|
|
83
83
|
### Prerequisites
|
|
84
84
|
|
|
85
85
|
- Python 3.8+
|
|
86
|
-
- tmux (Linux/macOS) or iTerm2 (macOS)
|
|
86
|
+
- tmux (Linux/macOS) and/or iTerm2 (macOS)
|
|
87
87
|
- SSH key configured for target hosts
|
|
88
88
|
|
|
89
|
-
## Usage
|
|
90
|
-
|
|
91
|
-
| Key | Action |
|
|
92
|
-
|-----|--------|
|
|
93
|
-
| `Space` | Toggle host selection |
|
|
94
|
-
| `a` / `d` | Select / Deselect all |
|
|
95
|
-
| `Enter` | Connect to selected hosts |
|
|
96
|
-
| `/` | Search/filter hosts |
|
|
97
|
-
| `p` | Toggle panes/tabs mode |
|
|
98
|
-
| `b` | Toggle broadcast mode |
|
|
99
|
-
| `e` | Open config editor |
|
|
100
|
-
| `s` | Open session manager |
|
|
101
|
-
| `h` | Show keyboard shortcuts |
|
|
102
|
-
| `q` | Quit |
|
|
103
|
-
|
|
104
89
|
## Multiplexer Backends
|
|
105
90
|
|
|
106
91
|
| Backend | Platform | Best For |
|
|
@@ -109,58 +94,6 @@ sshplex
|
|
|
109
94
|
| **tmux + iTerm2** | macOS | Native UI + persistence |
|
|
110
95
|
| **iTerm2 native** | macOS | Simple setup, no tmux dependency |
|
|
111
96
|
|
|
112
|
-
```yaml
|
|
113
|
-
# ~/.config/sshplex/sshplex.yaml
|
|
114
|
-
tmux:
|
|
115
|
-
backend: "tmux" # or "iterm2-native" on macOS
|
|
116
|
-
layout: "tiled"
|
|
117
|
-
max_panes_per_window: 5
|
|
118
|
-
```
|
|
119
|
-
|
|
120
|
-
## Sources of Truth
|
|
121
|
-
|
|
122
|
-
### Static Hosts
|
|
123
|
-
```yaml
|
|
124
|
-
sot:
|
|
125
|
-
import:
|
|
126
|
-
- name: "my-servers"
|
|
127
|
-
type: static
|
|
128
|
-
hosts:
|
|
129
|
-
- {name: "web-01", ip: "192.168.1.10", tags: ["web"]}
|
|
130
|
-
```
|
|
131
|
-
|
|
132
|
-
### NetBox
|
|
133
|
-
```yaml
|
|
134
|
-
sot:
|
|
135
|
-
import:
|
|
136
|
-
- name: "prod"
|
|
137
|
-
type: netbox
|
|
138
|
-
url: "https://netbox.example.com/"
|
|
139
|
-
token: "your-api-token"
|
|
140
|
-
```
|
|
141
|
-
|
|
142
|
-
### Ansible
|
|
143
|
-
```yaml
|
|
144
|
-
sot:
|
|
145
|
-
import:
|
|
146
|
-
- name: "inventory"
|
|
147
|
-
type: ansible
|
|
148
|
-
inventory_paths: ["/path/to/inventory.yml"]
|
|
149
|
-
```
|
|
150
|
-
|
|
151
|
-
### Consul
|
|
152
|
-
```bash
|
|
153
|
-
pip install "sshplex[consul]"
|
|
154
|
-
```
|
|
155
|
-
```yaml
|
|
156
|
-
sot:
|
|
157
|
-
import:
|
|
158
|
-
- name: "dc1"
|
|
159
|
-
type: consul
|
|
160
|
-
config:
|
|
161
|
-
host: "consul.example.com"
|
|
162
|
-
token: "your-token"
|
|
163
|
-
```
|
|
164
97
|
|
|
165
98
|
## Local Demo (Consul + Ansible)
|
|
166
99
|
|
|
@@ -179,45 +112,12 @@ Demo files:
|
|
|
179
112
|
- `demo/docker-compose.consul-demo.yml`
|
|
180
113
|
- `demo/sshplex.demo.yaml`
|
|
181
114
|
|
|
182
|
-
Example config snippet:
|
|
183
|
-
|
|
184
|
-
```yaml
|
|
185
|
-
sot:
|
|
186
|
-
providers: ["ansible", "consul"]
|
|
187
|
-
import:
|
|
188
|
-
- name: "demo-ansible"
|
|
189
|
-
type: ansible
|
|
190
|
-
inventory_paths:
|
|
191
|
-
- "demo/ansible-inventory-demo.yml"
|
|
192
|
-
|
|
193
|
-
- name: "demo-consul"
|
|
194
|
-
type: consul
|
|
195
|
-
config:
|
|
196
|
-
host: "127.0.0.1"
|
|
197
|
-
port: 8500
|
|
198
|
-
token: ""
|
|
199
|
-
scheme: "http"
|
|
200
|
-
verify: false
|
|
201
|
-
dc: "dc1"
|
|
202
|
-
```
|
|
203
|
-
|
|
204
115
|
Run with the bundled demo config:
|
|
205
116
|
|
|
206
117
|
```bash
|
|
207
118
|
sshplex --config demo/sshplex.demo.yaml
|
|
208
119
|
```
|
|
209
120
|
|
|
210
|
-
## CLI Reference
|
|
211
|
-
|
|
212
|
-
```bash
|
|
213
|
-
sshplex # Launch TUI
|
|
214
|
-
sshplex --onboarding # Interactive setup wizard
|
|
215
|
-
sshplex --debug # Test provider connectivity
|
|
216
|
-
sshplex --show-config # Show config paths
|
|
217
|
-
sshplex --clear-cache # Clear host cache
|
|
218
|
-
sshplex --config /path/to.yml # Use custom config
|
|
219
|
-
```
|
|
220
|
-
|
|
221
121
|
## Documentation
|
|
222
122
|
|
|
223
123
|
| Guide | Description |
|
|
@@ -232,14 +132,8 @@ sshplex --config /path/to.yml # Use custom config
|
|
|
232
132
|
# Basic (tmux only)
|
|
233
133
|
pip install sshplex
|
|
234
134
|
|
|
235
|
-
# With Consul support
|
|
236
|
-
pip install "sshplex[consul]"
|
|
237
|
-
|
|
238
|
-
# With iTerm2 native support (macOS)
|
|
239
|
-
pip install "sshplex[iterm2]"
|
|
240
|
-
|
|
241
|
-
# Development
|
|
242
|
-
pip install -e ".[dev]"
|
|
135
|
+
# With Consul,DEV,Iterm2 support
|
|
136
|
+
pip install "sshplex[dev,consul,iterm2]"
|
|
243
137
|
```
|
|
244
138
|
|
|
245
139
|
## Development
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|