sshplex 1.0.0__py3-none-any.whl
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/__init__.py +4 -0
- sshplex/cli.py +103 -0
- sshplex/config-template.yaml +35 -0
- sshplex/lib/__init__.py +1 -0
- sshplex/lib/config.py +196 -0
- sshplex/lib/logger.py +54 -0
- sshplex/lib/multiplexer/__init__.py +1 -0
- sshplex/lib/multiplexer/base.py +48 -0
- sshplex/lib/multiplexer/tmux.py +227 -0
- sshplex/lib/sot/__init__.py +1 -0
- sshplex/lib/sot/base.py +57 -0
- sshplex/lib/sot/netbox.py +174 -0
- sshplex/lib/ssh/__init__.py +1 -0
- sshplex/lib/ssh/connection.py +1 -0
- sshplex/lib/ssh/manager.py +1 -0
- sshplex/lib/ui/__init__.py +1 -0
- sshplex/lib/ui/host_selector.py +492 -0
- sshplex/lib/ui/session_manager.py +500 -0
- sshplex/main.py +182 -0
- sshplex/populate_examples.py +0 -0
- sshplex/sshplex_connector.py +113 -0
- sshplex-1.0.0.dist-info/METADATA +321 -0
- sshplex-1.0.0.dist-info/RECORD +27 -0
- sshplex-1.0.0.dist-info/WHEEL +5 -0
- sshplex-1.0.0.dist-info/entry_points.txt +3 -0
- sshplex-1.0.0.dist-info/licenses/LICENSE +201 -0
- sshplex-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,492 @@
|
|
|
1
|
+
"""SSHplex TUI Host Selector with Textual."""
|
|
2
|
+
|
|
3
|
+
from typing import List, Optional, Set, Any
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from textual.app import App, ComposeResult
|
|
6
|
+
from textual.containers import Container, Vertical, Horizontal
|
|
7
|
+
from textual.widgets import DataTable, Log, Static, Footer, Input
|
|
8
|
+
from textual.binding import Binding
|
|
9
|
+
from textual.reactive import reactive
|
|
10
|
+
from textual import events
|
|
11
|
+
|
|
12
|
+
from ..logger import get_logger
|
|
13
|
+
from ..sot.netbox import NetBoxProvider
|
|
14
|
+
from ..sot.base import Host
|
|
15
|
+
from .session_manager import TmuxSessionManager
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class HostSelector(App):
|
|
19
|
+
"""SSHplex TUI for selecting hosts to connect to."""
|
|
20
|
+
|
|
21
|
+
CSS = """
|
|
22
|
+
Screen {
|
|
23
|
+
layout: vertical;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
#log-panel {
|
|
27
|
+
height: 20%;
|
|
28
|
+
border: solid $primary;
|
|
29
|
+
margin: 0 1;
|
|
30
|
+
margin-bottom: 1;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
#main-panel {
|
|
34
|
+
height: 1fr;
|
|
35
|
+
border: solid $primary;
|
|
36
|
+
margin: 0 1;
|
|
37
|
+
margin-bottom: 1;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
#status-bar {
|
|
41
|
+
height: 3;
|
|
42
|
+
background: $surface;
|
|
43
|
+
color: $text;
|
|
44
|
+
padding: 0 1;
|
|
45
|
+
margin: 0 1;
|
|
46
|
+
dock: bottom;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
#search-container {
|
|
50
|
+
height: 3;
|
|
51
|
+
margin: 0 1;
|
|
52
|
+
margin-bottom: 1;
|
|
53
|
+
display: none;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
#search-input {
|
|
57
|
+
height: 3;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
DataTable {
|
|
61
|
+
height: 1fr;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
Log {
|
|
65
|
+
height: 1fr;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
#log Input {
|
|
69
|
+
display: none;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
Log > Input {
|
|
73
|
+
display: none;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
Log TextArea {
|
|
77
|
+
display: none;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
Footer {
|
|
81
|
+
dock: bottom;
|
|
82
|
+
}
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
BINDINGS = [
|
|
86
|
+
Binding("space", "toggle_select", "Toggle Select", show=True),
|
|
87
|
+
Binding("a", "select_all", "Select All", show=True),
|
|
88
|
+
Binding("d", "deselect_all", "Deselect All", show=True),
|
|
89
|
+
Binding("enter", "connect_selected", "Connect", show=True),
|
|
90
|
+
Binding("/", "start_search", "Search", show=True),
|
|
91
|
+
Binding("s", "show_sessions", "Sessions", show=True),
|
|
92
|
+
Binding("p", "toggle_panes", "Toggle Panes/Tabs", show=True),
|
|
93
|
+
Binding("b", "toggle_broadcast", "Toggle Broadcast", show=True),
|
|
94
|
+
Binding("escape", "focus_table", "Focus Table", show=False),
|
|
95
|
+
Binding("q", "quit", "Quit", show=True),
|
|
96
|
+
]
|
|
97
|
+
|
|
98
|
+
selected_hosts: reactive[Set[str]] = reactive(set())
|
|
99
|
+
search_filter: reactive[str] = reactive("")
|
|
100
|
+
use_panes: reactive[bool] = reactive(True) # True for panes, False for tabs
|
|
101
|
+
use_broadcast: reactive[bool] = reactive(False) # True for broadcast enabled, False for disabled
|
|
102
|
+
|
|
103
|
+
def __init__(self, config: Any) -> None:
|
|
104
|
+
"""Initialize the host selector.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
config: SSHplex configuration object
|
|
108
|
+
"""
|
|
109
|
+
super().__init__()
|
|
110
|
+
self.config = config
|
|
111
|
+
self.logger = get_logger()
|
|
112
|
+
self.hosts: List[Host] = []
|
|
113
|
+
self.filtered_hosts: List[Host] = []
|
|
114
|
+
self.netbox: Optional[NetBoxProvider] = None
|
|
115
|
+
self.table: Optional[DataTable] = None
|
|
116
|
+
self.log_widget: Optional[Log] = None
|
|
117
|
+
self.status_widget: Optional[Static] = None
|
|
118
|
+
self.search_input: Optional[Input] = None
|
|
119
|
+
|
|
120
|
+
def compose(self) -> ComposeResult:
|
|
121
|
+
"""Create the UI layout."""
|
|
122
|
+
|
|
123
|
+
# Log panel at top (conditionally shown)
|
|
124
|
+
if self.config.ui.show_log_panel:
|
|
125
|
+
with Container(id="log-panel"):
|
|
126
|
+
yield Log(id="log", auto_scroll=True)
|
|
127
|
+
|
|
128
|
+
# Search input (hidden by default)
|
|
129
|
+
with Container(id="search-container"):
|
|
130
|
+
yield Input(placeholder="Search hosts by name...", id="search-input")
|
|
131
|
+
|
|
132
|
+
# Main content panel
|
|
133
|
+
with Container(id="main-panel"):
|
|
134
|
+
yield DataTable(id="host-table", cursor_type="row")
|
|
135
|
+
|
|
136
|
+
# Status bar
|
|
137
|
+
with Container(id="status-bar"):
|
|
138
|
+
yield Static("SSHplex - Loading hosts...", id="status")
|
|
139
|
+
|
|
140
|
+
# Footer with keybindings
|
|
141
|
+
yield Footer()
|
|
142
|
+
|
|
143
|
+
def on_mount(self) -> None:
|
|
144
|
+
"""Initialize the UI and load hosts."""
|
|
145
|
+
# Get widget references
|
|
146
|
+
self.table = self.query_one("#host-table", DataTable)
|
|
147
|
+
if self.config.ui.show_log_panel:
|
|
148
|
+
self.log_widget = self.query_one("#log", Log)
|
|
149
|
+
self.status_widget = self.query_one("#status", Static)
|
|
150
|
+
self.search_input = self.query_one("#search-input", Input)
|
|
151
|
+
|
|
152
|
+
# Setup table columns
|
|
153
|
+
self.setup_table()
|
|
154
|
+
|
|
155
|
+
# Focus on the table by default
|
|
156
|
+
if self.table:
|
|
157
|
+
self.table.focus()
|
|
158
|
+
|
|
159
|
+
# Load hosts from NetBox
|
|
160
|
+
self.call_later(self.load_hosts)
|
|
161
|
+
|
|
162
|
+
self.log_message("SSHplex TUI started")
|
|
163
|
+
|
|
164
|
+
def setup_table(self) -> None:
|
|
165
|
+
"""Setup the data table columns."""
|
|
166
|
+
if not self.table:
|
|
167
|
+
return
|
|
168
|
+
|
|
169
|
+
# Add checkbox column with key
|
|
170
|
+
self.table.add_column("✓", width=3, key="checkbox")
|
|
171
|
+
|
|
172
|
+
# Add configured columns
|
|
173
|
+
for column in self.config.ui.table_columns:
|
|
174
|
+
if column == "name":
|
|
175
|
+
self.table.add_column("Name", width=15, key="name")
|
|
176
|
+
elif column == "ip":
|
|
177
|
+
self.table.add_column("IP Address", width=15, key="ip")
|
|
178
|
+
elif column == "cluster":
|
|
179
|
+
self.table.add_column("Cluster", width=20, key="cluster")
|
|
180
|
+
elif column == "role":
|
|
181
|
+
self.table.add_column("Role", width=15, key="role")
|
|
182
|
+
elif column == "tags":
|
|
183
|
+
self.table.add_column("Tags", width=30, key="tags")
|
|
184
|
+
elif column == "description":
|
|
185
|
+
self.table.add_column("Description", width=40, key="description")
|
|
186
|
+
|
|
187
|
+
async def load_hosts(self) -> None:
|
|
188
|
+
"""Load hosts from NetBox."""
|
|
189
|
+
self.log_message("Connecting to NetBox...")
|
|
190
|
+
self.update_status("Connecting to NetBox...")
|
|
191
|
+
|
|
192
|
+
try:
|
|
193
|
+
# Initialize NetBox provider
|
|
194
|
+
self.netbox = NetBoxProvider(
|
|
195
|
+
url=self.config.netbox.url,
|
|
196
|
+
token=self.config.netbox.token,
|
|
197
|
+
verify_ssl=self.config.netbox.verify_ssl,
|
|
198
|
+
timeout=self.config.netbox.timeout
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
# Connect to NetBox
|
|
202
|
+
if not self.netbox.connect():
|
|
203
|
+
self.log_message("ERROR: Failed to connect to NetBox", level="error")
|
|
204
|
+
self.update_status("Error: NetBox connection failed")
|
|
205
|
+
return
|
|
206
|
+
|
|
207
|
+
self.log_message("Successfully connected to NetBox")
|
|
208
|
+
self.update_status("Loading hosts...")
|
|
209
|
+
|
|
210
|
+
# Get hosts with filters
|
|
211
|
+
self.hosts = self.netbox.get_hosts(filters=self.config.netbox.default_filters)
|
|
212
|
+
self.filtered_hosts = self.hosts.copy() # Initialize filtered hosts
|
|
213
|
+
|
|
214
|
+
if not self.hosts:
|
|
215
|
+
self.log_message("WARNING: No hosts found matching filters", level="warning")
|
|
216
|
+
self.update_status("No hosts found")
|
|
217
|
+
return
|
|
218
|
+
|
|
219
|
+
# Populate table
|
|
220
|
+
self.populate_table()
|
|
221
|
+
|
|
222
|
+
self.log_message(f"Loaded {len(self.hosts)} hosts successfully")
|
|
223
|
+
self.update_status_with_mode()
|
|
224
|
+
|
|
225
|
+
except Exception as e:
|
|
226
|
+
self.log_message(f"ERROR: Failed to load hosts: {e}", level="error")
|
|
227
|
+
self.update_status(f"Error: {e}")
|
|
228
|
+
|
|
229
|
+
def populate_table(self) -> None:
|
|
230
|
+
"""Populate the table with host data."""
|
|
231
|
+
if not self.table:
|
|
232
|
+
return
|
|
233
|
+
|
|
234
|
+
# Clear existing table data
|
|
235
|
+
self.table.clear()
|
|
236
|
+
|
|
237
|
+
# Use filtered hosts if search is active, otherwise use all hosts
|
|
238
|
+
hosts_to_display = self.filtered_hosts if self.search_filter else self.hosts
|
|
239
|
+
|
|
240
|
+
if not hosts_to_display:
|
|
241
|
+
return
|
|
242
|
+
|
|
243
|
+
for host in hosts_to_display:
|
|
244
|
+
# Build row data based on configured columns
|
|
245
|
+
row_data = ["[ ]"] # Checkbox column
|
|
246
|
+
|
|
247
|
+
# Check if this host is selected and update checkbox
|
|
248
|
+
if host.name in self.selected_hosts:
|
|
249
|
+
row_data[0] = "[x]"
|
|
250
|
+
|
|
251
|
+
for column in self.config.ui.table_columns:
|
|
252
|
+
if column == "name":
|
|
253
|
+
row_data.append(host.name)
|
|
254
|
+
elif column == "ip":
|
|
255
|
+
row_data.append(host.ip)
|
|
256
|
+
elif column == "cluster":
|
|
257
|
+
row_data.append(getattr(host, 'cluster', 'N/A'))
|
|
258
|
+
elif column == "role":
|
|
259
|
+
row_data.append(getattr(host, 'role', 'N/A'))
|
|
260
|
+
elif column == "tags":
|
|
261
|
+
row_data.append(getattr(host, 'tags', ''))
|
|
262
|
+
elif column == "description":
|
|
263
|
+
row_data.append(getattr(host, 'description', ''))
|
|
264
|
+
|
|
265
|
+
self.table.add_row(*row_data, key=host.name)
|
|
266
|
+
|
|
267
|
+
def action_toggle_select(self) -> None:
|
|
268
|
+
"""Toggle selection of current row."""
|
|
269
|
+
if not self.table or not self.hosts:
|
|
270
|
+
return
|
|
271
|
+
|
|
272
|
+
cursor_row = self.table.cursor_row
|
|
273
|
+
hosts_to_use = self.filtered_hosts if self.search_filter else self.hosts
|
|
274
|
+
|
|
275
|
+
if cursor_row >= 0 and cursor_row < len(hosts_to_use):
|
|
276
|
+
host_name = hosts_to_use[cursor_row].name
|
|
277
|
+
|
|
278
|
+
if host_name in self.selected_hosts:
|
|
279
|
+
self.selected_hosts.discard(host_name)
|
|
280
|
+
self.update_row_checkbox(host_name, False)
|
|
281
|
+
self.log_message(f"Deselected: {host_name}")
|
|
282
|
+
else:
|
|
283
|
+
self.selected_hosts.add(host_name)
|
|
284
|
+
self.update_row_checkbox(host_name, True)
|
|
285
|
+
self.log_message(f"Selected: {host_name}")
|
|
286
|
+
|
|
287
|
+
self.update_status_selection()
|
|
288
|
+
|
|
289
|
+
def action_select_all(self) -> None:
|
|
290
|
+
"""Select all hosts (filtered if search is active)."""
|
|
291
|
+
if not self.hosts:
|
|
292
|
+
return
|
|
293
|
+
|
|
294
|
+
hosts_to_select = self.filtered_hosts if self.search_filter else self.hosts
|
|
295
|
+
|
|
296
|
+
for host in hosts_to_select:
|
|
297
|
+
self.selected_hosts.add(host.name)
|
|
298
|
+
self.update_row_checkbox(host.name, True)
|
|
299
|
+
|
|
300
|
+
self.log_message(f"Selected all {len(hosts_to_select)} hosts")
|
|
301
|
+
self.update_status_selection()
|
|
302
|
+
|
|
303
|
+
def action_deselect_all(self) -> None:
|
|
304
|
+
"""Deselect all hosts (filtered if search is active)."""
|
|
305
|
+
if not self.hosts:
|
|
306
|
+
return
|
|
307
|
+
|
|
308
|
+
hosts_to_deselect = self.filtered_hosts if self.search_filter else self.hosts
|
|
309
|
+
|
|
310
|
+
for host in hosts_to_deselect:
|
|
311
|
+
self.selected_hosts.discard(host.name)
|
|
312
|
+
self.update_row_checkbox(host.name, False)
|
|
313
|
+
|
|
314
|
+
self.log_message(f"Deselected all {len(hosts_to_deselect)} hosts")
|
|
315
|
+
self.update_status_selection()
|
|
316
|
+
|
|
317
|
+
def action_connect_selected(self) -> None:
|
|
318
|
+
"""Connect to selected hosts and exit the application."""
|
|
319
|
+
self.log_message("INFO: Enter key pressed - processing connection request", level="info")
|
|
320
|
+
|
|
321
|
+
if not self.selected_hosts:
|
|
322
|
+
self.log_message("WARNING: No hosts selected for connection", level="warning")
|
|
323
|
+
return
|
|
324
|
+
|
|
325
|
+
selected_host_objects = [h for h in self.hosts if h.name in self.selected_hosts]
|
|
326
|
+
mode = "Panes" if self.use_panes else "Tabs"
|
|
327
|
+
broadcast = "ON" if self.use_broadcast else "OFF"
|
|
328
|
+
self.log_message(f"INFO: Connecting to {len(selected_host_objects)} selected hosts in {mode} mode with Broadcast {broadcast}...", level="info")
|
|
329
|
+
|
|
330
|
+
# For Phase 1, just log the selection - connection logic will be added later
|
|
331
|
+
for host in selected_host_objects:
|
|
332
|
+
self.log_message(f"INFO: Would connect to: {host.name} ({host.ip}) - Cluster: {getattr(host, 'cluster', 'N/A')}", level="info")
|
|
333
|
+
|
|
334
|
+
self.log_message(f"INFO: Connection request complete. Mode: {mode}, Broadcast: {broadcast}, Hosts: {len(selected_host_objects)}", level="info")
|
|
335
|
+
self.log_message("INFO: Exiting SSHplex TUI application...", level="info")
|
|
336
|
+
|
|
337
|
+
# Exit the app and return selected hosts
|
|
338
|
+
self.app.exit(selected_host_objects)
|
|
339
|
+
|
|
340
|
+
def action_show_sessions(self) -> None:
|
|
341
|
+
"""Show the tmux session manager modal."""
|
|
342
|
+
self.log_message("Opening tmux session manager...")
|
|
343
|
+
session_manager = TmuxSessionManager()
|
|
344
|
+
self.push_screen(session_manager)
|
|
345
|
+
|
|
346
|
+
def update_row_checkbox(self, row_key: str, selected: bool) -> None:
|
|
347
|
+
"""Update the checkbox for a specific row."""
|
|
348
|
+
if not self.table:
|
|
349
|
+
return
|
|
350
|
+
|
|
351
|
+
checkbox = "[X]" if selected else "[ ]"
|
|
352
|
+
self.table.update_cell(row_key, "checkbox", checkbox)
|
|
353
|
+
|
|
354
|
+
def update_status_selection(self) -> None:
|
|
355
|
+
"""Update status bar with selection count and mode."""
|
|
356
|
+
self.update_status_with_mode()
|
|
357
|
+
|
|
358
|
+
def update_status(self, message: str) -> None:
|
|
359
|
+
"""Update the status bar."""
|
|
360
|
+
if self.status_widget:
|
|
361
|
+
self.status_widget.update(message)
|
|
362
|
+
|
|
363
|
+
def log_message(self, message: str, level: str = "info") -> None:
|
|
364
|
+
"""Log a message to both logger and UI log panel."""
|
|
365
|
+
# Log to file
|
|
366
|
+
if level == "error":
|
|
367
|
+
self.logger.error(f"SSHplex TUI: {message}")
|
|
368
|
+
elif level == "warning":
|
|
369
|
+
self.logger.warning(f"SSHplex TUI: {message}")
|
|
370
|
+
else:
|
|
371
|
+
self.logger.info(f"SSHplex TUI: {message}")
|
|
372
|
+
|
|
373
|
+
# Log to UI panel if enabled
|
|
374
|
+
if self.log_widget and self.config.ui.show_log_panel:
|
|
375
|
+
timestamp = datetime.now().strftime("%H:%M:%S")
|
|
376
|
+
level_prefix = level.upper() if level != "info" else "INFO"
|
|
377
|
+
self.log_widget.write_line(f"[{timestamp}] {level_prefix}: {message}")
|
|
378
|
+
|
|
379
|
+
def action_start_search(self) -> None:
|
|
380
|
+
"""Start search mode by showing and focusing the search input."""
|
|
381
|
+
if self.search_input:
|
|
382
|
+
# Show the search container
|
|
383
|
+
search_container = self.query_one("#search-container")
|
|
384
|
+
search_container.styles.display = "block"
|
|
385
|
+
|
|
386
|
+
# Focus on the search input
|
|
387
|
+
self.search_input.focus()
|
|
388
|
+
self.log_message("Search mode activated - type to filter hosts, ESC to focus table")
|
|
389
|
+
|
|
390
|
+
def action_focus_table(self) -> None:
|
|
391
|
+
"""Focus back on the table."""
|
|
392
|
+
if self.table:
|
|
393
|
+
self.table.focus()
|
|
394
|
+
# If search is active, we keep the filter but just change focus
|
|
395
|
+
if self.search_filter:
|
|
396
|
+
self.log_message(f"Table focused - search filter '{self.search_filter}' still active")
|
|
397
|
+
else:
|
|
398
|
+
self.log_message("Table focused")
|
|
399
|
+
|
|
400
|
+
self.log_message("Search cleared - showing all hosts")
|
|
401
|
+
self.update_status_selection()
|
|
402
|
+
|
|
403
|
+
def action_toggle_panes(self) -> None:
|
|
404
|
+
"""Toggle between panes and tabs mode for SSH connections."""
|
|
405
|
+
self.use_panes = not self.use_panes
|
|
406
|
+
mode = "Panes" if self.use_panes else "Tabs"
|
|
407
|
+
self.log_message(f"SSH connection mode switched to: {mode}")
|
|
408
|
+
self.update_status_with_mode()
|
|
409
|
+
|
|
410
|
+
def action_toggle_broadcast(self) -> None:
|
|
411
|
+
"""Toggle broadcast mode for synchronized input across connections."""
|
|
412
|
+
self.use_broadcast = not self.use_broadcast
|
|
413
|
+
broadcast_status = "ON" if self.use_broadcast else "OFF"
|
|
414
|
+
self.log_message(f"Broadcast mode switched to: {broadcast_status}")
|
|
415
|
+
self.update_status_with_mode()
|
|
416
|
+
|
|
417
|
+
def update_status_with_mode(self) -> None:
|
|
418
|
+
"""Update status bar to include current connection mode and broadcast status."""
|
|
419
|
+
mode = "Panes" if self.use_panes else "Tabs"
|
|
420
|
+
broadcast = "ON" if self.use_broadcast else "OFF"
|
|
421
|
+
selected_count = len(self.selected_hosts)
|
|
422
|
+
total_hosts = len(self.filtered_hosts) if self.search_filter else len(self.hosts)
|
|
423
|
+
|
|
424
|
+
if self.search_filter:
|
|
425
|
+
self.update_status(f"Filter: '{self.search_filter}' - {total_hosts}/{len(self.hosts)} hosts, {selected_count} selected | Mode: {mode} | Broadcast: {broadcast}")
|
|
426
|
+
else:
|
|
427
|
+
self.update_status(f"{total_hosts} hosts loaded, {selected_count} selected | Mode: {mode} | Broadcast: {broadcast}")
|
|
428
|
+
|
|
429
|
+
def key_enter(self) -> None:
|
|
430
|
+
"""Handle Enter key press directly."""
|
|
431
|
+
self.action_connect_selected()
|
|
432
|
+
|
|
433
|
+
def on_input_changed(self, event: Input.Changed) -> None:
|
|
434
|
+
"""Handle search input changes."""
|
|
435
|
+
if event.input == self.search_input:
|
|
436
|
+
self.search_filter = event.value.lower().strip()
|
|
437
|
+
|
|
438
|
+
# If search is cleared, hide the search container
|
|
439
|
+
if not self.search_filter:
|
|
440
|
+
search_container = self.query_one("#search-container")
|
|
441
|
+
search_container.styles.display = "none"
|
|
442
|
+
self.log_message("Search cleared")
|
|
443
|
+
|
|
444
|
+
self.filter_hosts()
|
|
445
|
+
|
|
446
|
+
def filter_hosts(self) -> None:
|
|
447
|
+
"""Filter hosts based on search term."""
|
|
448
|
+
if not self.search_filter:
|
|
449
|
+
self.filtered_hosts = self.hosts.copy()
|
|
450
|
+
else:
|
|
451
|
+
self.filtered_hosts = [
|
|
452
|
+
host for host in self.hosts
|
|
453
|
+
if self.search_filter in host.name.lower()
|
|
454
|
+
]
|
|
455
|
+
|
|
456
|
+
# Re-populate table with filtered results
|
|
457
|
+
self.populate_table()
|
|
458
|
+
|
|
459
|
+
# Update status
|
|
460
|
+
if self.search_filter:
|
|
461
|
+
filtered_count = len(self.filtered_hosts)
|
|
462
|
+
total_count = len(self.hosts)
|
|
463
|
+
selected_count = len(self.selected_hosts)
|
|
464
|
+
self.update_status(f"Filter: '{self.search_filter}' - {filtered_count}/{total_count} hosts shown, {selected_count} selected")
|
|
465
|
+
else:
|
|
466
|
+
self.update_status_selection()
|
|
467
|
+
|
|
468
|
+
def on_key(self, event: Any) -> None:
|
|
469
|
+
"""Handle key presses - specifically check for Enter on DataTable."""
|
|
470
|
+
self.log_message(f"DEBUG: Key pressed: {event.key}", level="info")
|
|
471
|
+
|
|
472
|
+
# Check if Enter was pressed while DataTable has focus
|
|
473
|
+
if event.key == "enter" and hasattr(self, 'table') and self.table and self.table.has_focus:
|
|
474
|
+
self.log_message("DEBUG: Enter key pressed on focused DataTable - calling connect action", level="info")
|
|
475
|
+
self.action_connect_selected()
|
|
476
|
+
event.prevent_default()
|
|
477
|
+
event.stop()
|
|
478
|
+
return
|
|
479
|
+
|
|
480
|
+
# Let the event bubble up for normal processing
|
|
481
|
+
event.prevent_default = False
|
|
482
|
+
|
|
483
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
484
|
+
"""Handle Enter key pressed in search input."""
|
|
485
|
+
if event.input == self.search_input:
|
|
486
|
+
# Focus back on the table when Enter is pressed in search
|
|
487
|
+
if self.table:
|
|
488
|
+
self.table.focus()
|
|
489
|
+
if self.search_filter:
|
|
490
|
+
self.log_message(f"Search complete - table focused with filter '{self.search_filter}'")
|
|
491
|
+
else:
|
|
492
|
+
self.log_message("Search complete - table focused")
|