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.
- susops/__init__.py +4 -0
- susops/client.py +230 -0
- susops/core/__init__.py +0 -0
- susops/core/browsers.py +330 -0
- susops/core/config.py +253 -0
- susops/core/log_style.py +92 -0
- susops/core/pac.py +185 -0
- susops/core/ports.py +57 -0
- susops/core/process.py +167 -0
- susops/core/rpc_protocol.py +186 -0
- susops/core/rpc_server.py +131 -0
- susops/core/services_daemon.py +312 -0
- susops/core/share.py +323 -0
- susops/core/socat.py +200 -0
- susops/core/ssh.py +330 -0
- susops/core/ssh_config.py +40 -0
- susops/core/status.py +245 -0
- susops/core/types.py +171 -0
- susops/facade.py +2237 -0
- susops/tray/__init__.py +20 -0
- susops/tray/base.py +650 -0
- susops/tray/linux.py +1623 -0
- susops/tray/mac.py +3105 -0
- susops/tui/__init__.py +0 -0
- susops/tui/__main__.py +44 -0
- susops/tui/app.py +191 -0
- susops/tui/cli.py +665 -0
- susops/tui/screens/__init__.py +114 -0
- susops/tui/screens/connections.py +871 -0
- susops/tui/screens/dashboard.py +935 -0
- susops/tui/screens/shares.py +357 -0
- susops/tui/widgets/__init__.py +0 -0
- susops/tui/widgets/connection_card.py +137 -0
- susops/version.py +12 -0
- susops-3.0.0rc3.dev1.dist-info/METADATA +977 -0
- susops-3.0.0rc3.dev1.dist-info/RECORD +40 -0
- susops-3.0.0rc3.dev1.dist-info/WHEEL +5 -0
- susops-3.0.0rc3.dev1.dist-info/entry_points.txt +7 -0
- susops-3.0.0rc3.dev1.dist-info/licenses/LICENSE +674 -0
- susops-3.0.0rc3.dev1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
"""Share screen — split-pane share list with modal dialogs for add/fetch."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from textual import work
|
|
7
|
+
from textual.app import ComposeResult
|
|
8
|
+
from textual.binding import Binding
|
|
9
|
+
from textual.containers import Horizontal, Vertical
|
|
10
|
+
from textual.css.query import NoMatches
|
|
11
|
+
from textual.screen import ModalScreen, Screen
|
|
12
|
+
from textual.widgets import Button, Input, Label, ListItem, ListView, Select, Static
|
|
13
|
+
|
|
14
|
+
from susops.core.ports import is_port_free, validate_port
|
|
15
|
+
from susops.tui.screens import _CollapsingLabel, compose_footer, open_in_explorer, share_status_dot
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class _AddShareDialog(ModalScreen):
|
|
19
|
+
"""Modal: start sharing a file."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, conn_hosts: dict[str, str], **kwargs) -> None:
|
|
22
|
+
super().__init__(**kwargs)
|
|
23
|
+
self._conn_hosts = conn_hosts # tag -> ssh_host
|
|
24
|
+
|
|
25
|
+
def compose(self) -> ComposeResult:
|
|
26
|
+
options = [(tag, tag) for tag in self._conn_hosts]
|
|
27
|
+
with Static(classes="modal-dialog"):
|
|
28
|
+
yield Label("[bold]Share a file[/bold]")
|
|
29
|
+
yield Label("File path:")
|
|
30
|
+
yield Input(placeholder="/path/to/file", id="path")
|
|
31
|
+
with Horizontal(classes="modal-form-row"):
|
|
32
|
+
with Static(classes="modal-field"):
|
|
33
|
+
yield Label("Password (blank = auto-generate):")
|
|
34
|
+
yield Input(placeholder="", id="password")
|
|
35
|
+
with Static(classes="modal-field"):
|
|
36
|
+
yield Label("Port (0 = auto):")
|
|
37
|
+
yield Input(placeholder="0", value="0", id="port")
|
|
38
|
+
yield Label("Connection:")
|
|
39
|
+
yield Select(options, allow_blank=False, id="conn")
|
|
40
|
+
yield _CollapsingLabel("", id="error", classes="modal-error")
|
|
41
|
+
with Horizontal(classes="modal-btn-row"):
|
|
42
|
+
yield Button("Share", id="btn-ok", variant="success")
|
|
43
|
+
yield Button("Cancel", id="btn-cancel")
|
|
44
|
+
|
|
45
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
46
|
+
if event.button.id == "btn-cancel":
|
|
47
|
+
self.dismiss(None)
|
|
48
|
+
return
|
|
49
|
+
path = self.query_one("#path", Input).value.strip()
|
|
50
|
+
if not path:
|
|
51
|
+
self.query_one("#error", Label).update("File path is required.")
|
|
52
|
+
return
|
|
53
|
+
if not Path(path).exists():
|
|
54
|
+
self.query_one("#error", Label).update("File not found.")
|
|
55
|
+
return
|
|
56
|
+
pw = self.query_one("#password", Input).value.strip() or None
|
|
57
|
+
error_label = self.query_one("#error", Label)
|
|
58
|
+
try:
|
|
59
|
+
port = int(self.query_one("#port", Input).value.strip() or "0")
|
|
60
|
+
except ValueError:
|
|
61
|
+
error_label.update("Port must be a number.")
|
|
62
|
+
return
|
|
63
|
+
if not validate_port(port, allow_zero=True):
|
|
64
|
+
error_label.update("Port must be 0 (auto) or between 1 and 65535.")
|
|
65
|
+
return
|
|
66
|
+
if port != 0 and not is_port_free(port):
|
|
67
|
+
error_label.update(f"Port {port} is already in use.")
|
|
68
|
+
return
|
|
69
|
+
conn_val = self.query_one("#conn", Select).value
|
|
70
|
+
conn = conn_val if isinstance(conn_val, str) else None
|
|
71
|
+
self.dismiss({"path": path, "password": pw, "port": port, "conn": conn})
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class _FetchDialog(ModalScreen):
|
|
75
|
+
"""Modal: fetch a shared file.
|
|
76
|
+
|
|
77
|
+
Performs the download inside the dialog so the user can see progress,
|
|
78
|
+
retry with corrected inputs on error, and only leaves when the fetch
|
|
79
|
+
succeeds (or they cancel). Dismisses with the downloaded Path on success.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
def __init__(self, conn_hosts: dict[str, str], **kwargs) -> None:
|
|
83
|
+
super().__init__(**kwargs)
|
|
84
|
+
self._conn_hosts = conn_hosts # tag -> ssh_host
|
|
85
|
+
|
|
86
|
+
def compose(self) -> ComposeResult:
|
|
87
|
+
options = [(tag, tag) for tag in self._conn_hosts]
|
|
88
|
+
with Static(classes="modal-dialog"):
|
|
89
|
+
yield Label("[bold]Fetch a shared file[/bold]")
|
|
90
|
+
with Horizontal(classes="modal-form-row"):
|
|
91
|
+
with Static(classes="modal-field"):
|
|
92
|
+
yield Label("Connection:")
|
|
93
|
+
yield Select(options, allow_blank=False, id="conn")
|
|
94
|
+
with Static(classes="modal-field"):
|
|
95
|
+
yield Label("Port:")
|
|
96
|
+
yield Input(placeholder="52100", id="port")
|
|
97
|
+
yield Label("Password:")
|
|
98
|
+
yield Input(placeholder="", id="password")
|
|
99
|
+
yield Label("Save to (blank = ~/Downloads/<filename>):")
|
|
100
|
+
yield Input(placeholder="", id="outfile")
|
|
101
|
+
yield _CollapsingLabel("", id="error", classes="modal-error")
|
|
102
|
+
with Horizontal(classes="modal-btn-row"):
|
|
103
|
+
yield Button("Fetch", id="btn-ok", variant="primary")
|
|
104
|
+
yield Button("Cancel", id="btn-cancel")
|
|
105
|
+
|
|
106
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
107
|
+
if event.button.id == "btn-cancel":
|
|
108
|
+
self.dismiss(None)
|
|
109
|
+
return
|
|
110
|
+
port_str = self.query_one("#port", Input).value.strip()
|
|
111
|
+
pw = self.query_one("#password", Input).value.strip()
|
|
112
|
+
error_label = self.query_one("#error", Label)
|
|
113
|
+
if not port_str or not pw:
|
|
114
|
+
error_label.update("Port and password are required.")
|
|
115
|
+
return
|
|
116
|
+
try:
|
|
117
|
+
port = int(port_str)
|
|
118
|
+
except ValueError:
|
|
119
|
+
error_label.update("Port must be a number.")
|
|
120
|
+
return
|
|
121
|
+
if not validate_port(port):
|
|
122
|
+
error_label.update("Port must be between 1 and 65535.")
|
|
123
|
+
return
|
|
124
|
+
conn_val = self.query_one("#conn", Select).value
|
|
125
|
+
conn = conn_val if isinstance(conn_val, str) else None
|
|
126
|
+
if not conn:
|
|
127
|
+
error_label.update("Connection is required.")
|
|
128
|
+
return
|
|
129
|
+
outfile = self.query_one("#outfile", Input).value.strip() or None
|
|
130
|
+
self._start_fetch(port, pw, conn, outfile)
|
|
131
|
+
|
|
132
|
+
def _set_busy(self, busy: bool) -> None:
|
|
133
|
+
btn = self.query_one("#btn-ok", Button)
|
|
134
|
+
btn.disabled = busy
|
|
135
|
+
btn.label = "Fetching…" if busy else "Fetch"
|
|
136
|
+
for widget_id in ("#port", "#password", "#outfile"):
|
|
137
|
+
self.query_one(widget_id, Input).disabled = busy
|
|
138
|
+
self.query_one("#conn", Select).disabled = busy
|
|
139
|
+
|
|
140
|
+
@work(thread=True)
|
|
141
|
+
def _start_fetch(self, port: int, password: str, conn: str, outfile: str | None) -> None:
|
|
142
|
+
self.app.call_from_thread(self._set_busy, True)
|
|
143
|
+
self.app.call_from_thread(self.query_one("#error", Label).update, "[dim]Fetching…[/dim]")
|
|
144
|
+
try:
|
|
145
|
+
out = Path(outfile) if outfile else None
|
|
146
|
+
result = self.app.manager.fetch( # type: ignore[attr-defined]
|
|
147
|
+
port=port, password=password, conn_tag=conn, outfile=out
|
|
148
|
+
)
|
|
149
|
+
self.app.call_from_thread(self.dismiss, result)
|
|
150
|
+
except Exception as e:
|
|
151
|
+
self.app.call_from_thread(self._set_busy, False)
|
|
152
|
+
self.app.call_from_thread(self.query_one("#error", Label).update, f"[red]{e}[/red]")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class SharesScreen(Screen):
|
|
156
|
+
"""Split-pane share screen: active shares list + detail panel."""
|
|
157
|
+
|
|
158
|
+
BINDINGS = [
|
|
159
|
+
Binding("escape", "app.pop_screen", "Back"),
|
|
160
|
+
Binding("a", "add_share", "Add"),
|
|
161
|
+
Binding("f", "fetch_file", "Fetch"),
|
|
162
|
+
Binding("d", "stop_share", "Stop"),
|
|
163
|
+
Binding("s", "start_share", "Start"),
|
|
164
|
+
Binding("x", "delete_share", "Delete"),
|
|
165
|
+
]
|
|
166
|
+
|
|
167
|
+
def compose(self) -> ComposeResult:
|
|
168
|
+
with Horizontal(id="share-split"):
|
|
169
|
+
with Vertical(id="share-list-panel"):
|
|
170
|
+
yield ListView(id="share-list")
|
|
171
|
+
yield Static("", id="share-detail", markup=True)
|
|
172
|
+
yield Label("", id="share-status")
|
|
173
|
+
yield from compose_footer()
|
|
174
|
+
|
|
175
|
+
def on_mount(self) -> None:
|
|
176
|
+
lv = self.query_one("#share-list", ListView)
|
|
177
|
+
lv.border_title = "Shares"
|
|
178
|
+
self._shares: list = []
|
|
179
|
+
self._reload()
|
|
180
|
+
self.set_interval(2.0, self._reload)
|
|
181
|
+
|
|
182
|
+
def _reload(self) -> None:
|
|
183
|
+
new_shares = self.app.manager.list_shares() # type: ignore[attr-defined]
|
|
184
|
+
lv = self.query_one("#share-list", ListView)
|
|
185
|
+
|
|
186
|
+
def _label(info) -> str:
|
|
187
|
+
name = Path(info.file_path).name
|
|
188
|
+
dot = share_status_dot(info.running, info.stopped)
|
|
189
|
+
return f"{dot} {name} {info.port}"
|
|
190
|
+
|
|
191
|
+
new_ports = [i.port for i in new_shares]
|
|
192
|
+
old_ports = [i.port for i in self._shares]
|
|
193
|
+
|
|
194
|
+
if new_ports == old_ports:
|
|
195
|
+
# Same shares — update labels in-place so selection is never disturbed
|
|
196
|
+
for item, info in zip(lv.query(ListItem), new_shares):
|
|
197
|
+
# ListItem.compose moves the Label from _pending_children to
|
|
198
|
+
# the DOM. If a recent rebuild's mount hasn't fired yet we
|
|
199
|
+
# land here with no Label child — skip; the next tick retries.
|
|
200
|
+
try:
|
|
201
|
+
item.query_one(Label).update(_label(info))
|
|
202
|
+
except NoMatches:
|
|
203
|
+
pass
|
|
204
|
+
else:
|
|
205
|
+
# Shares added/removed — rebuild and restore cursor
|
|
206
|
+
cur = lv.index or 0
|
|
207
|
+
lv.clear()
|
|
208
|
+
for info in new_shares:
|
|
209
|
+
lv.append(ListItem(Label(_label(info))))
|
|
210
|
+
if new_shares:
|
|
211
|
+
lv.index = min(cur, len(new_shares) - 1)
|
|
212
|
+
|
|
213
|
+
self._shares = new_shares
|
|
214
|
+
|
|
215
|
+
n_running = sum(1 for i in self._shares if i.running)
|
|
216
|
+
n_stopped = sum(1 for i in self._shares if not i.running and i.stopped)
|
|
217
|
+
n_offline = sum(1 for i in self._shares if not i.running and not i.stopped)
|
|
218
|
+
count = len(self._shares)
|
|
219
|
+
if count == 0:
|
|
220
|
+
status = "No shares"
|
|
221
|
+
elif n_running == count:
|
|
222
|
+
status = f"{count} share(s) running"
|
|
223
|
+
else:
|
|
224
|
+
parts = []
|
|
225
|
+
if n_running:
|
|
226
|
+
parts.append(f"{n_running} running")
|
|
227
|
+
if n_stopped:
|
|
228
|
+
parts.append(f"{n_stopped} stopped")
|
|
229
|
+
if n_offline:
|
|
230
|
+
parts.append(f"{n_offline} offline")
|
|
231
|
+
status = f"{count} shares ({', '.join(parts)})"
|
|
232
|
+
|
|
233
|
+
self.query_one("#share-status", Label).update(status)
|
|
234
|
+
if self._shares:
|
|
235
|
+
idx = lv.index or 0
|
|
236
|
+
self._show_detail(self._shares[idx])
|
|
237
|
+
else:
|
|
238
|
+
self.query_one("#share-detail", Static).update(
|
|
239
|
+
"[dim]No shares.\n\nPress [bold]a[/bold] to share a file.[/dim]"
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
def _conn_hosts(self) -> dict[str, str]:
|
|
243
|
+
try:
|
|
244
|
+
cfg = self.app.manager.list_config() # type: ignore[attr-defined]
|
|
245
|
+
return {c.tag: c.ssh_host for c in cfg.connections}
|
|
246
|
+
except Exception:
|
|
247
|
+
return {}
|
|
248
|
+
|
|
249
|
+
def _show_detail(self, info) -> None:
|
|
250
|
+
name = Path(info.file_path).name
|
|
251
|
+
if info.running:
|
|
252
|
+
state_str = "[green]running[/green]"
|
|
253
|
+
elif info.stopped:
|
|
254
|
+
state_str = "[dim]stopped[/dim]"
|
|
255
|
+
else:
|
|
256
|
+
state_str = "[red]offline[/red]"
|
|
257
|
+
|
|
258
|
+
if "'" not in info.file_path:
|
|
259
|
+
file_display = f"[@click=screen.open_share('{info.file_path}')]{info.file_path}[/]"
|
|
260
|
+
else:
|
|
261
|
+
file_display = info.file_path
|
|
262
|
+
text = (
|
|
263
|
+
f"[bold]File:[/bold] {file_display}\n"
|
|
264
|
+
f"[bold]Name:[/bold] {name}\n"
|
|
265
|
+
f"[bold]Connection:[/bold] {info.conn_tag or '—'}\n"
|
|
266
|
+
f"[bold]Port:[/bold] {info.port}\n"
|
|
267
|
+
f"[bold]Password:[/bold] {info.password}\n"
|
|
268
|
+
)
|
|
269
|
+
if info.running:
|
|
270
|
+
access_str = f"[green]{info.access_count} ok[/green]"
|
|
271
|
+
if info.failed_count:
|
|
272
|
+
access_str += f" [red]{info.failed_count} failed[/red]"
|
|
273
|
+
text += (
|
|
274
|
+
f"[bold]URL:[/bold] {info.url}\n"
|
|
275
|
+
f"[bold]State:[/bold] {state_str}\n"
|
|
276
|
+
f"[bold]Access:[/bold] {access_str}\n"
|
|
277
|
+
)
|
|
278
|
+
else:
|
|
279
|
+
text += f"[bold]State:[/bold] {state_str}\n"
|
|
280
|
+
|
|
281
|
+
if info.running:
|
|
282
|
+
text += (
|
|
283
|
+
f"\n[bold]Fetch command:[/bold]\n"
|
|
284
|
+
f" [dim]susops -c {info.conn_tag} fetch {info.port} {info.password}[/dim]"
|
|
285
|
+
f"\n\n[dim]Press [bold]d[/bold] to stop · [bold]x[/bold] to delete[/dim]"
|
|
286
|
+
)
|
|
287
|
+
elif info.stopped:
|
|
288
|
+
text += "\n[dim]Press [bold]s[/bold] to restart · [bold]x[/bold] to delete[/dim]"
|
|
289
|
+
else:
|
|
290
|
+
text += "\n[dim]Will auto-resume when connection starts · Press [bold]d[/bold] to stop · [bold]x[/bold] to delete[/dim]"
|
|
291
|
+
self.query_one("#share-detail", Static).update(text)
|
|
292
|
+
|
|
293
|
+
def action_open_share(self, file_path: str) -> None:
|
|
294
|
+
open_in_explorer(file_path)
|
|
295
|
+
|
|
296
|
+
def on_list_view_highlighted(self, event: ListView.Highlighted) -> None:
|
|
297
|
+
idx = event.list_view.index
|
|
298
|
+
if idx is not None and 0 <= idx < len(self._shares):
|
|
299
|
+
self._show_detail(self._shares[idx])
|
|
300
|
+
|
|
301
|
+
def action_add_share(self) -> None:
|
|
302
|
+
def _on_result(data) -> None:
|
|
303
|
+
if not data:
|
|
304
|
+
return
|
|
305
|
+
self._do_share(data["path"], data["conn"], data["password"], data["port"])
|
|
306
|
+
|
|
307
|
+
self.app.push_screen(_AddShareDialog(self._conn_hosts()), _on_result)
|
|
308
|
+
|
|
309
|
+
def action_fetch_file(self) -> None:
|
|
310
|
+
def _on_result(result) -> None:
|
|
311
|
+
if result is None:
|
|
312
|
+
return
|
|
313
|
+
self.query_one("#share-status", Label).update(
|
|
314
|
+
f"[green]Downloaded to: {result}[/green]"
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
self.app.push_screen(_FetchDialog(self._conn_hosts()), _on_result)
|
|
318
|
+
|
|
319
|
+
def action_stop_share(self) -> None:
|
|
320
|
+
idx = self.query_one("#share-list", ListView).index
|
|
321
|
+
if idx is None or idx >= len(self._shares):
|
|
322
|
+
return
|
|
323
|
+
port = self._shares[idx].port
|
|
324
|
+
self.app.manager.stop_share(port) # type: ignore[attr-defined]
|
|
325
|
+
self._reload()
|
|
326
|
+
|
|
327
|
+
def action_delete_share(self) -> None:
|
|
328
|
+
idx = self.query_one("#share-list", ListView).index
|
|
329
|
+
if idx is None or idx >= len(self._shares):
|
|
330
|
+
return
|
|
331
|
+
port = self._shares[idx].port
|
|
332
|
+
self.app.manager.delete_share(port) # type: ignore[attr-defined]
|
|
333
|
+
self._reload()
|
|
334
|
+
|
|
335
|
+
def action_start_share(self) -> None:
|
|
336
|
+
"""Restart a stopped share."""
|
|
337
|
+
idx = self.query_one("#share-list", ListView).index
|
|
338
|
+
if idx is None or idx >= len(self._shares):
|
|
339
|
+
return
|
|
340
|
+
info = self._shares[idx]
|
|
341
|
+
if info.running:
|
|
342
|
+
return
|
|
343
|
+
self._do_share(info.file_path, info.conn_tag, info.password, info.port)
|
|
344
|
+
|
|
345
|
+
def action_refresh(self) -> None:
|
|
346
|
+
self._reload()
|
|
347
|
+
|
|
348
|
+
@work(thread=True)
|
|
349
|
+
def _do_share(self, path: str, conn_tag: str, password: str | None, port: int) -> None:
|
|
350
|
+
mgr = self.app.manager # type: ignore[attr-defined]
|
|
351
|
+
try:
|
|
352
|
+
info = mgr.share(Path(path), conn_tag, password=password, port=port or None)
|
|
353
|
+
msg = f"[green]Sharing {Path(path).name} on port {info.port}, pw: {info.password}[/green]"
|
|
354
|
+
except Exception as e:
|
|
355
|
+
msg = f"[red]Error: {e}[/red]"
|
|
356
|
+
self.app.call_from_thread(self.query_one("#share-status", Label).update, msg)
|
|
357
|
+
self.app.call_from_thread(self._reload)
|
|
File without changes
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""ConnectionCard widget — htop-style per-connection status card."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from textual.app import ComposeResult
|
|
5
|
+
from textual.containers import Horizontal
|
|
6
|
+
from textual.reactive import reactive
|
|
7
|
+
from textual.widgets import Label, Sparkline, Static
|
|
8
|
+
|
|
9
|
+
from susops.core.types import ConnectionStatus
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _fmt_bps(bps: float) -> str:
|
|
13
|
+
if bps >= 1_048_576:
|
|
14
|
+
return f"{bps / 1_048_576:.1f} MB/s"
|
|
15
|
+
if bps >= 1024:
|
|
16
|
+
return f"{bps / 1024:.0f} kB/s"
|
|
17
|
+
return f"{bps:.0f} B/s"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ConnectionCard(Static):
|
|
21
|
+
"""Htop-style card: status dot, SSH info, CPU/mem/conns, bandwidth sparklines, forwards."""
|
|
22
|
+
|
|
23
|
+
DEFAULT_CSS = """
|
|
24
|
+
ConnectionCard {
|
|
25
|
+
height: auto;
|
|
26
|
+
border: round $surface-darken-1;
|
|
27
|
+
padding: 0 1;
|
|
28
|
+
margin: 0 0 1 0;
|
|
29
|
+
}
|
|
30
|
+
ConnectionCard .row-title {
|
|
31
|
+
height: 1;
|
|
32
|
+
text-style: bold;
|
|
33
|
+
}
|
|
34
|
+
ConnectionCard .row-sys {
|
|
35
|
+
height: 1;
|
|
36
|
+
color: $text-muted;
|
|
37
|
+
}
|
|
38
|
+
ConnectionCard .row-fwd {
|
|
39
|
+
height: 1;
|
|
40
|
+
color: $text-muted;
|
|
41
|
+
}
|
|
42
|
+
ConnectionCard .bw-label {
|
|
43
|
+
height: 1;
|
|
44
|
+
color: $text-muted;
|
|
45
|
+
width: 18;
|
|
46
|
+
}
|
|
47
|
+
ConnectionCard Sparkline {
|
|
48
|
+
height: 3;
|
|
49
|
+
}
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
_MAX_SAMPLES = 40
|
|
53
|
+
|
|
54
|
+
_title: reactive[str] = reactive("")
|
|
55
|
+
_sys_info: reactive[str] = reactive("")
|
|
56
|
+
_fwd_info: reactive[str] = reactive("")
|
|
57
|
+
_rx_label: reactive[str] = reactive("↓ 0 B/s")
|
|
58
|
+
_tx_label: reactive[str] = reactive("↑ 0 B/s")
|
|
59
|
+
|
|
60
|
+
def __init__(self, tag: str, **kwargs) -> None:
|
|
61
|
+
super().__init__(**kwargs)
|
|
62
|
+
self.tag = tag
|
|
63
|
+
self._rx_data: list[float] = [0.0] * self._MAX_SAMPLES
|
|
64
|
+
self._tx_data: list[float] = [0.0] * self._MAX_SAMPLES
|
|
65
|
+
|
|
66
|
+
def compose(self) -> ComposeResult:
|
|
67
|
+
yield Label(self._title, id=f"title-{self.tag}", classes="row-title")
|
|
68
|
+
yield Label(self._sys_info, id=f"sys-{self.tag}", classes="row-sys")
|
|
69
|
+
with Horizontal():
|
|
70
|
+
yield Label(self._rx_label, id=f"rxlbl-{self.tag}", classes="bw-label")
|
|
71
|
+
yield Sparkline(self._rx_data, id=f"rx-{self.tag}", summary_function=max)
|
|
72
|
+
with Horizontal():
|
|
73
|
+
yield Label(self._tx_label, id=f"txlbl-{self.tag}", classes="bw-label")
|
|
74
|
+
yield Sparkline(self._tx_data, id=f"tx-{self.tag}", summary_function=max)
|
|
75
|
+
yield Label(self._fwd_info, id=f"fwd-{self.tag}", classes="row-fwd")
|
|
76
|
+
|
|
77
|
+
def _safe_update(self, widget_id: str, cls, value) -> None:
|
|
78
|
+
try:
|
|
79
|
+
self.query_one(widget_id, cls).update(value)
|
|
80
|
+
except Exception:
|
|
81
|
+
pass
|
|
82
|
+
|
|
83
|
+
def watch__title(self, v: str) -> None:
|
|
84
|
+
self._safe_update(f"#title-{self.tag}", Label, v)
|
|
85
|
+
|
|
86
|
+
def watch__sys_info(self, v: str) -> None:
|
|
87
|
+
self._safe_update(f"#sys-{self.tag}", Label, v)
|
|
88
|
+
|
|
89
|
+
def watch__fwd_info(self, v: str) -> None:
|
|
90
|
+
self._safe_update(f"#fwd-{self.tag}", Label, v)
|
|
91
|
+
|
|
92
|
+
def watch__rx_label(self, v: str) -> None:
|
|
93
|
+
self._safe_update(f"#rxlbl-{self.tag}", Label, v)
|
|
94
|
+
|
|
95
|
+
def watch__tx_label(self, v: str) -> None:
|
|
96
|
+
self._safe_update(f"#txlbl-{self.tag}", Label, v)
|
|
97
|
+
|
|
98
|
+
def refresh_status(self, status: ConnectionStatus, proc_info: dict | None = None,
|
|
99
|
+
forwards: list | None = None) -> None:
|
|
100
|
+
dot_color = "green" if status.running else "red"
|
|
101
|
+
dot = f"[{dot_color}]●[/{dot_color}]"
|
|
102
|
+
port_str = f" SOCKS {status.socks_port}" if status.socks_port else ""
|
|
103
|
+
pid_str = f" pid={status.pid}" if status.pid else ""
|
|
104
|
+
self._title = f"{dot} [bold]{status.tag}[/bold]{port_str}{pid_str}"
|
|
105
|
+
|
|
106
|
+
if proc_info and status.running:
|
|
107
|
+
cpu = proc_info.get("cpu", 0.0)
|
|
108
|
+
mem = proc_info.get("mem_mb", 0.0)
|
|
109
|
+
conns = proc_info.get("conns", 0)
|
|
110
|
+
self._sys_info = (
|
|
111
|
+
f"[dim]CPU: {cpu:.1f}% MEM: {mem:.1f} MB "
|
|
112
|
+
f"Active conns: {conns}[/dim]"
|
|
113
|
+
)
|
|
114
|
+
elif not status.running:
|
|
115
|
+
self._sys_info = "[dim]stopped[/dim]"
|
|
116
|
+
else:
|
|
117
|
+
self._sys_info = ""
|
|
118
|
+
|
|
119
|
+
if forwards is not None:
|
|
120
|
+
parts = []
|
|
121
|
+
for fw in forwards:
|
|
122
|
+
label = f" [{fw.tag}]" if fw.tag else ""
|
|
123
|
+
parts.append(f"{fw.src_port}→{fw.dst_addr}:{fw.dst_port}{label}")
|
|
124
|
+
self._fwd_info = (
|
|
125
|
+
f"[dim]Forwards: {', '.join(parts)}[/dim]" if parts else ""
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
def refresh_bandwidth(self, rx: float, tx: float) -> None:
|
|
129
|
+
self._rx_data = self._rx_data[1:] + [rx]
|
|
130
|
+
self._tx_data = self._tx_data[1:] + [tx]
|
|
131
|
+
try:
|
|
132
|
+
self.query_one(f"#rx-{self.tag}", Sparkline).data = self._rx_data
|
|
133
|
+
self.query_one(f"#tx-{self.tag}", Sparkline).data = self._tx_data
|
|
134
|
+
except Exception:
|
|
135
|
+
pass
|
|
136
|
+
self._rx_label = f"↓ {_fmt_bps(rx):>10}"
|
|
137
|
+
self._tx_label = f"↑ {_fmt_bps(tx):>10}"
|
susops/version.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Single source of truth for the susops package version."""
|
|
2
|
+
from importlib.metadata import version, PackageNotFoundError
|
|
3
|
+
|
|
4
|
+
try:
|
|
5
|
+
VERSION = version("susops")
|
|
6
|
+
except PackageNotFoundError:
|
|
7
|
+
# Running from source without installation — fall back to pyproject.toml
|
|
8
|
+
import tomllib
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
_pyproject = Path(__file__).parent.parent.parent / "pyproject.toml"
|
|
12
|
+
VERSION = tomllib.loads(_pyproject.read_text())["project"]["version"]
|