truthound-dashboard 1.3.0__py3-none-any.whl → 1.4.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 (169) hide show
  1. truthound_dashboard/api/alerts.py +258 -0
  2. truthound_dashboard/api/anomaly.py +1302 -0
  3. truthound_dashboard/api/cross_alerts.py +352 -0
  4. truthound_dashboard/api/deps.py +143 -0
  5. truthound_dashboard/api/drift_monitor.py +540 -0
  6. truthound_dashboard/api/lineage.py +1151 -0
  7. truthound_dashboard/api/maintenance.py +363 -0
  8. truthound_dashboard/api/middleware.py +373 -1
  9. truthound_dashboard/api/model_monitoring.py +805 -0
  10. truthound_dashboard/api/notifications_advanced.py +2452 -0
  11. truthound_dashboard/api/plugins.py +2096 -0
  12. truthound_dashboard/api/profile.py +211 -14
  13. truthound_dashboard/api/reports.py +853 -0
  14. truthound_dashboard/api/router.py +147 -0
  15. truthound_dashboard/api/rule_suggestions.py +310 -0
  16. truthound_dashboard/api/schema_evolution.py +231 -0
  17. truthound_dashboard/api/sources.py +47 -3
  18. truthound_dashboard/api/triggers.py +190 -0
  19. truthound_dashboard/api/validations.py +13 -0
  20. truthound_dashboard/api/validators.py +333 -4
  21. truthound_dashboard/api/versioning.py +309 -0
  22. truthound_dashboard/api/websocket.py +301 -0
  23. truthound_dashboard/core/__init__.py +27 -0
  24. truthound_dashboard/core/anomaly.py +1395 -0
  25. truthound_dashboard/core/anomaly_explainer.py +633 -0
  26. truthound_dashboard/core/cache.py +206 -0
  27. truthound_dashboard/core/cached_services.py +422 -0
  28. truthound_dashboard/core/charts.py +352 -0
  29. truthound_dashboard/core/connections.py +1069 -42
  30. truthound_dashboard/core/cross_alerts.py +837 -0
  31. truthound_dashboard/core/drift_monitor.py +1477 -0
  32. truthound_dashboard/core/drift_sampling.py +669 -0
  33. truthound_dashboard/core/i18n/__init__.py +42 -0
  34. truthound_dashboard/core/i18n/detector.py +173 -0
  35. truthound_dashboard/core/i18n/messages.py +564 -0
  36. truthound_dashboard/core/lineage.py +971 -0
  37. truthound_dashboard/core/maintenance.py +443 -5
  38. truthound_dashboard/core/model_monitoring.py +1043 -0
  39. truthound_dashboard/core/notifications/channels.py +1020 -1
  40. truthound_dashboard/core/notifications/deduplication/__init__.py +143 -0
  41. truthound_dashboard/core/notifications/deduplication/policies.py +274 -0
  42. truthound_dashboard/core/notifications/deduplication/service.py +400 -0
  43. truthound_dashboard/core/notifications/deduplication/stores.py +2365 -0
  44. truthound_dashboard/core/notifications/deduplication/strategies.py +422 -0
  45. truthound_dashboard/core/notifications/dispatcher.py +43 -0
  46. truthound_dashboard/core/notifications/escalation/__init__.py +149 -0
  47. truthound_dashboard/core/notifications/escalation/backends.py +1384 -0
  48. truthound_dashboard/core/notifications/escalation/engine.py +429 -0
  49. truthound_dashboard/core/notifications/escalation/models.py +336 -0
  50. truthound_dashboard/core/notifications/escalation/scheduler.py +1187 -0
  51. truthound_dashboard/core/notifications/escalation/state_machine.py +330 -0
  52. truthound_dashboard/core/notifications/escalation/stores.py +2896 -0
  53. truthound_dashboard/core/notifications/events.py +49 -0
  54. truthound_dashboard/core/notifications/metrics/__init__.py +115 -0
  55. truthound_dashboard/core/notifications/metrics/base.py +528 -0
  56. truthound_dashboard/core/notifications/metrics/collectors.py +583 -0
  57. truthound_dashboard/core/notifications/routing/__init__.py +169 -0
  58. truthound_dashboard/core/notifications/routing/combinators.py +184 -0
  59. truthound_dashboard/core/notifications/routing/config.py +375 -0
  60. truthound_dashboard/core/notifications/routing/config_parser.py +867 -0
  61. truthound_dashboard/core/notifications/routing/engine.py +382 -0
  62. truthound_dashboard/core/notifications/routing/expression_engine.py +1269 -0
  63. truthound_dashboard/core/notifications/routing/jinja2_engine.py +774 -0
  64. truthound_dashboard/core/notifications/routing/rules.py +625 -0
  65. truthound_dashboard/core/notifications/routing/validator.py +678 -0
  66. truthound_dashboard/core/notifications/service.py +2 -0
  67. truthound_dashboard/core/notifications/stats_aggregator.py +850 -0
  68. truthound_dashboard/core/notifications/throttling/__init__.py +83 -0
  69. truthound_dashboard/core/notifications/throttling/builder.py +311 -0
  70. truthound_dashboard/core/notifications/throttling/stores.py +1859 -0
  71. truthound_dashboard/core/notifications/throttling/throttlers.py +633 -0
  72. truthound_dashboard/core/openlineage.py +1028 -0
  73. truthound_dashboard/core/plugins/__init__.py +39 -0
  74. truthound_dashboard/core/plugins/docs/__init__.py +39 -0
  75. truthound_dashboard/core/plugins/docs/extractor.py +703 -0
  76. truthound_dashboard/core/plugins/docs/renderers.py +804 -0
  77. truthound_dashboard/core/plugins/hooks/__init__.py +63 -0
  78. truthound_dashboard/core/plugins/hooks/decorators.py +367 -0
  79. truthound_dashboard/core/plugins/hooks/manager.py +403 -0
  80. truthound_dashboard/core/plugins/hooks/protocols.py +265 -0
  81. truthound_dashboard/core/plugins/lifecycle/__init__.py +41 -0
  82. truthound_dashboard/core/plugins/lifecycle/hot_reload.py +584 -0
  83. truthound_dashboard/core/plugins/lifecycle/machine.py +419 -0
  84. truthound_dashboard/core/plugins/lifecycle/states.py +266 -0
  85. truthound_dashboard/core/plugins/loader.py +504 -0
  86. truthound_dashboard/core/plugins/registry.py +810 -0
  87. truthound_dashboard/core/plugins/reporter_executor.py +588 -0
  88. truthound_dashboard/core/plugins/sandbox/__init__.py +59 -0
  89. truthound_dashboard/core/plugins/sandbox/code_validator.py +243 -0
  90. truthound_dashboard/core/plugins/sandbox/engines.py +770 -0
  91. truthound_dashboard/core/plugins/sandbox/protocols.py +194 -0
  92. truthound_dashboard/core/plugins/sandbox.py +617 -0
  93. truthound_dashboard/core/plugins/security/__init__.py +68 -0
  94. truthound_dashboard/core/plugins/security/analyzer.py +535 -0
  95. truthound_dashboard/core/plugins/security/policies.py +311 -0
  96. truthound_dashboard/core/plugins/security/protocols.py +296 -0
  97. truthound_dashboard/core/plugins/security/signing.py +842 -0
  98. truthound_dashboard/core/plugins/security.py +446 -0
  99. truthound_dashboard/core/plugins/validator_executor.py +401 -0
  100. truthound_dashboard/core/plugins/versioning/__init__.py +51 -0
  101. truthound_dashboard/core/plugins/versioning/constraints.py +377 -0
  102. truthound_dashboard/core/plugins/versioning/dependencies.py +541 -0
  103. truthound_dashboard/core/plugins/versioning/semver.py +266 -0
  104. truthound_dashboard/core/profile_comparison.py +601 -0
  105. truthound_dashboard/core/report_history.py +570 -0
  106. truthound_dashboard/core/reporters/__init__.py +57 -0
  107. truthound_dashboard/core/reporters/base.py +296 -0
  108. truthound_dashboard/core/reporters/csv_reporter.py +155 -0
  109. truthound_dashboard/core/reporters/html_reporter.py +598 -0
  110. truthound_dashboard/core/reporters/i18n/__init__.py +65 -0
  111. truthound_dashboard/core/reporters/i18n/base.py +494 -0
  112. truthound_dashboard/core/reporters/i18n/catalogs.py +930 -0
  113. truthound_dashboard/core/reporters/json_reporter.py +160 -0
  114. truthound_dashboard/core/reporters/junit_reporter.py +233 -0
  115. truthound_dashboard/core/reporters/markdown_reporter.py +207 -0
  116. truthound_dashboard/core/reporters/pdf_reporter.py +209 -0
  117. truthound_dashboard/core/reporters/registry.py +272 -0
  118. truthound_dashboard/core/rule_generator.py +2088 -0
  119. truthound_dashboard/core/scheduler.py +822 -12
  120. truthound_dashboard/core/schema_evolution.py +858 -0
  121. truthound_dashboard/core/services.py +152 -9
  122. truthound_dashboard/core/statistics.py +718 -0
  123. truthound_dashboard/core/streaming_anomaly.py +883 -0
  124. truthound_dashboard/core/triggers/__init__.py +45 -0
  125. truthound_dashboard/core/triggers/base.py +226 -0
  126. truthound_dashboard/core/triggers/evaluators.py +609 -0
  127. truthound_dashboard/core/triggers/factory.py +363 -0
  128. truthound_dashboard/core/unified_alerts.py +870 -0
  129. truthound_dashboard/core/validation_limits.py +509 -0
  130. truthound_dashboard/core/versioning.py +709 -0
  131. truthound_dashboard/core/websocket/__init__.py +59 -0
  132. truthound_dashboard/core/websocket/manager.py +512 -0
  133. truthound_dashboard/core/websocket/messages.py +130 -0
  134. truthound_dashboard/db/__init__.py +30 -0
  135. truthound_dashboard/db/models.py +3375 -3
  136. truthound_dashboard/main.py +22 -0
  137. truthound_dashboard/schemas/__init__.py +396 -1
  138. truthound_dashboard/schemas/anomaly.py +1258 -0
  139. truthound_dashboard/schemas/base.py +4 -0
  140. truthound_dashboard/schemas/cross_alerts.py +334 -0
  141. truthound_dashboard/schemas/drift_monitor.py +890 -0
  142. truthound_dashboard/schemas/lineage.py +428 -0
  143. truthound_dashboard/schemas/maintenance.py +154 -0
  144. truthound_dashboard/schemas/model_monitoring.py +374 -0
  145. truthound_dashboard/schemas/notifications_advanced.py +1363 -0
  146. truthound_dashboard/schemas/openlineage.py +704 -0
  147. truthound_dashboard/schemas/plugins.py +1293 -0
  148. truthound_dashboard/schemas/profile.py +420 -34
  149. truthound_dashboard/schemas/profile_comparison.py +242 -0
  150. truthound_dashboard/schemas/reports.py +285 -0
  151. truthound_dashboard/schemas/rule_suggestion.py +434 -0
  152. truthound_dashboard/schemas/schema_evolution.py +164 -0
  153. truthound_dashboard/schemas/source.py +117 -2
  154. truthound_dashboard/schemas/triggers.py +511 -0
  155. truthound_dashboard/schemas/unified_alerts.py +223 -0
  156. truthound_dashboard/schemas/validation.py +25 -1
  157. truthound_dashboard/schemas/validators/__init__.py +11 -0
  158. truthound_dashboard/schemas/validators/base.py +151 -0
  159. truthound_dashboard/schemas/versioning.py +152 -0
  160. truthound_dashboard/static/index.html +2 -2
  161. {truthound_dashboard-1.3.0.dist-info → truthound_dashboard-1.4.0.dist-info}/METADATA +142 -18
  162. truthound_dashboard-1.4.0.dist-info/RECORD +239 -0
  163. truthound_dashboard/static/assets/index-BCA8H1hO.js +0 -574
  164. truthound_dashboard/static/assets/index-BNsSQ2fN.css +0 -1
  165. truthound_dashboard/static/assets/unmerged_dictionaries-CsJWCRx9.js +0 -1
  166. truthound_dashboard-1.3.0.dist-info/RECORD +0 -110
  167. {truthound_dashboard-1.3.0.dist-info → truthound_dashboard-1.4.0.dist-info}/WHEEL +0 -0
  168. {truthound_dashboard-1.3.0.dist-info → truthound_dashboard-1.4.0.dist-info}/entry_points.txt +0 -0
  169. {truthound_dashboard-1.3.0.dist-info → truthound_dashboard-1.4.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,59 @@
1
+ """WebSocket support for real-time updates.
2
+
3
+ This module provides WebSocket infrastructure for broadcasting real-time
4
+ updates to connected clients.
5
+
6
+ Features:
7
+ - Connection management (track active connections, handle disconnects)
8
+ - Room-based broadcasting for targeted updates
9
+ - Heartbeat/ping-pong for connection health
10
+ - Optional token-based authentication
11
+ - Support for multiple concurrent clients
12
+
13
+ Example:
14
+ from truthound_dashboard.core.websocket import (
15
+ WebSocketManager,
16
+ get_websocket_manager,
17
+ )
18
+
19
+ # Get the global manager
20
+ manager = get_websocket_manager()
21
+
22
+ # Broadcast to all clients in a room
23
+ await manager.broadcast_to_room(
24
+ room="incidents",
25
+ message={"type": "incident_updated", "data": {...}},
26
+ )
27
+ """
28
+
29
+ from .manager import (
30
+ WebSocketConnection,
31
+ WebSocketManager,
32
+ get_websocket_manager,
33
+ reset_websocket_manager,
34
+ )
35
+ from .messages import (
36
+ IncidentCreatedMessage,
37
+ IncidentResolvedMessage,
38
+ IncidentStateChangedMessage,
39
+ IncidentUpdatedMessage,
40
+ WebSocketMessage,
41
+ WebSocketMessageType,
42
+ create_incident_message,
43
+ )
44
+
45
+ __all__ = [
46
+ # Manager
47
+ "WebSocketConnection",
48
+ "WebSocketManager",
49
+ "get_websocket_manager",
50
+ "reset_websocket_manager",
51
+ # Messages
52
+ "WebSocketMessage",
53
+ "WebSocketMessageType",
54
+ "IncidentCreatedMessage",
55
+ "IncidentUpdatedMessage",
56
+ "IncidentStateChangedMessage",
57
+ "IncidentResolvedMessage",
58
+ "create_incident_message",
59
+ ]
@@ -0,0 +1,512 @@
1
+ """WebSocket connection manager.
2
+
3
+ This module provides a manager class for handling WebSocket connections,
4
+ including connection tracking, room-based broadcasting, and health checks.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import logging
11
+ from dataclasses import dataclass, field
12
+ from datetime import datetime
13
+ from typing import Any
14
+
15
+ from fastapi import WebSocket, WebSocketDisconnect
16
+
17
+ from .messages import WebSocketMessage, WebSocketMessageType
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ @dataclass
23
+ class WebSocketConnection:
24
+ """Represents a WebSocket connection with metadata."""
25
+
26
+ websocket: WebSocket
27
+ connection_id: str
28
+ connected_at: datetime = field(default_factory=datetime.utcnow)
29
+ rooms: set[str] = field(default_factory=set)
30
+ last_ping: datetime | None = None
31
+ client_info: dict[str, Any] = field(default_factory=dict)
32
+ token: str | None = None
33
+
34
+ async def send_json(self, data: dict[str, Any]) -> bool:
35
+ """Send JSON data to the client.
36
+
37
+ Args:
38
+ data: Data to send.
39
+
40
+ Returns:
41
+ True if sent successfully, False otherwise.
42
+ """
43
+ try:
44
+ await self.websocket.send_json(data)
45
+ return True
46
+ except Exception as e:
47
+ logger.warning(
48
+ f"Failed to send message to connection {self.connection_id}: {e}"
49
+ )
50
+ return False
51
+
52
+ async def send_message(self, message: WebSocketMessage) -> bool:
53
+ """Send a WebSocket message to the client.
54
+
55
+ Args:
56
+ message: Message to send.
57
+
58
+ Returns:
59
+ True if sent successfully, False otherwise.
60
+ """
61
+ return await self.send_json(message.to_json())
62
+
63
+
64
+ class WebSocketManager:
65
+ """Manager for WebSocket connections.
66
+
67
+ Features:
68
+ - Connection tracking
69
+ - Room-based broadcasting
70
+ - Heartbeat/ping-pong
71
+ - Graceful disconnection handling
72
+ - Thread-safe operations
73
+
74
+ Example:
75
+ manager = WebSocketManager()
76
+
77
+ # Accept a new connection
78
+ conn = await manager.connect(websocket, connection_id)
79
+
80
+ # Join a room
81
+ await manager.join_room(conn, "incidents")
82
+
83
+ # Broadcast to room
84
+ await manager.broadcast_to_room(
85
+ room="incidents",
86
+ message={"type": "update", "data": {...}},
87
+ )
88
+
89
+ # Disconnect
90
+ await manager.disconnect(connection_id)
91
+ """
92
+
93
+ def __init__(
94
+ self,
95
+ ping_interval: float = 30.0,
96
+ ping_timeout: float = 10.0,
97
+ ) -> None:
98
+ """Initialize the WebSocket manager.
99
+
100
+ Args:
101
+ ping_interval: Interval between heartbeat pings (seconds).
102
+ ping_timeout: Timeout for ping response (seconds).
103
+ """
104
+ self._connections: dict[str, WebSocketConnection] = {}
105
+ self._rooms: dict[str, set[str]] = {}
106
+ self._lock = asyncio.Lock()
107
+ self._ping_interval = ping_interval
108
+ self._ping_timeout = ping_timeout
109
+ self._heartbeat_task: asyncio.Task | None = None
110
+ self._running = False
111
+
112
+ @property
113
+ def connection_count(self) -> int:
114
+ """Get the number of active connections."""
115
+ return len(self._connections)
116
+
117
+ @property
118
+ def room_count(self) -> int:
119
+ """Get the number of active rooms."""
120
+ return len(self._rooms)
121
+
122
+ async def start(self) -> None:
123
+ """Start the manager and heartbeat task."""
124
+ if self._running:
125
+ return
126
+
127
+ self._running = True
128
+ self._heartbeat_task = asyncio.create_task(self._heartbeat_loop())
129
+ logger.info("WebSocket manager started")
130
+
131
+ async def stop(self) -> None:
132
+ """Stop the manager and disconnect all clients."""
133
+ if not self._running:
134
+ return
135
+
136
+ self._running = False
137
+
138
+ # Cancel heartbeat task
139
+ if self._heartbeat_task:
140
+ self._heartbeat_task.cancel()
141
+ try:
142
+ await self._heartbeat_task
143
+ except asyncio.CancelledError:
144
+ pass
145
+ self._heartbeat_task = None
146
+
147
+ # Disconnect all clients
148
+ connection_ids = list(self._connections.keys())
149
+ for conn_id in connection_ids:
150
+ await self.disconnect(conn_id, reason="Server shutting down")
151
+
152
+ logger.info("WebSocket manager stopped")
153
+
154
+ async def connect(
155
+ self,
156
+ websocket: WebSocket,
157
+ connection_id: str,
158
+ token: str | None = None,
159
+ ) -> WebSocketConnection:
160
+ """Accept and register a new WebSocket connection.
161
+
162
+ Args:
163
+ websocket: The WebSocket instance.
164
+ connection_id: Unique identifier for this connection.
165
+ token: Optional authentication token.
166
+
167
+ Returns:
168
+ The registered connection object.
169
+ """
170
+ await websocket.accept()
171
+
172
+ connection = WebSocketConnection(
173
+ websocket=websocket,
174
+ connection_id=connection_id,
175
+ token=token,
176
+ )
177
+
178
+ async with self._lock:
179
+ self._connections[connection_id] = connection
180
+
181
+ logger.info(f"WebSocket connected: {connection_id}")
182
+
183
+ # Send connected message
184
+ await connection.send_message(
185
+ WebSocketMessage(
186
+ type=WebSocketMessageType.CONNECTED,
187
+ data={
188
+ "connection_id": connection_id,
189
+ "message": "Connected successfully",
190
+ },
191
+ )
192
+ )
193
+
194
+ return connection
195
+
196
+ async def disconnect(
197
+ self,
198
+ connection_id: str,
199
+ reason: str = "Client disconnected",
200
+ ) -> None:
201
+ """Disconnect and unregister a WebSocket connection.
202
+
203
+ Args:
204
+ connection_id: ID of the connection to disconnect.
205
+ reason: Reason for disconnection.
206
+ """
207
+ async with self._lock:
208
+ connection = self._connections.pop(connection_id, None)
209
+ if not connection:
210
+ return
211
+
212
+ # Remove from all rooms
213
+ for room in list(connection.rooms):
214
+ if room in self._rooms:
215
+ self._rooms[room].discard(connection_id)
216
+ if not self._rooms[room]:
217
+ del self._rooms[room]
218
+
219
+ # Try to close gracefully
220
+ try:
221
+ await connection.websocket.close()
222
+ except Exception:
223
+ pass
224
+
225
+ logger.info(f"WebSocket disconnected: {connection_id} ({reason})")
226
+
227
+ async def join_room(
228
+ self,
229
+ connection: WebSocketConnection,
230
+ room: str,
231
+ ) -> None:
232
+ """Add a connection to a room.
233
+
234
+ Args:
235
+ connection: The connection to add.
236
+ room: Room name.
237
+ """
238
+ async with self._lock:
239
+ if room not in self._rooms:
240
+ self._rooms[room] = set()
241
+ self._rooms[room].add(connection.connection_id)
242
+ connection.rooms.add(room)
243
+
244
+ logger.debug(f"Connection {connection.connection_id} joined room: {room}")
245
+
246
+ async def leave_room(
247
+ self,
248
+ connection: WebSocketConnection,
249
+ room: str,
250
+ ) -> None:
251
+ """Remove a connection from a room.
252
+
253
+ Args:
254
+ connection: The connection to remove.
255
+ room: Room name.
256
+ """
257
+ async with self._lock:
258
+ if room in self._rooms:
259
+ self._rooms[room].discard(connection.connection_id)
260
+ if not self._rooms[room]:
261
+ del self._rooms[room]
262
+ connection.rooms.discard(room)
263
+
264
+ logger.debug(f"Connection {connection.connection_id} left room: {room}")
265
+
266
+ async def broadcast(
267
+ self,
268
+ message: WebSocketMessage | dict[str, Any],
269
+ exclude: set[str] | None = None,
270
+ ) -> int:
271
+ """Broadcast a message to all connected clients.
272
+
273
+ Args:
274
+ message: Message to broadcast.
275
+ exclude: Set of connection IDs to exclude.
276
+
277
+ Returns:
278
+ Number of clients that received the message.
279
+ """
280
+ exclude = exclude or set()
281
+
282
+ if isinstance(message, WebSocketMessage):
283
+ data = message.to_json()
284
+ else:
285
+ data = message
286
+
287
+ sent_count = 0
288
+ failed_connections: list[str] = []
289
+
290
+ async with self._lock:
291
+ connections = list(self._connections.items())
292
+
293
+ for conn_id, connection in connections:
294
+ if conn_id in exclude:
295
+ continue
296
+
297
+ success = await connection.send_json(data)
298
+ if success:
299
+ sent_count += 1
300
+ else:
301
+ failed_connections.append(conn_id)
302
+
303
+ # Clean up failed connections
304
+ for conn_id in failed_connections:
305
+ await self.disconnect(conn_id, reason="Send failed")
306
+
307
+ return sent_count
308
+
309
+ async def broadcast_to_room(
310
+ self,
311
+ room: str,
312
+ message: WebSocketMessage | dict[str, Any],
313
+ exclude: set[str] | None = None,
314
+ ) -> int:
315
+ """Broadcast a message to all clients in a room.
316
+
317
+ Args:
318
+ room: Room name.
319
+ message: Message to broadcast.
320
+ exclude: Set of connection IDs to exclude.
321
+
322
+ Returns:
323
+ Number of clients that received the message.
324
+ """
325
+ exclude = exclude or set()
326
+
327
+ if isinstance(message, WebSocketMessage):
328
+ data = message.to_json()
329
+ else:
330
+ data = message
331
+
332
+ async with self._lock:
333
+ connection_ids = self._rooms.get(room, set()).copy()
334
+
335
+ sent_count = 0
336
+ failed_connections: list[str] = []
337
+
338
+ for conn_id in connection_ids:
339
+ if conn_id in exclude:
340
+ continue
341
+
342
+ async with self._lock:
343
+ connection = self._connections.get(conn_id)
344
+
345
+ if not connection:
346
+ continue
347
+
348
+ success = await connection.send_json(data)
349
+ if success:
350
+ sent_count += 1
351
+ else:
352
+ failed_connections.append(conn_id)
353
+
354
+ # Clean up failed connections
355
+ for conn_id in failed_connections:
356
+ await self.disconnect(conn_id, reason="Send failed")
357
+
358
+ return sent_count
359
+
360
+ async def send_to_connection(
361
+ self,
362
+ connection_id: str,
363
+ message: WebSocketMessage | dict[str, Any],
364
+ ) -> bool:
365
+ """Send a message to a specific connection.
366
+
367
+ Args:
368
+ connection_id: Target connection ID.
369
+ message: Message to send.
370
+
371
+ Returns:
372
+ True if sent successfully, False otherwise.
373
+ """
374
+ async with self._lock:
375
+ connection = self._connections.get(connection_id)
376
+
377
+ if not connection:
378
+ return False
379
+
380
+ if isinstance(message, WebSocketMessage):
381
+ data = message.to_json()
382
+ else:
383
+ data = message
384
+
385
+ success = await connection.send_json(data)
386
+ if not success:
387
+ await self.disconnect(connection_id, reason="Send failed")
388
+
389
+ return success
390
+
391
+ async def _heartbeat_loop(self) -> None:
392
+ """Background task for sending heartbeat pings."""
393
+ while self._running:
394
+ try:
395
+ await asyncio.sleep(self._ping_interval)
396
+
397
+ if not self._running:
398
+ break
399
+
400
+ # Send ping to all connections
401
+ async with self._lock:
402
+ connections = list(self._connections.values())
403
+
404
+ ping_message = WebSocketMessage(
405
+ type=WebSocketMessageType.PING,
406
+ data={"server_time": datetime.utcnow().isoformat()},
407
+ )
408
+
409
+ for connection in connections:
410
+ connection.last_ping = datetime.utcnow()
411
+ await connection.send_message(ping_message)
412
+
413
+ except asyncio.CancelledError:
414
+ break
415
+ except Exception as e:
416
+ logger.error(f"Error in heartbeat loop: {e}")
417
+
418
+ async def handle_client_message(
419
+ self,
420
+ connection: WebSocketConnection,
421
+ data: dict[str, Any],
422
+ ) -> None:
423
+ """Handle an incoming message from a client.
424
+
425
+ Args:
426
+ connection: The connection that sent the message.
427
+ data: The message data.
428
+ """
429
+ message_type = data.get("type", "")
430
+
431
+ if message_type == WebSocketMessageType.PONG.value:
432
+ # Client responded to ping
433
+ logger.debug(f"Received pong from {connection.connection_id}")
434
+ elif message_type == WebSocketMessageType.PING.value:
435
+ # Client sending ping, respond with pong
436
+ await connection.send_message(
437
+ WebSocketMessage(
438
+ type=WebSocketMessageType.PONG,
439
+ data={"server_time": datetime.utcnow().isoformat()},
440
+ )
441
+ )
442
+ else:
443
+ logger.debug(
444
+ f"Received message from {connection.connection_id}: {message_type}"
445
+ )
446
+
447
+ def get_connection(self, connection_id: str) -> WebSocketConnection | None:
448
+ """Get a connection by ID.
449
+
450
+ Args:
451
+ connection_id: Connection ID.
452
+
453
+ Returns:
454
+ The connection or None if not found.
455
+ """
456
+ return self._connections.get(connection_id)
457
+
458
+ def get_room_connections(self, room: str) -> list[WebSocketConnection]:
459
+ """Get all connections in a room.
460
+
461
+ Args:
462
+ room: Room name.
463
+
464
+ Returns:
465
+ List of connections in the room.
466
+ """
467
+ connection_ids = self._rooms.get(room, set())
468
+ return [
469
+ self._connections[conn_id]
470
+ for conn_id in connection_ids
471
+ if conn_id in self._connections
472
+ ]
473
+
474
+ def get_status(self) -> dict[str, Any]:
475
+ """Get manager status.
476
+
477
+ Returns:
478
+ Dictionary with status information.
479
+ """
480
+ return {
481
+ "running": self._running,
482
+ "connection_count": self.connection_count,
483
+ "room_count": self.room_count,
484
+ "rooms": {room: len(conns) for room, conns in self._rooms.items()},
485
+ "ping_interval": self._ping_interval,
486
+ "ping_timeout": self._ping_timeout,
487
+ }
488
+
489
+
490
+ # Global WebSocket manager instance
491
+ _websocket_manager: WebSocketManager | None = None
492
+
493
+
494
+ def get_websocket_manager() -> WebSocketManager:
495
+ """Get the global WebSocket manager instance.
496
+
497
+ Returns:
498
+ The WebSocket manager singleton.
499
+ """
500
+ global _websocket_manager
501
+ if _websocket_manager is None:
502
+ _websocket_manager = WebSocketManager()
503
+ return _websocket_manager
504
+
505
+
506
+ def reset_websocket_manager() -> None:
507
+ """Reset the global WebSocket manager instance.
508
+
509
+ Used for testing.
510
+ """
511
+ global _websocket_manager
512
+ _websocket_manager = None
@@ -0,0 +1,130 @@
1
+ """WebSocket message types and schemas.
2
+
3
+ This module defines the message types used for WebSocket communication.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from datetime import datetime
9
+ from enum import Enum
10
+ from typing import Any
11
+
12
+ from pydantic import BaseModel, Field
13
+
14
+
15
+ class WebSocketMessageType(str, Enum):
16
+ """WebSocket message types for escalation incidents."""
17
+
18
+ # Connection lifecycle
19
+ CONNECTED = "connected"
20
+ DISCONNECTED = "disconnected"
21
+ PING = "ping"
22
+ PONG = "pong"
23
+ ERROR = "error"
24
+
25
+ # Incident events
26
+ INCIDENT_CREATED = "incident_created"
27
+ INCIDENT_UPDATED = "incident_updated"
28
+ INCIDENT_STATE_CHANGED = "incident_state_changed"
29
+ INCIDENT_RESOLVED = "incident_resolved"
30
+ INCIDENT_ACKNOWLEDGED = "incident_acknowledged"
31
+ INCIDENT_ESCALATED = "incident_escalated"
32
+
33
+
34
+ class WebSocketMessage(BaseModel):
35
+ """Base WebSocket message schema."""
36
+
37
+ type: WebSocketMessageType = Field(..., description="Message type")
38
+ timestamp: str = Field(
39
+ default_factory=lambda: datetime.utcnow().isoformat(),
40
+ description="Message timestamp (ISO format)",
41
+ )
42
+ data: dict[str, Any] = Field(default_factory=dict, description="Message payload")
43
+
44
+ def to_json(self) -> dict[str, Any]:
45
+ """Convert to JSON-serializable dict."""
46
+ return self.model_dump()
47
+
48
+
49
+ class IncidentCreatedMessage(WebSocketMessage):
50
+ """Message for incident creation."""
51
+
52
+ type: WebSocketMessageType = WebSocketMessageType.INCIDENT_CREATED
53
+
54
+
55
+ class IncidentUpdatedMessage(WebSocketMessage):
56
+ """Message for incident updates."""
57
+
58
+ type: WebSocketMessageType = WebSocketMessageType.INCIDENT_UPDATED
59
+
60
+
61
+ class IncidentStateChangedMessage(WebSocketMessage):
62
+ """Message for incident state changes.
63
+
64
+ Includes from_state and to_state in the data payload.
65
+ """
66
+
67
+ type: WebSocketMessageType = WebSocketMessageType.INCIDENT_STATE_CHANGED
68
+
69
+
70
+ class IncidentResolvedMessage(WebSocketMessage):
71
+ """Message for incident resolution."""
72
+
73
+ type: WebSocketMessageType = WebSocketMessageType.INCIDENT_RESOLVED
74
+
75
+
76
+ class IncidentAcknowledgedMessage(WebSocketMessage):
77
+ """Message for incident acknowledgement."""
78
+
79
+ type: WebSocketMessageType = WebSocketMessageType.INCIDENT_ACKNOWLEDGED
80
+
81
+
82
+ class IncidentEscalatedMessage(WebSocketMessage):
83
+ """Message for incident escalation."""
84
+
85
+ type: WebSocketMessageType = WebSocketMessageType.INCIDENT_ESCALATED
86
+
87
+
88
+ def create_incident_message(
89
+ message_type: WebSocketMessageType,
90
+ incident_id: str,
91
+ incident_ref: str,
92
+ policy_id: str,
93
+ state: str,
94
+ current_level: int,
95
+ **extra_data: Any,
96
+ ) -> WebSocketMessage:
97
+ """Create an incident-related WebSocket message.
98
+
99
+ Args:
100
+ message_type: Type of the message.
101
+ incident_id: ID of the incident.
102
+ incident_ref: External reference of the incident.
103
+ policy_id: ID of the escalation policy.
104
+ state: Current state of the incident.
105
+ current_level: Current escalation level.
106
+ **extra_data: Additional data to include.
107
+
108
+ Returns:
109
+ WebSocketMessage with the incident data.
110
+ """
111
+ data = {
112
+ "incident_id": incident_id,
113
+ "incident_ref": incident_ref,
114
+ "policy_id": policy_id,
115
+ "state": state,
116
+ "current_level": current_level,
117
+ **extra_data,
118
+ }
119
+
120
+ message_classes = {
121
+ WebSocketMessageType.INCIDENT_CREATED: IncidentCreatedMessage,
122
+ WebSocketMessageType.INCIDENT_UPDATED: IncidentUpdatedMessage,
123
+ WebSocketMessageType.INCIDENT_STATE_CHANGED: IncidentStateChangedMessage,
124
+ WebSocketMessageType.INCIDENT_RESOLVED: IncidentResolvedMessage,
125
+ WebSocketMessageType.INCIDENT_ACKNOWLEDGED: IncidentAcknowledgedMessage,
126
+ WebSocketMessageType.INCIDENT_ESCALATED: IncidentEscalatedMessage,
127
+ }
128
+
129
+ message_class = message_classes.get(message_type, WebSocketMessage)
130
+ return message_class(type=message_type, data=data)