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.
- procler/__init__.py +3 -0
- procler/__main__.py +6 -0
- procler/api/__init__.py +5 -0
- procler/api/app.py +261 -0
- procler/api/deps.py +21 -0
- procler/api/routes/__init__.py +5 -0
- procler/api/routes/config.py +290 -0
- procler/api/routes/groups.py +62 -0
- procler/api/routes/logs.py +43 -0
- procler/api/routes/processes.py +185 -0
- procler/api/routes/recipes.py +69 -0
- procler/api/routes/snippets.py +134 -0
- procler/api/routes/ws.py +459 -0
- procler/cli.py +1478 -0
- procler/config/__init__.py +65 -0
- procler/config/changelog.py +148 -0
- procler/config/loader.py +256 -0
- procler/config/schema.py +315 -0
- procler/core/__init__.py +54 -0
- procler/core/context_base.py +117 -0
- procler/core/context_docker.py +384 -0
- procler/core/context_local.py +287 -0
- procler/core/daemon_detector.py +325 -0
- procler/core/events.py +74 -0
- procler/core/groups.py +419 -0
- procler/core/health.py +280 -0
- procler/core/log_tailer.py +262 -0
- procler/core/process_manager.py +1277 -0
- procler/core/recipes.py +330 -0
- procler/core/snippets.py +231 -0
- procler/core/variable_substitution.py +65 -0
- procler/db.py +96 -0
- procler/logging.py +41 -0
- procler/models.py +130 -0
- procler/py.typed +0 -0
- procler/settings.py +29 -0
- procler/static/assets/AboutView-BwZnsfpW.js +4 -0
- procler/static/assets/AboutView-UHbxWXcS.css +1 -0
- procler/static/assets/Code-HTS-H1S6.js +74 -0
- procler/static/assets/ConfigView-CGJcmp9G.css +1 -0
- procler/static/assets/ConfigView-aVtbRDf8.js +1 -0
- procler/static/assets/DashboardView-C5jw9Nsd.css +1 -0
- procler/static/assets/DashboardView-Dab7Cu9v.js +1 -0
- procler/static/assets/DataTable-z39TOAa4.js +746 -0
- procler/static/assets/DescriptionsItem-B2E8YbqJ.js +74 -0
- procler/static/assets/Divider-Dk-6aD2Y.js +42 -0
- procler/static/assets/Empty-MuygEHZM.js +24 -0
- procler/static/assets/Grid-CZ9QVKAT.js +1 -0
- procler/static/assets/GroupsView-BALG7i1X.js +1 -0
- procler/static/assets/GroupsView-gXAI1CVC.css +1 -0
- procler/static/assets/Input-e0xaxoWE.js +259 -0
- procler/static/assets/PhArrowsClockwise.vue-DqDg31az.js +1 -0
- procler/static/assets/PhCheckCircle.vue-Fwj9sh9m.js +1 -0
- procler/static/assets/PhEye.vue-JcPHciC2.js +1 -0
- procler/static/assets/PhPlay.vue-CZm7Gy3u.js +1 -0
- procler/static/assets/PhPlus.vue-yTWqKlSh.js +1 -0
- procler/static/assets/PhStop.vue-DxsqwIki.js +1 -0
- procler/static/assets/PhTrash.vue-DcqQbN1_.js +125 -0
- procler/static/assets/PhXCircle.vue-BXWmrabV.js +1 -0
- procler/static/assets/ProcessDetailView-DDbtIWq9.css +1 -0
- procler/static/assets/ProcessDetailView-DPtdNV-q.js +1 -0
- procler/static/assets/ProcessesView-B3a6Umur.js +1 -0
- procler/static/assets/ProcessesView-goLmghbJ.css +1 -0
- procler/static/assets/RecipesView-D2VxdneD.js +166 -0
- procler/static/assets/RecipesView-DXnFDCK4.css +1 -0
- procler/static/assets/Select-BBR17AHq.js +317 -0
- procler/static/assets/SnippetsView-B3a9q3AI.css +1 -0
- procler/static/assets/SnippetsView-DBCB2yGq.js +1 -0
- procler/static/assets/Spin-BXTjvFUk.js +90 -0
- procler/static/assets/Tag-Bh_qV63A.js +71 -0
- procler/static/assets/changelog-KkTT4H9-.js +1 -0
- procler/static/assets/groups-Zu-_v8ey.js +1 -0
- procler/static/assets/index-BsN-YMXq.css +1 -0
- procler/static/assets/index-BzW1XhyH.js +1282 -0
- procler/static/assets/procler-DOrSB1Vj.js +1 -0
- procler/static/assets/recipes-1w5SseGb.js +1 -0
- procler/static/index.html +17 -0
- procler/static/procler.png +0 -0
- procler-0.2.0.dist-info/METADATA +545 -0
- procler-0.2.0.dist-info/RECORD +83 -0
- procler-0.2.0.dist-info/WHEEL +4 -0
- procler-0.2.0.dist-info/entry_points.txt +2 -0
- procler-0.2.0.dist-info/licenses/LICENSE +21 -0
procler/api/routes/ws.py
ADDED
|
@@ -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)
|