procler 0.2.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.
Files changed (83) hide show
  1. procler/__init__.py +3 -0
  2. procler/__main__.py +6 -0
  3. procler/api/__init__.py +5 -0
  4. procler/api/app.py +261 -0
  5. procler/api/deps.py +21 -0
  6. procler/api/routes/__init__.py +5 -0
  7. procler/api/routes/config.py +290 -0
  8. procler/api/routes/groups.py +62 -0
  9. procler/api/routes/logs.py +43 -0
  10. procler/api/routes/processes.py +185 -0
  11. procler/api/routes/recipes.py +69 -0
  12. procler/api/routes/snippets.py +134 -0
  13. procler/api/routes/ws.py +459 -0
  14. procler/cli.py +1478 -0
  15. procler/config/__init__.py +65 -0
  16. procler/config/changelog.py +148 -0
  17. procler/config/loader.py +256 -0
  18. procler/config/schema.py +315 -0
  19. procler/core/__init__.py +54 -0
  20. procler/core/context_base.py +117 -0
  21. procler/core/context_docker.py +384 -0
  22. procler/core/context_local.py +287 -0
  23. procler/core/daemon_detector.py +325 -0
  24. procler/core/events.py +74 -0
  25. procler/core/groups.py +419 -0
  26. procler/core/health.py +280 -0
  27. procler/core/log_tailer.py +262 -0
  28. procler/core/process_manager.py +1277 -0
  29. procler/core/recipes.py +330 -0
  30. procler/core/snippets.py +231 -0
  31. procler/core/variable_substitution.py +65 -0
  32. procler/db.py +96 -0
  33. procler/logging.py +41 -0
  34. procler/models.py +130 -0
  35. procler/py.typed +0 -0
  36. procler/settings.py +29 -0
  37. procler/static/assets/AboutView-BwZnsfpW.js +4 -0
  38. procler/static/assets/AboutView-UHbxWXcS.css +1 -0
  39. procler/static/assets/Code-HTS-H1S6.js +74 -0
  40. procler/static/assets/ConfigView-CGJcmp9G.css +1 -0
  41. procler/static/assets/ConfigView-aVtbRDf8.js +1 -0
  42. procler/static/assets/DashboardView-C5jw9Nsd.css +1 -0
  43. procler/static/assets/DashboardView-Dab7Cu9v.js +1 -0
  44. procler/static/assets/DataTable-z39TOAa4.js +746 -0
  45. procler/static/assets/DescriptionsItem-B2E8YbqJ.js +74 -0
  46. procler/static/assets/Divider-Dk-6aD2Y.js +42 -0
  47. procler/static/assets/Empty-MuygEHZM.js +24 -0
  48. procler/static/assets/Grid-CZ9QVKAT.js +1 -0
  49. procler/static/assets/GroupsView-BALG7i1X.js +1 -0
  50. procler/static/assets/GroupsView-gXAI1CVC.css +1 -0
  51. procler/static/assets/Input-e0xaxoWE.js +259 -0
  52. procler/static/assets/PhArrowsClockwise.vue-DqDg31az.js +1 -0
  53. procler/static/assets/PhCheckCircle.vue-Fwj9sh9m.js +1 -0
  54. procler/static/assets/PhEye.vue-JcPHciC2.js +1 -0
  55. procler/static/assets/PhPlay.vue-CZm7Gy3u.js +1 -0
  56. procler/static/assets/PhPlus.vue-yTWqKlSh.js +1 -0
  57. procler/static/assets/PhStop.vue-DxsqwIki.js +1 -0
  58. procler/static/assets/PhTrash.vue-DcqQbN1_.js +125 -0
  59. procler/static/assets/PhXCircle.vue-BXWmrabV.js +1 -0
  60. procler/static/assets/ProcessDetailView-DDbtIWq9.css +1 -0
  61. procler/static/assets/ProcessDetailView-DPtdNV-q.js +1 -0
  62. procler/static/assets/ProcessesView-B3a6Umur.js +1 -0
  63. procler/static/assets/ProcessesView-goLmghbJ.css +1 -0
  64. procler/static/assets/RecipesView-D2VxdneD.js +166 -0
  65. procler/static/assets/RecipesView-DXnFDCK4.css +1 -0
  66. procler/static/assets/Select-BBR17AHq.js +317 -0
  67. procler/static/assets/SnippetsView-B3a9q3AI.css +1 -0
  68. procler/static/assets/SnippetsView-DBCB2yGq.js +1 -0
  69. procler/static/assets/Spin-BXTjvFUk.js +90 -0
  70. procler/static/assets/Tag-Bh_qV63A.js +71 -0
  71. procler/static/assets/changelog-KkTT4H9-.js +1 -0
  72. procler/static/assets/groups-Zu-_v8ey.js +1 -0
  73. procler/static/assets/index-BsN-YMXq.css +1 -0
  74. procler/static/assets/index-BzW1XhyH.js +1282 -0
  75. procler/static/assets/procler-DOrSB1Vj.js +1 -0
  76. procler/static/assets/recipes-1w5SseGb.js +1 -0
  77. procler/static/index.html +17 -0
  78. procler/static/procler.png +0 -0
  79. procler-0.2.0.dist-info/METADATA +545 -0
  80. procler-0.2.0.dist-info/RECORD +83 -0
  81. procler-0.2.0.dist-info/WHEEL +4 -0
  82. procler-0.2.0.dist-info/entry_points.txt +2 -0
  83. procler-0.2.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,459 @@
1
+ """WebSocket handler for real-time updates."""
2
+
3
+ import asyncio
4
+ import json
5
+ import logging
6
+ from typing import Any
7
+
8
+ from fastapi import APIRouter, WebSocket, WebSocketDisconnect
9
+
10
+ from ...core.events import EVENT_LOG_ENTRY, EVENT_RECIPE_STEP, EVENT_STATUS_CHANGE, get_event_bus
11
+ from ...core.log_tailer import get_log_tailer
12
+ from ...core.process_manager import get_linux_process_state
13
+ from ...db import init_database
14
+ from ...models import Process
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ router = APIRouter()
19
+ STATUS_POLL_INTERVAL = 5.0
20
+
21
+
22
+ class ConnectionManager:
23
+ """Manages WebSocket connections and subscriptions."""
24
+
25
+ def __init__(self):
26
+ # All active connections
27
+ self.active_connections: list[WebSocket] = []
28
+ # Process-specific log subscriptions: process_id -> set of websockets
29
+ self.log_subscriptions: dict[int, set[WebSocket]] = {}
30
+ # Process-specific status subscriptions: process_id -> set of websockets
31
+ self.status_subscriptions: dict[int, set[WebSocket]] = {}
32
+ # Global status subscriptions (all process changes)
33
+ self.global_status_subscriptions: set[WebSocket] = set()
34
+ # Status pollers to refresh linux_state in real time
35
+ self.status_pollers: dict[WebSocket, asyncio.Task] = {}
36
+ # Recipe subscriptions: recipe_name -> set of websockets
37
+ self.recipe_subscriptions: dict[str, set[WebSocket]] = {}
38
+
39
+ async def connect(self, websocket: WebSocket) -> None:
40
+ """Accept a new WebSocket connection."""
41
+ await websocket.accept()
42
+ self.active_connections.append(websocket)
43
+
44
+ async def disconnect(self, websocket: WebSocket) -> None:
45
+ """Remove a WebSocket connection and all its subscriptions."""
46
+ if websocket in self.active_connections:
47
+ self.active_connections.remove(websocket)
48
+
49
+ poller = self.status_pollers.pop(websocket, None)
50
+ if poller:
51
+ poller.cancel()
52
+
53
+ # Track which processes need tailer cleanup
54
+ processes_to_check: list[int] = []
55
+
56
+ # Remove from all log subscriptions
57
+ for process_id in list(self.log_subscriptions.keys()):
58
+ self.log_subscriptions[process_id].discard(websocket)
59
+ if not self.log_subscriptions[process_id]:
60
+ del self.log_subscriptions[process_id]
61
+ processes_to_check.append(process_id)
62
+
63
+ # Remove from all status subscriptions
64
+ for process_id in list(self.status_subscriptions.keys()):
65
+ self.status_subscriptions[process_id].discard(websocket)
66
+ if not self.status_subscriptions[process_id]:
67
+ del self.status_subscriptions[process_id]
68
+
69
+ # Remove from global subscriptions
70
+ self.global_status_subscriptions.discard(websocket)
71
+
72
+ # Remove from recipe subscriptions
73
+ for recipe_name in list(self.recipe_subscriptions.keys()):
74
+ self.recipe_subscriptions[recipe_name].discard(websocket)
75
+ if not self.recipe_subscriptions[recipe_name]:
76
+ del self.recipe_subscriptions[recipe_name]
77
+
78
+ # Stop tailers for processes with no remaining subscribers
79
+ for process_id in processes_to_check:
80
+ await self._stop_tailing_if_needed(process_id)
81
+
82
+ async def subscribe_logs(self, websocket: WebSocket, process_id: int) -> None:
83
+ """Subscribe to log updates for a specific process."""
84
+ is_first_subscriber = process_id not in self.log_subscriptions
85
+
86
+ if process_id not in self.log_subscriptions:
87
+ self.log_subscriptions[process_id] = set()
88
+ self.log_subscriptions[process_id].add(websocket)
89
+
90
+ # Start tailing if this is the first subscriber for a daemon process
91
+ if is_first_subscriber:
92
+ await self._start_tailing_if_needed(process_id)
93
+
94
+ async def unsubscribe_logs(self, websocket: WebSocket, process_id: int) -> None:
95
+ """Unsubscribe from log updates for a specific process."""
96
+ if process_id in self.log_subscriptions:
97
+ self.log_subscriptions[process_id].discard(websocket)
98
+ if not self.log_subscriptions[process_id]:
99
+ del self.log_subscriptions[process_id]
100
+ # Stop tailing when no more subscribers
101
+ await self._stop_tailing_if_needed(process_id)
102
+
103
+ async def _start_tailing_if_needed(self, process_id: int) -> None:
104
+ """Start log file tailing if the process has a log file."""
105
+ try:
106
+ init_database()
107
+ process = Process.from_id(process_id)
108
+ if process and getattr(process, "log_file", None):
109
+ tailer = get_log_tailer()
110
+ started = await tailer.start_tailing(process)
111
+ if started:
112
+ logger.info(f"Started tailing logs for process {process.name} (id={process_id})")
113
+ elif process:
114
+ logger.debug(f"Process {process.name} has no log_file configured, skipping tail")
115
+ except Exception as e:
116
+ logger.warning(f"Error starting tailer for process {process_id}: {e}")
117
+
118
+ async def _stop_tailing_if_needed(self, process_id: int) -> None:
119
+ """Stop log file tailing when no subscribers remain."""
120
+ try:
121
+ tailer = get_log_tailer()
122
+ await tailer.stop_tailing(process_id)
123
+ except Exception as e:
124
+ logger.debug(f"Error stopping tailer for process {process_id}: {e}")
125
+
126
+ def subscribe_status(self, websocket: WebSocket, process_id: int | None = None) -> None:
127
+ """Subscribe to status updates for a specific process or all processes."""
128
+ if process_id is None:
129
+ self.global_status_subscriptions.add(websocket)
130
+ else:
131
+ if process_id not in self.status_subscriptions:
132
+ self.status_subscriptions[process_id] = set()
133
+ self.status_subscriptions[process_id].add(websocket)
134
+ self._ensure_status_poller(websocket)
135
+
136
+ def unsubscribe_status(self, websocket: WebSocket, process_id: int | None = None) -> None:
137
+ """Unsubscribe from status updates."""
138
+ if process_id is None:
139
+ self.global_status_subscriptions.discard(websocket)
140
+ elif process_id in self.status_subscriptions:
141
+ self.status_subscriptions[process_id].discard(websocket)
142
+ if not self.status_subscriptions[process_id]:
143
+ del self.status_subscriptions[process_id]
144
+ if not self._has_status_subscription(websocket):
145
+ poller = self.status_pollers.pop(websocket, None)
146
+ if poller:
147
+ poller.cancel()
148
+
149
+ async def broadcast_log(self, process_id: int, log_data: dict[str, Any]) -> None:
150
+ """Broadcast a log entry to all subscribers of a process."""
151
+ message = {
152
+ "type": "log",
153
+ "process_id": process_id,
154
+ "data": log_data,
155
+ }
156
+ subscribers = self.log_subscriptions.get(process_id, set())
157
+ await self._send_to_many(subscribers, message)
158
+
159
+ async def broadcast_status(self, process_id: int, status_data: dict[str, Any]) -> None:
160
+ """Broadcast a status change to subscribers."""
161
+ message = {
162
+ "type": "status",
163
+ "process_id": process_id,
164
+ "data": status_data,
165
+ }
166
+
167
+ # Send to process-specific subscribers
168
+ subscribers = self.status_subscriptions.get(process_id, set())
169
+ # Also send to global subscribers
170
+ all_subscribers = subscribers | self.global_status_subscriptions
171
+ await self._send_to_many(all_subscribers, message)
172
+
173
+ def subscribe_recipe(self, websocket: WebSocket, recipe_name: str) -> None:
174
+ """Subscribe to recipe execution updates."""
175
+ if recipe_name not in self.recipe_subscriptions:
176
+ self.recipe_subscriptions[recipe_name] = set()
177
+ self.recipe_subscriptions[recipe_name].add(websocket)
178
+
179
+ def unsubscribe_recipe(self, websocket: WebSocket, recipe_name: str) -> None:
180
+ """Unsubscribe from recipe execution updates."""
181
+ if recipe_name in self.recipe_subscriptions:
182
+ self.recipe_subscriptions[recipe_name].discard(websocket)
183
+ if not self.recipe_subscriptions[recipe_name]:
184
+ del self.recipe_subscriptions[recipe_name]
185
+
186
+ async def broadcast_recipe_step(self, recipe_name: str, step_data: dict[str, Any]) -> None:
187
+ """Broadcast a recipe step update to subscribers."""
188
+ message = {
189
+ "type": "recipe_step",
190
+ "recipe": recipe_name,
191
+ "data": step_data,
192
+ }
193
+ subscribers = self.recipe_subscriptions.get(recipe_name, set())
194
+ await self._send_to_many(subscribers, message)
195
+
196
+ async def _send_to_many(self, websockets: set[WebSocket], message: dict) -> None:
197
+ """Send a message to multiple WebSocket connections."""
198
+ disconnected = []
199
+ for websocket in websockets:
200
+ try:
201
+ await websocket.send_json(message)
202
+ except Exception:
203
+ disconnected.append(websocket)
204
+
205
+ # Clean up disconnected sockets
206
+ for ws in disconnected:
207
+ await self.disconnect(ws)
208
+
209
+ async def send_personal(self, websocket: WebSocket, message: dict) -> None:
210
+ """Send a message to a specific WebSocket."""
211
+ try:
212
+ await websocket.send_json(message)
213
+ except Exception:
214
+ await self.disconnect(websocket)
215
+
216
+ def _has_status_subscription(self, websocket: WebSocket) -> bool:
217
+ if websocket in self.global_status_subscriptions:
218
+ return True
219
+ return any(websocket in subscribers for subscribers in self.status_subscriptions.values())
220
+
221
+ def _ensure_status_poller(self, websocket: WebSocket) -> None:
222
+ if websocket in self.status_pollers:
223
+ return
224
+ self.status_pollers[websocket] = asyncio.create_task(self._status_poll_loop(websocket))
225
+
226
+ async def _status_poll_loop(self, websocket: WebSocket) -> None:
227
+ while websocket in self.active_connections:
228
+ if not self._has_status_subscription(websocket):
229
+ break
230
+ try:
231
+ await self._send_status_snapshot(websocket)
232
+ except Exception:
233
+ await self.disconnect(websocket)
234
+ break
235
+ await asyncio.sleep(STATUS_POLL_INTERVAL)
236
+
237
+ async def _send_status_snapshot(self, websocket: WebSocket) -> None:
238
+ init_database()
239
+ if websocket in self.global_status_subscriptions:
240
+ processes = Process.query().all()
241
+ else:
242
+ process_ids = [
243
+ process_id for process_id, subscribers in self.status_subscriptions.items() if websocket in subscribers
244
+ ]
245
+ processes = [Process.from_id(process_id) for process_id in process_ids]
246
+ for process in processes:
247
+ if not process:
248
+ continue
249
+ await self.send_personal(
250
+ websocket,
251
+ {
252
+ "type": "status",
253
+ "process_id": process._id,
254
+ "data": _build_status_payload(process),
255
+ },
256
+ )
257
+
258
+
259
+ # Global connection manager instance
260
+ manager = ConnectionManager()
261
+
262
+
263
+ def get_connection_manager() -> ConnectionManager:
264
+ """Get the global ConnectionManager instance."""
265
+ return manager
266
+
267
+
268
+ # Event handlers that bridge ProcessManager events to WebSocket broadcasts
269
+ async def _handle_status_change(data: dict[str, Any]) -> None:
270
+ """Handle status change events from ProcessManager."""
271
+ process_id = data.get("process_id")
272
+ if process_id is not None:
273
+ init_database()
274
+ process = Process.from_id(process_id)
275
+ if process:
276
+ data = {**data, **_build_status_payload(process)}
277
+ await manager.broadcast_status(process_id, data)
278
+
279
+
280
+ async def _handle_log_entry(data: dict[str, Any]) -> None:
281
+ """Handle log entry events from ProcessManager."""
282
+ process_id = data.get("process_id")
283
+ if process_id is not None:
284
+ await manager.broadcast_log(process_id, data)
285
+
286
+
287
+ async def _handle_recipe_step(data: dict[str, Any]) -> None:
288
+ """Handle recipe step events from RecipeExecutor."""
289
+ recipe_name = data.get("recipe")
290
+ if recipe_name is not None:
291
+ await manager.broadcast_recipe_step(recipe_name, data)
292
+
293
+
294
+ def setup_event_handlers() -> None:
295
+ """Setup event handlers to bridge ProcessManager events to WebSocket."""
296
+ event_bus = get_event_bus()
297
+ event_bus.subscribe(EVENT_STATUS_CHANGE, _handle_status_change)
298
+ event_bus.subscribe(EVENT_LOG_ENTRY, _handle_log_entry)
299
+ event_bus.subscribe(EVENT_RECIPE_STEP, _handle_recipe_step)
300
+
301
+
302
+ # Setup event handlers when module loads
303
+ setup_event_handlers()
304
+
305
+
306
+ def _build_status_payload(process: Process) -> dict[str, Any]:
307
+ data: dict[str, Any] = {
308
+ "status": process.status,
309
+ "pid": process.pid,
310
+ }
311
+ if process.pid and process.status == "running":
312
+ linux_state = get_linux_process_state(process.pid)
313
+ if linux_state:
314
+ data["linux_state"] = linux_state
315
+ if linux_state["state_code"] == "D":
316
+ data["warning"] = "Process in uninterruptible sleep (D state) - may be stuck on I/O"
317
+ elif linux_state["state_code"] == "Z":
318
+ data["warning"] = "Process is a zombie - parent has not reaped it"
319
+ elif linux_state["state_code"] == "T":
320
+ data["warning"] = "Process is stopped (possibly by debugger or signal)"
321
+ return data
322
+
323
+
324
+ @router.websocket("/ws")
325
+ async def websocket_endpoint(websocket: WebSocket):
326
+ """
327
+ WebSocket endpoint for real-time updates.
328
+
329
+ Protocol:
330
+ Client -> Server:
331
+ {"action": "subscribe_logs", "process_id": 1}
332
+ {"action": "unsubscribe_logs", "process_id": 1}
333
+ {"action": "subscribe_status", "process_id": 1} # specific process
334
+ {"action": "subscribe_status"} # all processes
335
+ {"action": "unsubscribe_status", "process_id": 1}
336
+ {"action": "unsubscribe_status"} # all processes
337
+ {"action": "ping"}
338
+
339
+ Server -> Client:
340
+ {"type": "log", "process_id": 1, "data": {"timestamp": "...", "stream": "stdout", "line": "..."}}
341
+ {"type": "status", "process_id": 1, "data": {"status": "running", "pid": 12345, "linux_state": {...}}}
342
+ {"type": "subscribed", "action": "subscribe_logs", "process_id": 1}
343
+ {"type": "unsubscribed", "action": "unsubscribe_logs", "process_id": 1}
344
+ {"type": "pong"}
345
+ {"type": "error", "message": "..."}
346
+ """
347
+ await manager.connect(websocket)
348
+
349
+ try:
350
+ while True:
351
+ data = await websocket.receive_text()
352
+
353
+ try:
354
+ message = json.loads(data)
355
+ except json.JSONDecodeError:
356
+ await manager.send_personal(
357
+ websocket,
358
+ {"type": "error", "message": "Invalid JSON"},
359
+ )
360
+ continue
361
+
362
+ action = message.get("action")
363
+ process_id = message.get("process_id")
364
+
365
+ if action == "ping":
366
+ await manager.send_personal(websocket, {"type": "pong"})
367
+
368
+ elif action == "subscribe_logs":
369
+ if process_id is None:
370
+ await manager.send_personal(
371
+ websocket,
372
+ {"type": "error", "message": "process_id required for subscribe_logs"},
373
+ )
374
+ else:
375
+ await manager.subscribe_logs(websocket, process_id)
376
+ await manager.send_personal(
377
+ websocket,
378
+ {
379
+ "type": "subscribed",
380
+ "action": "subscribe_logs",
381
+ "process_id": process_id,
382
+ },
383
+ )
384
+
385
+ elif action == "unsubscribe_logs":
386
+ if process_id is None:
387
+ await manager.send_personal(
388
+ websocket,
389
+ {"type": "error", "message": "process_id required for unsubscribe_logs"},
390
+ )
391
+ else:
392
+ await manager.unsubscribe_logs(websocket, process_id)
393
+ await manager.send_personal(
394
+ websocket,
395
+ {
396
+ "type": "unsubscribed",
397
+ "action": "unsubscribe_logs",
398
+ "process_id": process_id,
399
+ },
400
+ )
401
+
402
+ elif action == "subscribe_status":
403
+ manager.subscribe_status(websocket, process_id)
404
+ response = {"type": "subscribed", "action": "subscribe_status"}
405
+ if process_id is not None:
406
+ response["process_id"] = process_id
407
+ await manager.send_personal(websocket, response)
408
+
409
+ elif action == "unsubscribe_status":
410
+ manager.unsubscribe_status(websocket, process_id)
411
+ response = {"type": "unsubscribed", "action": "unsubscribe_status"}
412
+ if process_id is not None:
413
+ response["process_id"] = process_id
414
+ await manager.send_personal(websocket, response)
415
+
416
+ elif action == "subscribe_recipe":
417
+ recipe_name = message.get("recipe")
418
+ if recipe_name is None:
419
+ await manager.send_personal(
420
+ websocket,
421
+ {"type": "error", "message": "recipe required for subscribe_recipe"},
422
+ )
423
+ else:
424
+ manager.subscribe_recipe(websocket, recipe_name)
425
+ await manager.send_personal(
426
+ websocket,
427
+ {
428
+ "type": "subscribed",
429
+ "action": "subscribe_recipe",
430
+ "recipe": recipe_name,
431
+ },
432
+ )
433
+
434
+ elif action == "unsubscribe_recipe":
435
+ recipe_name = message.get("recipe")
436
+ if recipe_name is None:
437
+ await manager.send_personal(
438
+ websocket,
439
+ {"type": "error", "message": "recipe required for unsubscribe_recipe"},
440
+ )
441
+ else:
442
+ manager.unsubscribe_recipe(websocket, recipe_name)
443
+ await manager.send_personal(
444
+ websocket,
445
+ {
446
+ "type": "unsubscribed",
447
+ "action": "unsubscribe_recipe",
448
+ "recipe": recipe_name,
449
+ },
450
+ )
451
+
452
+ else:
453
+ await manager.send_personal(
454
+ websocket,
455
+ {"type": "error", "message": f"Unknown action: {action}"},
456
+ )
457
+
458
+ except WebSocketDisconnect:
459
+ await manager.disconnect(websocket)