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/core/status.py ADDED
@@ -0,0 +1,245 @@
1
+ """SSE StatusServer — real-time event push for TUI and tray apps.
2
+
3
+ Provides a Server-Sent Events endpoint at GET /events that broadcasts
4
+ state, share, bandwidth, and forward events to all connected clients.
5
+
6
+ Event types:
7
+ state — {"tag": "work", "running": true, "pid": 1234}
8
+ share — {"port": 52100, "file": "report.pdf", "running": true, "conn_tag": "work"}
9
+ bandwidth — {"tag": "work", "rx_bps": 1234.5, "tx_bps": 567.8}
10
+ forward — {"tag": "work", "fw_tag": "db", "direction": "local", "running": true}
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import asyncio
15
+ import json
16
+ import time
17
+ from typing import Any, Callable
18
+
19
+ __all__ = ["StatusServer"]
20
+
21
+
22
+ class StatusServer:
23
+ """Async SSE server that broadcasts events to connected clients.
24
+
25
+ Reuses the shared event loop from shares.py so both servers run on the
26
+ same daemon thread.
27
+ """
28
+
29
+ def __init__(self) -> None:
30
+ self._runner = None
31
+ self._port: int = 0
32
+ self._queues: list[asyncio.Queue[str | None]] = []
33
+ self._queues_lock: asyncio.Lock | None = None
34
+ # Set by the services daemon to react to client-count changes — used
35
+ # to drive idle-shutdown.
36
+ self.on_clients_changed: Callable[[int], None] | None = None
37
+ # Set by the services daemon (wired to mgr._log) to record connect /
38
+ # disconnect events in the in-memory log buffer.
39
+ self.on_log: Callable[[str], None] | None = None
40
+
41
+ def client_count(self) -> int:
42
+ return len(self._queues)
43
+
44
+ def start(self, port: int = 0) -> int:
45
+ """Start the SSE server. Returns the bound port."""
46
+ from susops.core.share import _get_loop
47
+
48
+ if self._runner is not None:
49
+ return self._port
50
+
51
+ loop = _get_loop()
52
+ future = asyncio.run_coroutine_threadsafe(
53
+ self._start_async(port), loop
54
+ )
55
+ self._port = future.result(timeout=10)
56
+ return self._port
57
+
58
+ async def _start_async(self, port: int) -> int:
59
+ from aiohttp import web
60
+
61
+ self._queues_lock = asyncio.Lock()
62
+
63
+ # SSE comment lines (lines starting with ":") are valid no-ops per the
64
+ # spec; we use them as a periodic keepalive so a write actually
65
+ # happens even when the daemon has no events to broadcast. Without
66
+ # this, a frontend that closes its TCP connection is never noticed —
67
+ # `queue.get()` blocks forever and `_fire_clients_changed(0)` never
68
+ # fires, which means idle-shutdown can't trigger.
69
+ HEARTBEAT_INTERVAL_S = 5.0
70
+
71
+ async def handle_events(request: web.Request) -> web.StreamResponse:
72
+ resp = web.StreamResponse(
73
+ status=200,
74
+ headers={
75
+ "Content-Type": "text/event-stream",
76
+ "Cache-Control": "no-cache",
77
+ "Connection": "keep-alive",
78
+ "Access-Control-Allow-Origin": "*",
79
+ },
80
+ )
81
+ await resp.prepare(request)
82
+
83
+ # Identify the client. Frontends set `X-Susops-Client` to one of
84
+ # tui / tray-mac / tray-linux / cli; anything else is "other".
85
+ client_type = request.headers.get("X-Susops-Client", "other")
86
+ client_version = request.headers.get("X-Susops-Client-Version", "?")
87
+ client_pid = request.headers.get("X-Susops-Pid", "?")
88
+ # Optional per-client event filter: comma-separated list of event
89
+ # names (e.g. "state,share"). When set, the broadcaster only
90
+ # pushes matching events to this client — useful for clients
91
+ # like the tray that don't render bandwidth charts.
92
+ events_header = request.headers.get("X-Susops-Events", "").strip()
93
+ event_filter: set[str] | None = (
94
+ {e.strip() for e in events_header.split(",") if e.strip()}
95
+ if events_header else None
96
+ )
97
+ peer = request.transport.get_extra_info("peername") if request.transport else None
98
+ peer_str = f"{peer[0]}:{peer[1]}" if peer else "?"
99
+ connect_t = time.monotonic()
100
+
101
+ queue: asyncio.Queue[str | None] = asyncio.Queue()
102
+ queue._susops_events = event_filter # type: ignore[attr-defined]
103
+ async with self._queues_lock:
104
+ self._queues.append(queue)
105
+ count = len(self._queues)
106
+ self._fire_clients_changed(count)
107
+ # Always include the subscribed event set in the connect line —
108
+ # "all" when the client sent no X-Susops-Events filter, the
109
+ # explicit list otherwise. Useful to confirm what each client
110
+ # actually subscribed to (e.g. tray opts out of bandwidth).
111
+ events_str = (
112
+ ",".join(sorted(event_filter))
113
+ if event_filter is not None else "all"
114
+ )
115
+ self._fire_log(
116
+ f"SSE client connected: {client_type} v{client_version} "
117
+ f"(pid {client_pid}, peer {peer_str}, events: {events_str}) — {count} active"
118
+ )
119
+
120
+ try:
121
+ while True:
122
+ try:
123
+ msg = await asyncio.wait_for(
124
+ queue.get(), timeout=HEARTBEAT_INTERVAL_S,
125
+ )
126
+ except asyncio.TimeoutError:
127
+ # No event in HEARTBEAT_INTERVAL_S — send a comment
128
+ # line. If the client is gone the write will raise
129
+ # ConnectionResetError and we'll fall into the
130
+ # finally block, decrementing the client count.
131
+ await resp.write(b": keepalive\n\n")
132
+ continue
133
+ if msg is None:
134
+ break
135
+ await resp.write(msg.encode())
136
+ except (ConnectionResetError, asyncio.CancelledError):
137
+ pass
138
+ except Exception:
139
+ # Any other write failure also implies the client is gone.
140
+ pass
141
+ finally:
142
+ async with self._queues_lock:
143
+ try:
144
+ self._queues.remove(queue)
145
+ except ValueError:
146
+ pass
147
+ count = len(self._queues)
148
+ self._fire_clients_changed(count)
149
+ duration = time.monotonic() - connect_t
150
+ self._fire_log(
151
+ f"SSE client disconnected: {client_type} "
152
+ f"(pid {client_pid}, after {duration:.1f}s) — {count} active"
153
+ )
154
+
155
+ return resp
156
+
157
+ app = web.Application()
158
+ app.router.add_get("/events", handle_events)
159
+
160
+ runner = web.AppRunner(app)
161
+ await runner.setup()
162
+ site = web.TCPSite(runner, "127.0.0.1", port)
163
+ await site.start()
164
+ self._runner = runner
165
+ return site._server.sockets[0].getsockname()[1] # type: ignore[union-attr]
166
+
167
+ def stop(self) -> None:
168
+ """Stop the SSE server and disconnect all clients."""
169
+ if self._runner is None:
170
+ return
171
+
172
+ from susops.core.share import _get_loop
173
+
174
+ loop = _get_loop()
175
+ future = asyncio.run_coroutine_threadsafe(
176
+ self._stop_async(), loop
177
+ )
178
+ try:
179
+ future.result(timeout=5)
180
+ except Exception:
181
+ pass
182
+ self._runner = None
183
+ self._port = 0
184
+
185
+ async def _stop_async(self) -> None:
186
+ # Signal all clients to disconnect
187
+ if self._queues_lock is not None:
188
+ async with self._queues_lock:
189
+ for q in self._queues:
190
+ await q.put(None)
191
+ if self._runner is not None:
192
+ await self._runner.cleanup()
193
+
194
+ def emit(self, event: str, data: dict[str, Any]) -> None:
195
+ """Broadcast an SSE event to all connected clients (thread-safe).
196
+
197
+ Honours each client's per-connection event filter (set via the
198
+ ``X-Susops-Events`` header). Clients with no filter receive every
199
+ event; clients with a filter only receive events whose name is in
200
+ their set.
201
+ """
202
+ if self._runner is None:
203
+ return
204
+
205
+ from susops.core.share import _get_loop
206
+
207
+ payload = f"event: {event}\ndata: {json.dumps(data)}\n\n"
208
+ loop = _get_loop()
209
+ asyncio.run_coroutine_threadsafe(
210
+ self._broadcast(event, payload), loop
211
+ )
212
+
213
+ async def _broadcast(self, event: str, payload: str) -> None:
214
+ if self._queues_lock is None:
215
+ return
216
+ async with self._queues_lock:
217
+ for q in list(self._queues):
218
+ allowed: set[str] | None = getattr(q, "_susops_events", None)
219
+ if allowed is not None and event not in allowed:
220
+ continue
221
+ await q.put(payload)
222
+
223
+ def is_running(self) -> bool:
224
+ return self._runner is not None
225
+
226
+ def get_port(self) -> int:
227
+ return self._port
228
+
229
+ def _fire_clients_changed(self, count: int) -> None:
230
+ cb = self.on_clients_changed
231
+ if cb is None:
232
+ return
233
+ try:
234
+ cb(count)
235
+ except Exception:
236
+ pass
237
+
238
+ def _fire_log(self, msg: str) -> None:
239
+ cb = self.on_log
240
+ if cb is None:
241
+ return
242
+ try:
243
+ cb(msg)
244
+ except Exception:
245
+ pass
susops/core/types.py ADDED
@@ -0,0 +1,171 @@
1
+ """Types module for SusOps.
2
+
3
+ Defines all shared enums and result dataclasses used throughout the susops package.
4
+ This module is the single source of truth for type definitions — no other module
5
+ should define these types.
6
+
7
+ Enums:
8
+ - ProcessState: Enumeration of SSH/PAC process states
9
+ - LogoStyle: Enumeration of logo style options
10
+
11
+ Result Dataclasses:
12
+ - ConnectionStatus: Status of a single SSH connection
13
+ - StartResult: Result of starting connections/PAC
14
+ - StopResult: Result of stopping connections/PAC
15
+ - StatusResult: Overall status query result
16
+ - TestResult: Result of testing a connection
17
+ - ShareInfo: Information about an active file share
18
+ """
19
+
20
+ import dataclasses
21
+ import enum
22
+ from typing import Optional
23
+
24
+ __all__ = [
25
+ "ProcessState",
26
+ "LogoStyle",
27
+ "ConnectionStatus",
28
+ "StartResult",
29
+ "StopResult",
30
+ "StatusResult",
31
+ "TestResult",
32
+ "ShareInfo",
33
+ ]
34
+
35
+
36
+ class ProcessState(enum.Enum):
37
+ """Enumeration of possible process states for SSH and PAC services."""
38
+
39
+ INITIAL = "initial"
40
+ """Not yet checked — initial state before status inquiry."""
41
+
42
+ RUNNING = "running"
43
+ """All services are up and running."""
44
+
45
+ STOPPED_PARTIALLY = "stopped_partially"
46
+ """Some services are down, others are running."""
47
+
48
+ STOPPED = "stopped"
49
+ """All services are down."""
50
+
51
+ ERROR = "error"
52
+ """Unexpected state or error occurred."""
53
+
54
+
55
+ class LogoStyle(enum.Enum):
56
+ """Enumeration of logo style options for UI display."""
57
+
58
+ COLORED_GLASSES = "COLORED_GLASSES"
59
+ """Colored glasses logo variant."""
60
+
61
+ COLORED_S = "COLORED_S"
62
+ """Colored S logo variant."""
63
+
64
+ GEAR = "GEAR"
65
+ """Gear logo variant."""
66
+
67
+
68
+ @dataclasses.dataclass(frozen=True)
69
+ class ConnectionStatus:
70
+ """Status information for a single SSH connection.
71
+
72
+ Attributes:
73
+ tag: Unique identifier for the connection (e.g., 'proxy1', 'vpn')
74
+ running: Whether the connection is currently active
75
+ pid: Process ID of the ssh process, or None if not running
76
+ socks_port: Port number on which the SOCKS proxy is listening (0 if not running)
77
+ """
78
+
79
+ tag: str
80
+ running: bool
81
+ pid: Optional[int] = None
82
+ socks_port: int = 0
83
+ enabled: bool = True
84
+
85
+
86
+ @dataclasses.dataclass(frozen=True)
87
+ class StartResult:
88
+ """Result of attempting to start connections and/or PAC service.
89
+
90
+ Attributes:
91
+ success: Whether the operation succeeded
92
+ message: Human-readable status message
93
+ connection_statuses: Tuple of ConnectionStatus objects for each connection
94
+ """
95
+
96
+ success: bool
97
+ message: str
98
+ connection_statuses: tuple[ConnectionStatus, ...] = ()
99
+
100
+
101
+ @dataclasses.dataclass(frozen=True)
102
+ class StopResult:
103
+ """Result of attempting to stop connections and/or PAC service.
104
+
105
+ Attributes:
106
+ success: Whether the operation succeeded
107
+ message: Human-readable status message
108
+ """
109
+
110
+ success: bool
111
+ message: str
112
+
113
+
114
+ @dataclasses.dataclass(frozen=True)
115
+ class StatusResult:
116
+ """Result of querying overall status of connections and services.
117
+
118
+ Attributes:
119
+ state: The overall ProcessState
120
+ connection_statuses: Tuple of ConnectionStatus objects for each connection
121
+ pac_running: Whether the PAC (Proxy Auto-Config) service is running
122
+ pac_port: Port number on which PAC HTTP server is listening
123
+ message: Optional human-readable status message
124
+ """
125
+
126
+ state: ProcessState
127
+ connection_statuses: tuple[ConnectionStatus, ...]
128
+ pac_running: bool
129
+ pac_port: int
130
+ message: str = ""
131
+
132
+
133
+ @dataclasses.dataclass(frozen=True)
134
+ class TestResult:
135
+ """Result of testing connectivity through a connection.
136
+
137
+ Attributes:
138
+ target: Description of the target that was tested (e.g., hostname, URL)
139
+ success: Whether the test succeeded
140
+ message: Human-readable result or error message
141
+ latency_ms: Measured round-trip latency in milliseconds, or None if test failed
142
+ """
143
+
144
+ target: str
145
+ success: bool
146
+ message: str
147
+ latency_ms: Optional[float] = None
148
+
149
+
150
+ @dataclasses.dataclass(frozen=True)
151
+ class ShareInfo:
152
+ """Information about an active file share session.
153
+
154
+ Attributes:
155
+ file_path: Absolute path to the file being shared
156
+ port: Port number on which the file server is listening
157
+ password: Authentication password for accessing the share
158
+ url: Full HTTP URL for accessing the share (e.g., 'http://localhost:8080')
159
+ conn_tag: Connection tag used for port forwarding, or None for local-only share
160
+ running: Whether the share server is currently active
161
+ """
162
+
163
+ file_path: str
164
+ port: int
165
+ password: str
166
+ url: str
167
+ conn_tag: str | None = None
168
+ running: bool = True
169
+ stopped: bool = False # True = manually stopped; False + not running = offline (conn down)
170
+ access_count: int = 0
171
+ failed_count: int = 0