susops 3.0.0rc3.dev1__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.
@@ -0,0 +1,871 @@
1
+ """Connection editor screen — CRUD for connections, PAC hosts, port forwards."""
2
+ from __future__ import annotations
3
+
4
+ from textual import work
5
+ from textual.app import ComposeResult
6
+ from textual.binding import Binding
7
+ from textual.containers import Horizontal
8
+ from textual.screen import Screen, ModalScreen
9
+ from textual.widgets import (
10
+ Button,
11
+ Checkbox,
12
+ DataTable,
13
+ Input,
14
+ Label,
15
+ Select,
16
+ Static,
17
+ TabbedContent,
18
+ TabPane,
19
+ )
20
+
21
+ from susops.core.config import PortForward
22
+ from susops.core.ports import is_port_free, validate_port
23
+ from susops.core.ssh_config import get_ssh_hosts
24
+ from susops.tui.screens import _CollapsingLabel, compose_footer, fmt_bps, fmt_bytes, proto_label, status_dot
25
+
26
+
27
+ def _select_str(screen: ModalScreen, selector_id: str, default: str = "") -> str:
28
+ """Return a Select widget's value as str, or default when nothing is selected."""
29
+ val = screen.query_one(selector_id, Select).value
30
+ return val if isinstance(val, str) else default
31
+
32
+
33
+ def _fw_dot(mgr, fw: PortForward, conn_tag: str, direction: str, conn_running: bool) -> str:
34
+ """Return the status dot for a port forward, handling TCP+UDP partial state."""
35
+ if not fw.enabled:
36
+ return "─"
37
+ if not conn_running:
38
+ return "[red]○[/red]"
39
+ # Connection is running: TCP is up (via master). For TCP+UDP, also check UDP.
40
+ if fw.tcp and fw.udp:
41
+ try:
42
+ udp_up = mgr.is_udp_forward_running(conn_tag, fw.src_port, direction)
43
+ except Exception:
44
+ udp_up = False
45
+ partial = not udp_up # TCP up, UDP down
46
+ return status_dot(running=True, partial=partial)
47
+ return "[green]●[/green]"
48
+
49
+
50
+ class _AddConnectionDialog(ModalScreen):
51
+ """Modal for adding a new SSH connection."""
52
+
53
+ def compose(self) -> ComposeResult:
54
+ ssh_hosts = get_ssh_hosts()
55
+ with Static(classes="modal-dialog"):
56
+ yield Label("[bold]Add Connection[/bold]")
57
+ with Horizontal(classes="modal-form-row"):
58
+ with Static(classes="modal-field"):
59
+ yield Label("Tag:")
60
+ yield Input(placeholder="e.g. work", id="tag")
61
+ with Static(classes="modal-field"):
62
+ yield Label("SOCKS port (0 = auto):")
63
+ yield Input(placeholder="0", id="socks-port", value="0")
64
+ if ssh_hosts:
65
+ yield Label("SSH host (pick from ~/.ssh/config or type below):")
66
+ options = [(h, h) for h in ssh_hosts]
67
+ yield Select(options, prompt="— pick from SSH config —", id="ssh-host-select", allow_blank=True)
68
+ else:
69
+ yield Label("SSH host (user@host):")
70
+ yield Input(placeholder="user@hostname", id="ssh-host")
71
+ yield _CollapsingLabel("", id="error", classes="modal-error")
72
+ with Horizontal(classes="modal-btn-row"):
73
+ yield Button("Add", id="btn-ok", variant="success")
74
+ yield Button("Cancel", id="btn-cancel")
75
+
76
+ def on_select_changed(self, event: Select.Changed) -> None:
77
+ if event.select.id == "ssh-host-select" and isinstance(event.value, str):
78
+ self.query_one("#ssh-host", Input).value = event.value
79
+
80
+ def on_button_pressed(self, event) -> None:
81
+ if event.button.id == "btn-cancel":
82
+ self.dismiss(None)
83
+ return
84
+ tag = self.query_one("#tag", Input).value.strip()
85
+ host = self.query_one("#ssh-host", Input).value.strip()
86
+ port_str = self.query_one("#socks-port", Input).value.strip()
87
+ error_label = self.query_one(".modal-error", Label)
88
+ if not tag:
89
+ error_label.update("Tag is required.")
90
+ return
91
+ if not host:
92
+ error_label.update("SSH host is required.")
93
+ return
94
+ try:
95
+ port = int(port_str or "0")
96
+ except ValueError:
97
+ error_label.update("SOCKS port must be a number.")
98
+ return
99
+ if not validate_port(port, allow_zero=True):
100
+ error_label.update("SOCKS port must be 0 (auto) or between 1 and 65535.")
101
+ return
102
+ if port != 0 and not is_port_free(port):
103
+ error_label.update(f"Port {port} is already in use.")
104
+ return
105
+ self.dismiss({"tag": tag, "host": host, "port": port})
106
+
107
+
108
+ class _AddPacHostDialog(ModalScreen):
109
+ """Modal for adding a PAC host."""
110
+
111
+ def __init__(self, connections: list[str], pac_hosts: dict[str, list[str]], **kwargs) -> None:
112
+ super().__init__(**kwargs)
113
+ self._connections = connections
114
+ self._pac_hosts = pac_hosts # {conn_tag: [host, ...]}
115
+
116
+ def compose(self) -> ComposeResult:
117
+ options = [(tag, tag) for tag in self._connections]
118
+ with Static(classes="modal-dialog"):
119
+ yield Label("[bold]Add PAC Host[/bold]")
120
+ with Horizontal(classes="modal-form-row"):
121
+ with Static(classes="modal-field"):
122
+ yield Label("Host / wildcard / CIDR:")
123
+ yield Input(placeholder="*.example.com or 10.0.0.0/8", id="host")
124
+ with Static(classes="modal-field"):
125
+ yield Label("Connection:")
126
+ yield Select(options, allow_blank=False, id="conn")
127
+ yield _CollapsingLabel("", id="hint", classes="modal-hint")
128
+ yield _CollapsingLabel("", id="error", classes="modal-error")
129
+ with Horizontal(classes="modal-btn-row"):
130
+ yield Button("Add", id="btn-ok", variant="success")
131
+ yield Button("Cancel", id="btn-cancel")
132
+
133
+ def _update_hint(self) -> None:
134
+ host = self.query_one("#host", Input).value.strip()
135
+ selected_conn = _select_str(self, "#conn") or None
136
+ hint = self.query_one("#hint", Label)
137
+ if not host:
138
+ hint.update("")
139
+ return
140
+ in_same = selected_conn is not None and host in self._pac_hosts.get(selected_conn, [])
141
+ others = [tag for tag, hosts in self._pac_hosts.items() if host in hosts and tag != selected_conn]
142
+ if in_same and others:
143
+ hint.update(f"[red]Already in this connection and: {', '.join(others)}[/red]")
144
+ elif in_same:
145
+ hint.update("[red]Already assigned to this connection[/red]")
146
+ elif others:
147
+ hint.update(f"[yellow]Already in: {', '.join(others)}[/yellow]")
148
+ else:
149
+ hint.update("")
150
+
151
+ def on_input_changed(self, event: Input.Changed) -> None:
152
+ if event.input.id == "host":
153
+ self._update_hint()
154
+
155
+ def on_select_changed(self, event) -> None:
156
+ if event.select.id == "conn":
157
+ self._update_hint()
158
+
159
+ def on_button_pressed(self, event) -> None:
160
+ if event.button.id == "btn-cancel":
161
+ self.dismiss(None)
162
+ return
163
+ host = self.query_one("#host", Input).value.strip()
164
+ conn = _select_str(self, "#conn") or None
165
+ if not host:
166
+ self.query_one(".modal-error", Label).update("Host pattern is required.")
167
+ return
168
+ self.dismiss({"host": host, "conn": conn})
169
+
170
+
171
+ class _AddForwardDialog(ModalScreen):
172
+ """Modal for adding a local or remote port forward."""
173
+
174
+ def __init__(self, direction: str, connections: list[str], **kwargs) -> None:
175
+ super().__init__(**kwargs)
176
+ self._direction = direction
177
+ self._connections = connections
178
+
179
+ def compose(self) -> ComposeResult:
180
+ d = self._direction
181
+ conn_options = [(tag, tag) for tag in self._connections]
182
+ bind_options = [("localhost", "localhost"), ("172.17.0.1", "172.17.0.1"), ("0.0.0.0", "0.0.0.0")]
183
+ with Static(classes="modal-dialog"):
184
+ yield Label(f"[bold]Add {d.capitalize()} Forward[/bold]")
185
+ with Horizontal(classes="modal-form-row"):
186
+ with Static(classes="modal-field"):
187
+ yield Label("Connection:")
188
+ yield Select(conn_options, allow_blank=False, id="conn")
189
+ with Static(classes="modal-field"):
190
+ yield Label("Label (optional):")
191
+ yield Input(placeholder="", id="tag")
192
+ with Horizontal(classes="modal-form-row"):
193
+ with Static(classes="modal-field"):
194
+ yield Label("Forward Local Port *:" if d == "local" else "Forward Remote Port *:")
195
+ yield Input(placeholder="8080", id="src-port")
196
+ with Static(classes="modal-field"):
197
+ yield Label("To Remote Port *:" if d == "local" else "To Local Port *:")
198
+ yield Input(placeholder="8080", id="dst-port")
199
+ with Horizontal(classes="modal-form-row"):
200
+ with Static(classes="modal-field"):
201
+ yield Label("Local Bind:" if d == "local" else "Remote Bind:")
202
+ yield Select(bind_options, allow_blank=False, id="src-addr")
203
+ with Static(classes="modal-field"):
204
+ yield Label("Remote Bind:" if d == "local" else "Local Bind:")
205
+ yield Select(bind_options, allow_blank=False, id="dst-addr")
206
+ yield Label("Protocol:")
207
+ with Horizontal(classes="modal-proto-row"):
208
+ yield Checkbox("TCP", value=True, id="proto-tcp")
209
+ yield Checkbox("UDP", value=False, id="proto-udp")
210
+ yield _CollapsingLabel("", id="error", classes="modal-error")
211
+ with Horizontal(classes="modal-btn-row"):
212
+ yield Button("Add", id="btn-ok", variant="success")
213
+ yield Button("Cancel", id="btn-cancel")
214
+
215
+ def on_button_pressed(self, event) -> None:
216
+ if event.button.id == "btn-cancel":
217
+ self.dismiss(None)
218
+ return
219
+ conn = _select_str(self, "#conn")
220
+ src_addr = _select_str(self, "#src-addr", "localhost")
221
+ dst_addr = _select_str(self, "#dst-addr", "localhost")
222
+ tag = self.query_one("#tag", Input).value.strip()
223
+ tcp = self.query_one("#proto-tcp", Checkbox).value
224
+ udp = self.query_one("#proto-udp", Checkbox).value
225
+ error_label = self.query_one(".modal-error", Label)
226
+ if not tcp and not udp:
227
+ error_label.update("Select at least one protocol (TCP or UDP).")
228
+ return
229
+ try:
230
+ src = int(self.query_one("#src-port", Input).value.strip())
231
+ dst = int(self.query_one("#dst-port", Input).value.strip())
232
+ except ValueError:
233
+ error_label.update("Ports must be valid numbers.")
234
+ return
235
+ if not validate_port(src) or not validate_port(dst):
236
+ error_label.update("Ports must be between 1 and 65535.")
237
+ return
238
+ if self._direction == "local" and not is_port_free(src):
239
+ error_label.update(f"Local port {src} is already in use.")
240
+ return
241
+ self.dismiss({
242
+ "conn": conn, "src": src, "dst": dst,
243
+ "src_addr": src_addr, "dst_addr": dst_addr,
244
+ "tag": tag, "dir": self._direction,
245
+ "tcp": tcp, "udp": udp,
246
+ })
247
+
248
+
249
+ class ConnectionsScreen(Screen):
250
+ """TabbedContent CRUD screen for connections, PAC hosts, and port forwards."""
251
+
252
+ BINDINGS = [
253
+ Binding("escape", "app.pop_screen", "Back"),
254
+ Binding("a", "add_item", "Add"),
255
+ Binding("d", "delete_item", "Delete"),
256
+ Binding("t", "toggle_enabled", "Toggle"),
257
+ Binding("e", "test_item", "Test"),
258
+ Binding("s", "start_item", "Start"),
259
+ Binding("x", "stop_item", "Stop"),
260
+ Binding("r", "restart_item", "Restart"), # connections only
261
+ ]
262
+
263
+ DEFAULT_CSS = """
264
+ ConnectionEditorScreen { layout: vertical; }
265
+ #editor-tabs { height: 1fr; }
266
+ DataTable { height: 1fr; }
267
+ #detail-preview {
268
+ height: 7;
269
+ border: round $primary-darken-1;
270
+ border-title-align: left;
271
+ padding: 0 1;
272
+ margin: 0 1 1 1;
273
+ color: $text-muted;
274
+ }
275
+ """
276
+
277
+ def compose(self) -> ComposeResult:
278
+ # yield Header()
279
+ with TabbedContent(id="editor-tabs"):
280
+ with TabPane("Connections", id="tab-connections"):
281
+ yield DataTable(id="tbl-connections", cursor_type="row")
282
+ with TabPane("Domain / IP / CIDR", id="tab-pac"):
283
+ yield DataTable(id="tbl-pac", cursor_type="row")
284
+ with TabPane("Local Forwards", id="tab-local"):
285
+ yield DataTable(id="tbl-local", cursor_type="row")
286
+ with TabPane("Remote Forwards", id="tab-remote"):
287
+ yield DataTable(id="tbl-remote", cursor_type="row")
288
+ yield Static("", id="detail-preview")
289
+ yield from compose_footer()
290
+
291
+ def on_mount(self) -> None:
292
+ self.query_one("#detail-preview", Static).border_title = "Details"
293
+ self._setup_tables()
294
+ self._reload()
295
+ self.set_interval(5.0, self._bg_reload)
296
+
297
+ def _setup_tables(self) -> None:
298
+ tbl = self.query_one("#tbl-connections", DataTable)
299
+ tbl.add_columns("Status", "Tag", "SSH Host", "SOCKS Port", "Domains", "Forwards")
300
+
301
+ tbl = self.query_one("#tbl-pac", DataTable)
302
+ tbl.add_columns("Status", "Host", "Connection")
303
+
304
+ tbl = self.query_one("#tbl-local", DataTable)
305
+ tbl.add_columns("Status", "Connection", "Local Port", "Local Bind", "Remote Port", "Remote Bind", "Protocol",
306
+ "Label")
307
+
308
+ tbl = self.query_one("#tbl-remote", DataTable)
309
+ tbl.add_columns("Status", "Connection", "Remote Port", "Remote Bind", "Local Port", "Local Bind", "Protocol",
310
+ "Label")
311
+
312
+ @staticmethod
313
+ def _reload_table(tbl: DataTable, rows: list[tuple[tuple, "str | None"]]) -> None:
314
+ """Repopulate a DataTable while preserving the cursor row.
315
+
316
+ rows is a list of (row_values_tuple, key_or_None) pairs.
317
+ """
318
+ cur = tbl.cursor_row
319
+ tbl.clear()
320
+ for row_args, key in rows:
321
+ if key is not None:
322
+ tbl.add_row(*row_args, key=key)
323
+ else:
324
+ tbl.add_row(*row_args)
325
+ if tbl.row_count:
326
+ tbl.move_cursor(row=min(cur, tbl.row_count - 1))
327
+
328
+ @work(thread=True)
329
+ def _bg_reload(self) -> None:
330
+ """Background status refresh — fetches live tunnel state then updates UI."""
331
+ mgr = self.app.manager # type: ignore[attr-defined]
332
+ try:
333
+ status_result = mgr.status()
334
+ status_map = {cs.tag: cs.running for cs in status_result.connection_statuses}
335
+ except Exception:
336
+ status_map = {}
337
+ self.app.call_from_thread(self._reload, status_map)
338
+
339
+ def _reload(self, status_map: dict | None = None) -> None:
340
+ """Repopulate all tables, preserving each table's cursor position."""
341
+ if status_map is None:
342
+ try:
343
+ status_result = self.app.manager.status() # type: ignore[attr-defined]
344
+ status_map = {cs.tag: cs.running for cs in status_result.connection_statuses}
345
+ except Exception:
346
+ status_map = {}
347
+
348
+ mgr = self.app.manager # type: ignore[attr-defined]
349
+ config = mgr.list_config()
350
+
351
+ conn_rows: list[tuple[tuple, str | None]] = []
352
+ for conn in config.connections:
353
+ running = status_map.get(conn.tag, False)
354
+ conn_rows.append((
355
+ (status_dot(running, conn.enabled), conn.tag, conn.ssh_host,
356
+ str(conn.socks_proxy_port) if conn.socks_proxy_port else "auto",
357
+ str(len(conn.pac_hosts)),
358
+ str(len(conn.forwards.local) + len(conn.forwards.remote))),
359
+ conn.tag,
360
+ ))
361
+ self._reload_table(self.query_one("#tbl-connections", DataTable), conn_rows)
362
+
363
+ pac_rows: list[tuple[tuple, str | None]] = []
364
+ for conn in config.connections:
365
+ conn_running = status_map.get(conn.tag, False)
366
+ # Merge enabled + disabled hosts and sort alphabetically per
367
+ # connection — without this, toggling a host's enabled flag
368
+ # moves it across the enabled/disabled boundary in the table.
369
+ # Alphabetical sort keeps each host in a stable position
370
+ # regardless of state.
371
+ enabled_set = set(conn.pac_hosts)
372
+ all_hosts = sorted(enabled_set | set(conn.pac_hosts_disabled))
373
+ for host in all_hosts:
374
+ if host in enabled_set:
375
+ pac_rows.append((
376
+ (status_dot(conn_running), host, conn.tag),
377
+ f"{conn.tag}:{host}",
378
+ ))
379
+ else:
380
+ pac_rows.append((
381
+ ("─", host, conn.tag),
382
+ f"{conn.tag}:{host}",
383
+ ))
384
+ self._reload_table(self.query_one("#tbl-pac", DataTable), pac_rows)
385
+
386
+ local_rows: list[tuple[tuple, str | None]] = []
387
+ for conn in config.connections:
388
+ conn_running = status_map.get(conn.tag, False)
389
+ for fw in conn.forwards.local:
390
+ dot = _fw_dot(mgr, fw, conn.tag, "local", conn_running)
391
+ local_rows.append((
392
+ (dot, conn.tag, str(fw.src_port), fw.src_addr,
393
+ str(fw.dst_port), fw.dst_addr, proto_label(fw), fw.tag or ""),
394
+ f"{conn.tag}:L:{fw.src_port}",
395
+ ))
396
+ self._reload_table(self.query_one("#tbl-local", DataTable), local_rows)
397
+
398
+ remote_rows: list[tuple[tuple, str | None]] = []
399
+ for conn in config.connections:
400
+ conn_running = status_map.get(conn.tag, False)
401
+ for fw in conn.forwards.remote:
402
+ dot = _fw_dot(mgr, fw, conn.tag, "remote", conn_running)
403
+ remote_rows.append((
404
+ (dot, conn.tag, str(fw.src_port), fw.src_addr,
405
+ str(fw.dst_port), fw.dst_addr, proto_label(fw), fw.tag or ""),
406
+ f"{conn.tag}:R:{fw.src_port}",
407
+ ))
408
+ self._reload_table(self.query_one("#tbl-remote", DataTable), remote_rows)
409
+
410
+ def _conn_tags(self) -> list[str]:
411
+ return [c.tag for c in self.app.manager.list_config().connections] # type: ignore[attr-defined]
412
+
413
+ def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None:
414
+ active = self.query_one("#editor-tabs", TabbedContent).active
415
+ preview = self.query_one("#detail-preview", Static)
416
+
417
+ try:
418
+ mgr = self.app.manager # type: ignore[attr-defined]
419
+ config = mgr.list_config()
420
+ conn_map = {c.tag: c for c in config.connections}
421
+ except Exception:
422
+ preview.update("")
423
+ return
424
+
425
+ if active == "tab-connections":
426
+ tbl = self.query_one("#tbl-connections", DataTable)
427
+ if tbl.row_count == 0:
428
+ preview.update("")
429
+ return
430
+ try:
431
+ row = tbl.get_row_at(tbl.cursor_row)
432
+ tag = str(row[1])
433
+ conn = conn_map.get(tag)
434
+ if not conn:
435
+ preview.update("")
436
+ return
437
+ rx, tx = mgr.get_bandwidth(tag)
438
+ rx_total, tx_total = mgr.get_bandwidth_totals(tag)
439
+ uptime = mgr.get_uptime(tag)
440
+ port = conn.socks_proxy_port or "auto"
441
+ enabled_local = sum(1 for f in conn.forwards.local if f.enabled)
442
+ enabled_remote = sum(1 for f in conn.forwards.remote if f.enabled)
443
+ total_fwd = len(conn.forwards.local) + len(conn.forwards.remote)
444
+ enabled_fwd = enabled_local + enabled_remote
445
+ uptime_str = f"{int(uptime)}s" if uptime and uptime < 60 else (
446
+ f"{int(uptime // 60)}m{int(uptime % 60)}s" if uptime else "—")
447
+ lines = [
448
+ f"[bold]{conn.ssh_host}[/bold] SOCKS ::{port} up {uptime_str}",
449
+ f"[green]↓[/green] {fmt_bps(rx):>8} total [cyan]{fmt_bytes(rx_total)}[/cyan] "
450
+ f"[yellow]↑[/yellow] {fmt_bps(tx):>8} total [cyan]{fmt_bytes(tx_total)}[/cyan]",
451
+ f"Forwards {enabled_fwd}/{total_fwd} enabled "
452
+ f"({enabled_local} local · {enabled_remote} remote) "
453
+ f"PAC domains {len(conn.pac_hosts)}",
454
+ f"Proxy curl --proxy socks5h://127.0.0.1:{port} http://example.com",
455
+ ]
456
+ preview.update("\n".join(lines))
457
+ except Exception:
458
+ preview.update("")
459
+
460
+ elif active == "tab-pac":
461
+ tbl = self.query_one("#tbl-pac", DataTable)
462
+ if tbl.row_count == 0:
463
+ preview.update("")
464
+ return
465
+ try:
466
+ row = tbl.get_row_at(tbl.cursor_row)
467
+ pattern, conn_tag = str(row[1]), str(row[2])
468
+ conn = conn_map.get(conn_tag)
469
+ port = conn.socks_proxy_port if conn else "?"
470
+ example = pattern.lstrip("*.")
471
+ lines = [
472
+ f"Pattern [bold]{pattern}[/bold]",
473
+ f"Connection {conn_tag}",
474
+ f"Proxy SOCKS5 127.0.0.1:{port}",
475
+ f"Example curl --proxy socks5h://127.0.0.1:{port} http://{example}",
476
+ ]
477
+ preview.update("\n".join(lines))
478
+ except Exception:
479
+ preview.update("")
480
+
481
+ elif active in ("tab-local", "tab-remote"):
482
+ direction = "local" if active == "tab-local" else "remote"
483
+ tbl_id = f"#tbl-{direction}"
484
+ tbl = self.query_one(tbl_id, DataTable)
485
+ if tbl.row_count == 0:
486
+ preview.update("")
487
+ return
488
+ try:
489
+ row = tbl.get_row_at(tbl.cursor_row)
490
+ # row: Status, Connection, src_port, src_addr, dst_port, dst_addr, Protocol, Label
491
+ conn_tag = str(row[1])
492
+ src_port, src_addr = int(str(row[2])), str(row[3])
493
+ dst_port, dst_addr = int(str(row[4])), str(row[5])
494
+ proto = str(row[6])
495
+ label = str(row[7])
496
+ conn = conn_map.get(conn_tag)
497
+
498
+ enabled_fwds = (conn.forwards.local if direction == "local" else conn.forwards.remote) if conn else []
499
+ fw = next((f for f in enabled_fwds if f.src_port == src_port), None)
500
+ enabled = fw.enabled if fw else False
501
+ state = "[green]enabled[/green]" if enabled else "[dim]disabled[/dim]"
502
+
503
+ port_free = is_port_free(src_port)
504
+ port_status = "[green]✓ free[/green]" if port_free else "[red]✗ in use[/red]"
505
+
506
+ fwd_spec = f"{src_addr}:{src_port}:{dst_addr}:{dst_port}"
507
+ flag = "-L" if direction == "local" else "-R"
508
+
509
+ lines = [
510
+ f"[bold]{label or conn_tag}[/bold] {state} {proto} via {conn_tag}",
511
+ f"Bind {src_addr}:{src_port} → {dst_addr}:{dst_port}",
512
+ f"Port {src_port} {port_status}",
513
+ f"Forward ssh -O forward {flag} {fwd_spec}",
514
+ ]
515
+ preview.update("\n".join(lines))
516
+ except Exception:
517
+ preview.update("")
518
+ else:
519
+ preview.update("")
520
+
521
+ def action_add_item(self) -> None:
522
+ active = self.query_one("#editor-tabs", TabbedContent).active
523
+ if active == "tab-connections":
524
+ self._do_add_connection()
525
+ elif active == "tab-pac":
526
+ self._do_add_pac()
527
+ elif active == "tab-local":
528
+ self._do_add_forward("local")
529
+ elif active == "tab-remote":
530
+ self._do_add_forward("remote")
531
+
532
+ def action_delete_item(self) -> None:
533
+ active = self.query_one("#editor-tabs", TabbedContent).active
534
+ if active == "tab-connections":
535
+ self._do_rm_connection()
536
+ elif active == "tab-pac":
537
+ self._do_rm_pac()
538
+ elif active == "tab-local":
539
+ self._do_rm_forward("local")
540
+ elif active == "tab-remote":
541
+ self._do_rm_forward("remote")
542
+
543
+ def on_tabbed_content_tab_activated(self, event: TabbedContent.TabActivated) -> None:
544
+ self.refresh_bindings()
545
+
546
+ def check_action(self, action: str, parameters: tuple) -> bool | None:
547
+ active = self.query_one("#editor-tabs", TabbedContent).active
548
+ on_forward = active in ("tab-local", "tab-remote")
549
+ on_conn = active == "tab-connections"
550
+ if action in ("start_item", "stop_item"):
551
+ return on_conn or on_forward
552
+ if action == "restart_item":
553
+ return on_conn # restart not applicable to forwards
554
+ if action == "toggle_enabled":
555
+ return active in ("tab-connections", "tab-pac", "tab-local", "tab-remote")
556
+ return True
557
+
558
+ def _selected_conn_tag(self) -> str | None:
559
+ tbl = self.query_one("#tbl-connections", DataTable)
560
+ if tbl.row_count == 0:
561
+ return None
562
+ try:
563
+ return str(tbl.get_row_at(tbl.cursor_row)[1])
564
+ except (IndexError, Exception):
565
+ return None
566
+
567
+ def _selected_forward(self, direction: str) -> tuple[str, int] | None:
568
+ tbl_id = "#tbl-local" if direction == "local" else "#tbl-remote"
569
+ tbl = self.query_one(tbl_id, DataTable)
570
+ if tbl.row_count == 0:
571
+ return None
572
+ try:
573
+ row = tbl.get_row_at(tbl.cursor_row)
574
+ return str(row[1]), int(str(row[2]))
575
+ except (IndexError, ValueError):
576
+ return None
577
+
578
+ def action_start_item(self) -> None:
579
+ active = self.query_one("#editor-tabs", TabbedContent).active
580
+ if active == "tab-connections":
581
+ if tag := self._selected_conn_tag():
582
+ self._run_conn_op("start", tag)
583
+ elif active in ("tab-local", "tab-remote"):
584
+ direction = "local" if active == "tab-local" else "remote"
585
+ if sel := self._selected_forward(direction):
586
+ self._run_forward_op("start", sel[0], sel[1], direction)
587
+
588
+ def action_stop_item(self) -> None:
589
+ active = self.query_one("#editor-tabs", TabbedContent).active
590
+ if active == "tab-connections":
591
+ if tag := self._selected_conn_tag():
592
+ self._run_conn_op("stop", tag)
593
+ elif active in ("tab-local", "tab-remote"):
594
+ direction = "local" if active == "tab-local" else "remote"
595
+ if sel := self._selected_forward(direction):
596
+ self._run_forward_op("stop", sel[0], sel[1], direction)
597
+
598
+ def action_restart_item(self) -> None:
599
+ active = self.query_one("#editor-tabs", TabbedContent).active
600
+ if active == "tab-connections":
601
+ if tag := self._selected_conn_tag():
602
+ self._run_conn_op("restart", tag)
603
+ elif active in ("tab-local", "tab-remote"):
604
+ direction = "local" if active == "tab-local" else "remote"
605
+ if sel := self._selected_forward(direction):
606
+ self._run_forward_op("restart", sel[0], sel[1], direction)
607
+
608
+ def action_test_item(self) -> None:
609
+ active = self.query_one("#editor-tabs", TabbedContent).active
610
+ if active == "tab-connections":
611
+ if tag := self._selected_conn_tag():
612
+ self._run_test_conn(tag)
613
+ elif active == "tab-pac":
614
+ tbl = self.query_one("#tbl-pac", DataTable)
615
+ if tbl.row_count == 0:
616
+ return
617
+ try:
618
+ row = tbl.get_row_at(tbl.cursor_row)
619
+ host, conn_tag = str(row[1]), str(row[2])
620
+ self._run_test_domain(host, conn_tag)
621
+ except (IndexError, ValueError):
622
+ pass
623
+ elif active in ("tab-local", "tab-remote"):
624
+ direction = "local" if active == "tab-local" else "remote"
625
+ if sel := self._selected_forward(direction):
626
+ self._run_test_forward(sel[0], sel[1], direction)
627
+
628
+ @work(thread=True)
629
+ def _run_test_conn(self, conn_tag: str) -> None:
630
+ mgr = self.app.manager # type: ignore[attr-defined]
631
+ config = mgr.list_config()
632
+ conn = next((c for c in config.connections if c.tag == conn_tag), None)
633
+ ssh_host = conn.ssh_host if conn else conn_tag
634
+ result = mgr.test_connection(conn_tag)
635
+ if result.success:
636
+ dot = "[green]●[/green]"
637
+ msg = f"{result.message} [dim]{result.latency_ms:.0f}ms[/dim]"
638
+ else:
639
+ dot = "[red]○[/red]"
640
+ msg = result.message
641
+ body = f"[dim]SSH host:[/dim] {ssh_host}\n{dot} {msg}"
642
+ severity = "information" if result.success else "error"
643
+ self.app.call_from_thread(
644
+ self.app.notify, body, title=f"Test: {conn_tag}", severity=severity, timeout=8.0
645
+ )
646
+
647
+ @work(thread=True)
648
+ def _run_test_domain(self, host: str, conn_tag: str) -> None:
649
+ mgr = self.app.manager # type: ignore[attr-defined]
650
+ result = mgr.test_domain(host, conn_tag)
651
+ if result.success:
652
+ dot = "[green]●[/green]"
653
+ msg = f"{result.message} [dim]{result.latency_ms:.0f}ms[/dim]"
654
+ else:
655
+ dot = "[red]○[/red]"
656
+ msg = result.message
657
+ body = f"[dim]Connection:[/dim] {conn_tag}\n[dim]Host:[/dim] {host}\n{dot} {msg}"
658
+ severity = "information" if result.success else "error"
659
+ self.app.call_from_thread(
660
+ self.app.notify, body, title=f"Test: {host}", severity=severity, timeout=8.0
661
+ )
662
+
663
+ @work(thread=True)
664
+ def _run_test_forward(self, conn_tag: str, src_port: int, direction: str) -> None:
665
+ mgr = self.app.manager # type: ignore[attr-defined]
666
+ try:
667
+ results = mgr.test_forward(conn_tag, src_port, direction)
668
+ except Exception as exc:
669
+ self.app.call_from_thread(
670
+ self.app.notify, f"[red]{exc}[/red]",
671
+ title=f"Test: {direction} {src_port}", severity="error", timeout=8.0,
672
+ )
673
+ return
674
+ parts: list[str] = [
675
+ f"[dim]Connection:[/dim] {conn_tag}",
676
+ f"[dim]Direction:[/dim] {direction} port {src_port}",
677
+ ]
678
+ all_ok = True
679
+ for proto, ok in results.items():
680
+ dot = "[green]●[/green]" if ok else "[red]○[/red]"
681
+ if not ok:
682
+ all_ok = False
683
+ if proto == "tcp":
684
+ detail = "port bound" if ok else "port not bound"
685
+ if direction == "remote":
686
+ detail = "master socket alive" if ok else "master socket dead"
687
+ else:
688
+ detail = "socat process running" if ok else "socat process not running"
689
+ parts.append(f"{dot} {proto.upper()} {detail}")
690
+ severity = "information" if all_ok else "error"
691
+ self.app.call_from_thread(
692
+ self.app.notify, "\n".join(parts),
693
+ title=f"Test: {direction} :{src_port}", severity=severity, timeout=8.0,
694
+ )
695
+
696
+ def action_toggle_enabled(self) -> None:
697
+ active = self.query_one("#editor-tabs", TabbedContent).active
698
+ if active == "tab-connections":
699
+ if tag := self._selected_conn_tag():
700
+ self._run_toggle_conn(tag)
701
+ elif active == "tab-pac":
702
+ tbl = self.query_one("#tbl-pac", DataTable)
703
+ if tbl.row_count == 0:
704
+ return
705
+ try:
706
+ row = tbl.get_row_at(tbl.cursor_row)
707
+ host, conn_tag = str(row[1]), str(row[2])
708
+ self._run_toggle_pac(host, conn_tag)
709
+ except (IndexError, ValueError):
710
+ pass
711
+ elif active in ("tab-local", "tab-remote"):
712
+ direction = "local" if active == "tab-local" else "remote"
713
+ if sel := self._selected_forward(direction):
714
+ self._run_toggle_forward(sel[0], sel[1], direction)
715
+
716
+ @work(thread=True)
717
+ def _run_toggle_conn(self, tag: str) -> None:
718
+ mgr = self.app.manager # type: ignore[attr-defined]
719
+ config = mgr.list_config()
720
+ conn = next((c for c in config.connections if c.tag == tag), None)
721
+ if conn:
722
+ try:
723
+ mgr.set_connection_enabled(tag, not conn.enabled)
724
+ except Exception as exc:
725
+ self.app.call_from_thread(self.notify, str(exc), title="SusOps", severity="error", timeout=3)
726
+ self.app.call_from_thread(self._bg_reload)
727
+
728
+ @work(thread=True)
729
+ def _run_toggle_pac(self, host: str, conn_tag: str) -> None:
730
+ mgr = self.app.manager # type: ignore[attr-defined]
731
+ config = mgr.list_config()
732
+ conn = next((c for c in config.connections if c.tag == conn_tag), None)
733
+ if conn:
734
+ currently_enabled = host in conn.pac_hosts
735
+ try:
736
+ mgr.set_pac_host_enabled(host, not currently_enabled, conn_tag=conn_tag)
737
+ except Exception as exc:
738
+ self.app.call_from_thread(self.notify, str(exc), title="SusOps", severity="error", timeout=3)
739
+ self.app.call_from_thread(self._bg_reload)
740
+
741
+ @work(thread=True)
742
+ def _run_toggle_forward(self, conn_tag: str, src_port: int, direction: str) -> None:
743
+ mgr = self.app.manager # type: ignore[attr-defined]
744
+ try:
745
+ mgr.toggle_forward_enabled(conn_tag, src_port, direction)
746
+ except Exception as exc:
747
+ self.app.call_from_thread(self.notify, str(exc), title="SusOps", severity="error", timeout=3)
748
+ self.app.call_from_thread(self._bg_reload)
749
+
750
+ @work(thread=True)
751
+ def _run_conn_op(self, op: str, tag: str) -> None:
752
+ mgr = self.app.manager # type: ignore[attr-defined]
753
+ if op == "start":
754
+ mgr.start(tag=tag)
755
+ elif op == "stop":
756
+ mgr.stop(tag=tag)
757
+ elif op == "restart":
758
+ mgr.restart(tag=tag)
759
+ self.app.call_from_thread(self._bg_reload)
760
+
761
+ @work(thread=True)
762
+ def _run_forward_op(self, op: str, conn_tag: str, src_port: int, direction: str) -> None:
763
+ mgr = self.app.manager # type: ignore[attr-defined]
764
+ try:
765
+ if op == "start":
766
+ mgr.set_forward_enabled(conn_tag, src_port, direction, True)
767
+ elif op == "stop":
768
+ mgr.set_forward_enabled(conn_tag, src_port, direction, False)
769
+ elif op == "restart":
770
+ mgr.set_forward_enabled(conn_tag, src_port, direction, False)
771
+ mgr.set_forward_enabled(conn_tag, src_port, direction, True)
772
+ except Exception as exc:
773
+ self.app.call_from_thread(self.notify, str(exc), title="SusOps", severity="error", timeout=3)
774
+ self.app.call_from_thread(self._bg_reload)
775
+
776
+ # --- Connection CRUD ---
777
+
778
+ def _do_add_connection(self) -> None:
779
+ def _on_result(data) -> None:
780
+ if not data:
781
+ return
782
+ try:
783
+ self.app.manager.add_connection(data["tag"], data["host"], data["port"]) # type: ignore[attr-defined]
784
+ self._bg_reload()
785
+ except ValueError as e:
786
+ self.app.notify(str(e), title="SusOps", severity="error")
787
+
788
+ self.app.push_screen(_AddConnectionDialog(), _on_result)
789
+
790
+ def _do_rm_connection(self) -> None:
791
+ tbl = self.query_one("#tbl-connections", DataTable)
792
+ if tbl.row_count == 0:
793
+ return
794
+ row = tbl.get_row_at(tbl.cursor_row)
795
+ tag = str(row[1])
796
+ try:
797
+ self.app.manager.remove_connection(tag) # type: ignore[attr-defined]
798
+ self._bg_reload()
799
+ except ValueError as e:
800
+ self.app.notify(str(e), title="SusOps", severity="error")
801
+
802
+ # --- PAC host CRUD ---
803
+
804
+ def _do_add_pac(self) -> None:
805
+ def _on_result(data) -> None:
806
+ if not data:
807
+ return
808
+ try:
809
+ self.app.manager.add_pac_host(data["host"], conn_tag=data["conn"]) # type: ignore[attr-defined]
810
+ self._bg_reload()
811
+ except ValueError as e:
812
+ self.app.notify(str(e), title="SusOps", severity="error")
813
+
814
+ config = self.app.manager.list_config() # type: ignore[attr-defined]
815
+ pac_hosts = {c.tag: list(c.pac_hosts) for c in config.connections}
816
+ self.app.push_screen(_AddPacHostDialog(self._conn_tags(), pac_hosts), _on_result)
817
+
818
+ def _do_rm_pac(self) -> None:
819
+ tbl = self.query_one("#tbl-pac", DataTable)
820
+ if tbl.row_count == 0:
821
+ return
822
+ row = tbl.get_row_at(tbl.cursor_row)
823
+ host = str(row[1])
824
+ conn_tag = str(row[2])
825
+ try:
826
+ self.app.manager.remove_pac_host(host, conn_tag=conn_tag) # type: ignore[attr-defined]
827
+ self._bg_reload()
828
+ except ValueError as e:
829
+ self.app.notify(str(e), title="SusOps", severity="error")
830
+
831
+ # --- Port forward CRUD ---
832
+
833
+ def _do_add_forward(self, direction: str) -> None:
834
+ def _on_result(data) -> None:
835
+ if not data:
836
+ return
837
+ fw = PortForward(
838
+ src_addr=data["src_addr"],
839
+ src_port=data["src"],
840
+ dst_addr=data["dst_addr"],
841
+ dst_port=data["dst"],
842
+ tag=data["tag"],
843
+ tcp=data["tcp"],
844
+ udp=data["udp"],
845
+ )
846
+ try:
847
+ if direction == "local":
848
+ self.app.manager.add_local_forward(data["conn"], fw) # type: ignore[attr-defined]
849
+ else:
850
+ self.app.manager.add_remote_forward(data["conn"], fw) # type: ignore[attr-defined]
851
+ self._bg_reload()
852
+ except ValueError as e:
853
+ self.app.notify(str(e), title="SusOps", severity="error")
854
+
855
+ self.app.push_screen(_AddForwardDialog(direction, self._conn_tags()), _on_result)
856
+
857
+ def _do_rm_forward(self, direction: str) -> None:
858
+ tbl_id = "#tbl-local" if direction == "local" else "#tbl-remote"
859
+ tbl = self.query_one(tbl_id, DataTable)
860
+ if tbl.row_count == 0:
861
+ return
862
+ row = tbl.get_row_at(tbl.cursor_row)
863
+ port = int(str(row[2]))
864
+ try:
865
+ if direction == "local":
866
+ self.app.manager.remove_local_forward(port) # type: ignore[attr-defined]
867
+ else:
868
+ self.app.manager.remove_remote_forward(port) # type: ignore[attr-defined]
869
+ self._bg_reload()
870
+ except ValueError as e:
871
+ self.app.notify(str(e), title="SusOps", severity="error")