truthound-dashboard 1.3.1__py3-none-any.whl → 1.4.1__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.
- truthound_dashboard/api/alerts.py +258 -0
- truthound_dashboard/api/anomaly.py +1302 -0
- truthound_dashboard/api/cross_alerts.py +352 -0
- truthound_dashboard/api/deps.py +143 -0
- truthound_dashboard/api/drift_monitor.py +540 -0
- truthound_dashboard/api/lineage.py +1151 -0
- truthound_dashboard/api/maintenance.py +363 -0
- truthound_dashboard/api/middleware.py +373 -1
- truthound_dashboard/api/model_monitoring.py +805 -0
- truthound_dashboard/api/notifications_advanced.py +2452 -0
- truthound_dashboard/api/plugins.py +2096 -0
- truthound_dashboard/api/profile.py +211 -14
- truthound_dashboard/api/reports.py +853 -0
- truthound_dashboard/api/router.py +147 -0
- truthound_dashboard/api/rule_suggestions.py +310 -0
- truthound_dashboard/api/schema_evolution.py +231 -0
- truthound_dashboard/api/sources.py +47 -3
- truthound_dashboard/api/triggers.py +190 -0
- truthound_dashboard/api/validations.py +13 -0
- truthound_dashboard/api/validators.py +333 -4
- truthound_dashboard/api/versioning.py +309 -0
- truthound_dashboard/api/websocket.py +301 -0
- truthound_dashboard/core/__init__.py +27 -0
- truthound_dashboard/core/anomaly.py +1395 -0
- truthound_dashboard/core/anomaly_explainer.py +633 -0
- truthound_dashboard/core/cache.py +206 -0
- truthound_dashboard/core/cached_services.py +422 -0
- truthound_dashboard/core/charts.py +352 -0
- truthound_dashboard/core/connections.py +1069 -42
- truthound_dashboard/core/cross_alerts.py +837 -0
- truthound_dashboard/core/drift_monitor.py +1477 -0
- truthound_dashboard/core/drift_sampling.py +669 -0
- truthound_dashboard/core/i18n/__init__.py +42 -0
- truthound_dashboard/core/i18n/detector.py +173 -0
- truthound_dashboard/core/i18n/messages.py +564 -0
- truthound_dashboard/core/lineage.py +971 -0
- truthound_dashboard/core/maintenance.py +443 -5
- truthound_dashboard/core/model_monitoring.py +1043 -0
- truthound_dashboard/core/notifications/channels.py +1020 -1
- truthound_dashboard/core/notifications/deduplication/__init__.py +143 -0
- truthound_dashboard/core/notifications/deduplication/policies.py +274 -0
- truthound_dashboard/core/notifications/deduplication/service.py +400 -0
- truthound_dashboard/core/notifications/deduplication/stores.py +2365 -0
- truthound_dashboard/core/notifications/deduplication/strategies.py +422 -0
- truthound_dashboard/core/notifications/dispatcher.py +43 -0
- truthound_dashboard/core/notifications/escalation/__init__.py +149 -0
- truthound_dashboard/core/notifications/escalation/backends.py +1384 -0
- truthound_dashboard/core/notifications/escalation/engine.py +429 -0
- truthound_dashboard/core/notifications/escalation/models.py +336 -0
- truthound_dashboard/core/notifications/escalation/scheduler.py +1187 -0
- truthound_dashboard/core/notifications/escalation/state_machine.py +330 -0
- truthound_dashboard/core/notifications/escalation/stores.py +2896 -0
- truthound_dashboard/core/notifications/events.py +49 -0
- truthound_dashboard/core/notifications/metrics/__init__.py +115 -0
- truthound_dashboard/core/notifications/metrics/base.py +528 -0
- truthound_dashboard/core/notifications/metrics/collectors.py +583 -0
- truthound_dashboard/core/notifications/routing/__init__.py +169 -0
- truthound_dashboard/core/notifications/routing/combinators.py +184 -0
- truthound_dashboard/core/notifications/routing/config.py +375 -0
- truthound_dashboard/core/notifications/routing/config_parser.py +867 -0
- truthound_dashboard/core/notifications/routing/engine.py +382 -0
- truthound_dashboard/core/notifications/routing/expression_engine.py +1269 -0
- truthound_dashboard/core/notifications/routing/jinja2_engine.py +774 -0
- truthound_dashboard/core/notifications/routing/rules.py +625 -0
- truthound_dashboard/core/notifications/routing/validator.py +678 -0
- truthound_dashboard/core/notifications/service.py +2 -0
- truthound_dashboard/core/notifications/stats_aggregator.py +850 -0
- truthound_dashboard/core/notifications/throttling/__init__.py +83 -0
- truthound_dashboard/core/notifications/throttling/builder.py +311 -0
- truthound_dashboard/core/notifications/throttling/stores.py +1859 -0
- truthound_dashboard/core/notifications/throttling/throttlers.py +633 -0
- truthound_dashboard/core/openlineage.py +1028 -0
- truthound_dashboard/core/plugins/__init__.py +39 -0
- truthound_dashboard/core/plugins/docs/__init__.py +39 -0
- truthound_dashboard/core/plugins/docs/extractor.py +703 -0
- truthound_dashboard/core/plugins/docs/renderers.py +804 -0
- truthound_dashboard/core/plugins/hooks/__init__.py +63 -0
- truthound_dashboard/core/plugins/hooks/decorators.py +367 -0
- truthound_dashboard/core/plugins/hooks/manager.py +403 -0
- truthound_dashboard/core/plugins/hooks/protocols.py +265 -0
- truthound_dashboard/core/plugins/lifecycle/__init__.py +41 -0
- truthound_dashboard/core/plugins/lifecycle/hot_reload.py +584 -0
- truthound_dashboard/core/plugins/lifecycle/machine.py +419 -0
- truthound_dashboard/core/plugins/lifecycle/states.py +266 -0
- truthound_dashboard/core/plugins/loader.py +504 -0
- truthound_dashboard/core/plugins/registry.py +810 -0
- truthound_dashboard/core/plugins/reporter_executor.py +588 -0
- truthound_dashboard/core/plugins/sandbox/__init__.py +59 -0
- truthound_dashboard/core/plugins/sandbox/code_validator.py +243 -0
- truthound_dashboard/core/plugins/sandbox/engines.py +770 -0
- truthound_dashboard/core/plugins/sandbox/protocols.py +194 -0
- truthound_dashboard/core/plugins/sandbox.py +617 -0
- truthound_dashboard/core/plugins/security/__init__.py +68 -0
- truthound_dashboard/core/plugins/security/analyzer.py +535 -0
- truthound_dashboard/core/plugins/security/policies.py +311 -0
- truthound_dashboard/core/plugins/security/protocols.py +296 -0
- truthound_dashboard/core/plugins/security/signing.py +842 -0
- truthound_dashboard/core/plugins/security.py +446 -0
- truthound_dashboard/core/plugins/validator_executor.py +401 -0
- truthound_dashboard/core/plugins/versioning/__init__.py +51 -0
- truthound_dashboard/core/plugins/versioning/constraints.py +377 -0
- truthound_dashboard/core/plugins/versioning/dependencies.py +541 -0
- truthound_dashboard/core/plugins/versioning/semver.py +266 -0
- truthound_dashboard/core/profile_comparison.py +601 -0
- truthound_dashboard/core/report_history.py +570 -0
- truthound_dashboard/core/reporters/__init__.py +57 -0
- truthound_dashboard/core/reporters/base.py +296 -0
- truthound_dashboard/core/reporters/csv_reporter.py +155 -0
- truthound_dashboard/core/reporters/html_reporter.py +598 -0
- truthound_dashboard/core/reporters/i18n/__init__.py +65 -0
- truthound_dashboard/core/reporters/i18n/base.py +494 -0
- truthound_dashboard/core/reporters/i18n/catalogs.py +930 -0
- truthound_dashboard/core/reporters/json_reporter.py +160 -0
- truthound_dashboard/core/reporters/junit_reporter.py +233 -0
- truthound_dashboard/core/reporters/markdown_reporter.py +207 -0
- truthound_dashboard/core/reporters/pdf_reporter.py +209 -0
- truthound_dashboard/core/reporters/registry.py +272 -0
- truthound_dashboard/core/rule_generator.py +2088 -0
- truthound_dashboard/core/scheduler.py +822 -12
- truthound_dashboard/core/schema_evolution.py +858 -0
- truthound_dashboard/core/services.py +152 -9
- truthound_dashboard/core/statistics.py +718 -0
- truthound_dashboard/core/streaming_anomaly.py +883 -0
- truthound_dashboard/core/triggers/__init__.py +45 -0
- truthound_dashboard/core/triggers/base.py +226 -0
- truthound_dashboard/core/triggers/evaluators.py +609 -0
- truthound_dashboard/core/triggers/factory.py +363 -0
- truthound_dashboard/core/unified_alerts.py +870 -0
- truthound_dashboard/core/validation_limits.py +509 -0
- truthound_dashboard/core/versioning.py +709 -0
- truthound_dashboard/core/websocket/__init__.py +59 -0
- truthound_dashboard/core/websocket/manager.py +512 -0
- truthound_dashboard/core/websocket/messages.py +130 -0
- truthound_dashboard/db/__init__.py +30 -0
- truthound_dashboard/db/models.py +3375 -3
- truthound_dashboard/main.py +22 -0
- truthound_dashboard/schemas/__init__.py +396 -1
- truthound_dashboard/schemas/anomaly.py +1258 -0
- truthound_dashboard/schemas/base.py +4 -0
- truthound_dashboard/schemas/cross_alerts.py +334 -0
- truthound_dashboard/schemas/drift_monitor.py +890 -0
- truthound_dashboard/schemas/lineage.py +428 -0
- truthound_dashboard/schemas/maintenance.py +154 -0
- truthound_dashboard/schemas/model_monitoring.py +374 -0
- truthound_dashboard/schemas/notifications_advanced.py +1363 -0
- truthound_dashboard/schemas/openlineage.py +704 -0
- truthound_dashboard/schemas/plugins.py +1293 -0
- truthound_dashboard/schemas/profile.py +420 -34
- truthound_dashboard/schemas/profile_comparison.py +242 -0
- truthound_dashboard/schemas/reports.py +285 -0
- truthound_dashboard/schemas/rule_suggestion.py +434 -0
- truthound_dashboard/schemas/schema_evolution.py +164 -0
- truthound_dashboard/schemas/source.py +117 -2
- truthound_dashboard/schemas/triggers.py +511 -0
- truthound_dashboard/schemas/unified_alerts.py +223 -0
- truthound_dashboard/schemas/validation.py +25 -1
- truthound_dashboard/schemas/validators/__init__.py +11 -0
- truthound_dashboard/schemas/validators/base.py +151 -0
- truthound_dashboard/schemas/versioning.py +152 -0
- truthound_dashboard/static/index.html +2 -2
- {truthound_dashboard-1.3.1.dist-info → truthound_dashboard-1.4.1.dist-info}/METADATA +147 -23
- truthound_dashboard-1.4.1.dist-info/RECORD +239 -0
- truthound_dashboard/static/assets/index-BZG20KuF.js +0 -586
- truthound_dashboard/static/assets/index-D_HyZ3pb.css +0 -1
- truthound_dashboard/static/assets/unmerged_dictionaries-CtpqQBm0.js +0 -1
- truthound_dashboard-1.3.1.dist-info/RECORD +0 -110
- {truthound_dashboard-1.3.1.dist-info → truthound_dashboard-1.4.1.dist-info}/WHEEL +0 -0
- {truthound_dashboard-1.3.1.dist-info → truthound_dashboard-1.4.1.dist-info}/entry_points.txt +0 -0
- {truthound_dashboard-1.3.1.dist-info → truthound_dashboard-1.4.1.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)
|