htmlgraph 0.27.6__py3-none-any.whl → 0.28.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.
- htmlgraph/__init__.py +9 -1
- htmlgraph/api/broadcast.py +316 -0
- htmlgraph/api/broadcast_routes.py +357 -0
- htmlgraph/api/broadcast_websocket.py +115 -0
- htmlgraph/api/cost_alerts_websocket.py +7 -16
- htmlgraph/api/main.py +110 -1
- htmlgraph/api/offline.py +776 -0
- htmlgraph/api/presence.py +446 -0
- htmlgraph/api/reactive.py +455 -0
- htmlgraph/api/reactive_routes.py +195 -0
- htmlgraph/api/static/broadcast-demo.html +393 -0
- htmlgraph/api/sync_routes.py +184 -0
- htmlgraph/api/websocket.py +112 -37
- htmlgraph/broadcast_integration.py +227 -0
- htmlgraph/cli_commands/sync.py +207 -0
- htmlgraph/db/schema.py +214 -0
- htmlgraph/hooks/event_tracker.py +53 -2
- htmlgraph/reactive_integration.py +148 -0
- htmlgraph/session_context.py +1669 -0
- htmlgraph/session_manager.py +70 -0
- htmlgraph/sync/__init__.py +21 -0
- htmlgraph/sync/git_sync.py +458 -0
- {htmlgraph-0.27.6.dist-info → htmlgraph-0.28.0.dist-info}/METADATA +1 -1
- {htmlgraph-0.27.6.dist-info → htmlgraph-0.28.0.dist-info}/RECORD +31 -16
- {htmlgraph-0.27.6.data → htmlgraph-0.28.0.data}/data/htmlgraph/dashboard.html +0 -0
- {htmlgraph-0.27.6.data → htmlgraph-0.28.0.data}/data/htmlgraph/styles.css +0 -0
- {htmlgraph-0.27.6.data → htmlgraph-0.28.0.data}/data/htmlgraph/templates/AGENTS.md.template +0 -0
- {htmlgraph-0.27.6.data → htmlgraph-0.28.0.data}/data/htmlgraph/templates/CLAUDE.md.template +0 -0
- {htmlgraph-0.27.6.data → htmlgraph-0.28.0.data}/data/htmlgraph/templates/GEMINI.md.template +0 -0
- {htmlgraph-0.27.6.dist-info → htmlgraph-0.28.0.dist-info}/WHEEL +0 -0
- {htmlgraph-0.27.6.dist-info → htmlgraph-0.28.0.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Reactive Query System - Phase 3
|
|
3
|
+
|
|
4
|
+
Dashboard queries that auto-update when underlying data changes.
|
|
5
|
+
No more manual refresh - changes propagate instantly via WebSocket.
|
|
6
|
+
|
|
7
|
+
Features:
|
|
8
|
+
- Register queries with SQL templates
|
|
9
|
+
- Automatic invalidation on data changes
|
|
10
|
+
- Dependency tracking (queries know what data they depend on)
|
|
11
|
+
- Sub-100ms query update latency
|
|
12
|
+
- WebSocket push notifications to subscribers
|
|
13
|
+
|
|
14
|
+
Example:
|
|
15
|
+
>>> manager = ReactiveQueryManager(websocket_manager, db_path)
|
|
16
|
+
>>> await manager.register_default_queries(db)
|
|
17
|
+
>>>
|
|
18
|
+
>>> # Subscribe client to query
|
|
19
|
+
>>> result = await manager.subscribe_to_query("features_by_status", client_id)
|
|
20
|
+
>>>
|
|
21
|
+
>>> # When feature status changes, query auto-updates
|
|
22
|
+
>>> await manager.on_resource_updated("feat-123", "feature")
|
|
23
|
+
>>> # Client receives new query result automatically
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
import logging
|
|
27
|
+
import time
|
|
28
|
+
from dataclasses import dataclass
|
|
29
|
+
from datetime import datetime
|
|
30
|
+
from typing import Any
|
|
31
|
+
|
|
32
|
+
import aiosqlite
|
|
33
|
+
|
|
34
|
+
logger = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class QueryDefinition:
|
|
39
|
+
"""Definition of a reactive query."""
|
|
40
|
+
|
|
41
|
+
query_id: str
|
|
42
|
+
name: str
|
|
43
|
+
description: str
|
|
44
|
+
sql: str # SQL query template
|
|
45
|
+
depends_on: list[
|
|
46
|
+
str
|
|
47
|
+
] # Resource patterns it depends on (e.g., "*features", "feat-123")
|
|
48
|
+
refresh_interval_ms: int = 5000 # How often to refresh if no changes
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class QueryResult:
|
|
53
|
+
"""Result of executing a query."""
|
|
54
|
+
|
|
55
|
+
query_id: str
|
|
56
|
+
timestamp: datetime
|
|
57
|
+
rows: list[dict[str, Any]]
|
|
58
|
+
row_count: int = 0
|
|
59
|
+
execution_time_ms: float = 0.0
|
|
60
|
+
|
|
61
|
+
def __post_init__(self) -> None:
|
|
62
|
+
self.row_count = len(self.rows)
|
|
63
|
+
|
|
64
|
+
def to_dict(self) -> dict[str, Any]:
|
|
65
|
+
"""Convert to dictionary for JSON serialization."""
|
|
66
|
+
return {
|
|
67
|
+
"query_id": self.query_id,
|
|
68
|
+
"timestamp": self.timestamp.isoformat(),
|
|
69
|
+
"rows": self.rows,
|
|
70
|
+
"row_count": self.row_count,
|
|
71
|
+
"execution_time_ms": self.execution_time_ms,
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class ReactiveQuery:
|
|
76
|
+
"""Single reactive query with change detection."""
|
|
77
|
+
|
|
78
|
+
def __init__(self, definition: QueryDefinition, db_path: str):
|
|
79
|
+
"""
|
|
80
|
+
Initialize reactive query.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
definition: Query definition
|
|
84
|
+
db_path: Path to SQLite database
|
|
85
|
+
"""
|
|
86
|
+
self.definition = definition
|
|
87
|
+
self.db_path = db_path
|
|
88
|
+
self.subscribers: list[str] = [] # client_ids subscribed
|
|
89
|
+
self.last_result: QueryResult | None = None
|
|
90
|
+
self.last_execution: datetime | None = None
|
|
91
|
+
|
|
92
|
+
async def execute(self) -> QueryResult:
|
|
93
|
+
"""Execute query and return result."""
|
|
94
|
+
start_time = time.time()
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
async with aiosqlite.connect(self.db_path) as db:
|
|
98
|
+
db.row_factory = aiosqlite.Row
|
|
99
|
+
cursor = await db.execute(self.definition.sql)
|
|
100
|
+
rows = await cursor.fetchall()
|
|
101
|
+
|
|
102
|
+
# Convert rows to dicts
|
|
103
|
+
row_dicts: list[dict[str, Any]] = []
|
|
104
|
+
rows_list = list(rows)
|
|
105
|
+
if rows_list:
|
|
106
|
+
columns = list(rows_list[0].keys())
|
|
107
|
+
row_dicts = [dict(zip(columns, row)) for row in rows_list]
|
|
108
|
+
|
|
109
|
+
result = QueryResult(
|
|
110
|
+
query_id=self.definition.query_id,
|
|
111
|
+
timestamp=datetime.now(),
|
|
112
|
+
rows=row_dicts,
|
|
113
|
+
execution_time_ms=(time.time() - start_time) * 1000,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
self.last_execution = datetime.now()
|
|
117
|
+
return result
|
|
118
|
+
|
|
119
|
+
except Exception as e:
|
|
120
|
+
logger.error(f"Query {self.definition.query_id} failed: {e}")
|
|
121
|
+
return QueryResult(
|
|
122
|
+
query_id=self.definition.query_id,
|
|
123
|
+
timestamp=datetime.now(),
|
|
124
|
+
rows=[],
|
|
125
|
+
execution_time_ms=(time.time() - start_time) * 1000,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
async def has_changed(self) -> bool:
|
|
129
|
+
"""Check if query result has changed since last execution."""
|
|
130
|
+
if self.last_result is None:
|
|
131
|
+
return True
|
|
132
|
+
|
|
133
|
+
# Store old rows for comparison
|
|
134
|
+
old_rows = self.last_result.rows
|
|
135
|
+
|
|
136
|
+
new_result = await self.execute()
|
|
137
|
+
|
|
138
|
+
# Simple comparison: check if rows changed
|
|
139
|
+
changed = new_result.rows != old_rows
|
|
140
|
+
|
|
141
|
+
self.last_result = new_result
|
|
142
|
+
return changed
|
|
143
|
+
|
|
144
|
+
def add_subscriber(self, client_id: str) -> None:
|
|
145
|
+
"""Add client to subscribers list."""
|
|
146
|
+
if client_id not in self.subscribers:
|
|
147
|
+
self.subscribers.append(client_id)
|
|
148
|
+
|
|
149
|
+
def remove_subscriber(self, client_id: str) -> None:
|
|
150
|
+
"""Remove client from subscribers."""
|
|
151
|
+
if client_id in self.subscribers:
|
|
152
|
+
self.subscribers.remove(client_id)
|
|
153
|
+
|
|
154
|
+
def get_subscribers(self) -> list[str]:
|
|
155
|
+
"""Get all subscribed clients."""
|
|
156
|
+
return self.subscribers.copy()
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class ReactiveQueryManager:
|
|
160
|
+
"""
|
|
161
|
+
Manages all reactive queries and handles invalidation.
|
|
162
|
+
|
|
163
|
+
Tracks dependencies between queries and resources, automatically
|
|
164
|
+
invalidating and re-executing queries when dependent data changes.
|
|
165
|
+
"""
|
|
166
|
+
|
|
167
|
+
def __init__(self, websocket_manager: Any, db_path: str):
|
|
168
|
+
"""
|
|
169
|
+
Initialize reactive query manager.
|
|
170
|
+
|
|
171
|
+
Args:
|
|
172
|
+
websocket_manager: WebSocketManager for broadcasting updates
|
|
173
|
+
db_path: Path to SQLite database
|
|
174
|
+
"""
|
|
175
|
+
self.websocket_manager = websocket_manager
|
|
176
|
+
self.db_path = db_path
|
|
177
|
+
self.queries: dict[str, ReactiveQuery] = {}
|
|
178
|
+
self.dependencies: dict[str, set[str]] = {} # resource_pattern -> query_ids
|
|
179
|
+
|
|
180
|
+
def register_query(self, definition: QueryDefinition) -> None:
|
|
181
|
+
"""
|
|
182
|
+
Register a new reactive query.
|
|
183
|
+
|
|
184
|
+
Args:
|
|
185
|
+
definition: Query definition with SQL and dependencies
|
|
186
|
+
"""
|
|
187
|
+
query = ReactiveQuery(definition, self.db_path)
|
|
188
|
+
self.queries[definition.query_id] = query
|
|
189
|
+
|
|
190
|
+
# Track dependencies
|
|
191
|
+
for dep in definition.depends_on:
|
|
192
|
+
if dep not in self.dependencies:
|
|
193
|
+
self.dependencies[dep] = set()
|
|
194
|
+
self.dependencies[dep].add(definition.query_id)
|
|
195
|
+
|
|
196
|
+
logger.info(f"Registered query: {definition.query_id} ({definition.name})")
|
|
197
|
+
|
|
198
|
+
async def register_default_queries(self) -> None:
|
|
199
|
+
"""Register common queries used by dashboard."""
|
|
200
|
+
|
|
201
|
+
# Query 1: Features by status
|
|
202
|
+
self.register_query(
|
|
203
|
+
QueryDefinition(
|
|
204
|
+
query_id="features_by_status",
|
|
205
|
+
name="Features by Status",
|
|
206
|
+
description="Count of features in each status",
|
|
207
|
+
sql="""
|
|
208
|
+
SELECT status, COUNT(*) as count
|
|
209
|
+
FROM features
|
|
210
|
+
GROUP BY status
|
|
211
|
+
ORDER BY status
|
|
212
|
+
""",
|
|
213
|
+
depends_on=["*features"], # Depends on all features
|
|
214
|
+
)
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
# Query 2: Agent workload
|
|
218
|
+
self.register_query(
|
|
219
|
+
QueryDefinition(
|
|
220
|
+
query_id="agent_workload",
|
|
221
|
+
name="Agent Workload",
|
|
222
|
+
description="Active tasks per agent",
|
|
223
|
+
sql="""
|
|
224
|
+
SELECT
|
|
225
|
+
agent_id,
|
|
226
|
+
COUNT(DISTINCT session_id) as active_sessions,
|
|
227
|
+
COUNT(*) as total_events
|
|
228
|
+
FROM agent_events
|
|
229
|
+
WHERE timestamp > datetime('now', '-1 hour')
|
|
230
|
+
GROUP BY agent_id
|
|
231
|
+
ORDER BY total_events DESC
|
|
232
|
+
""",
|
|
233
|
+
depends_on=["*events"],
|
|
234
|
+
)
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
# Query 3: Recent activity
|
|
238
|
+
self.register_query(
|
|
239
|
+
QueryDefinition(
|
|
240
|
+
query_id="recent_activity",
|
|
241
|
+
name="Recent Activity",
|
|
242
|
+
description="Last 20 tool executions",
|
|
243
|
+
sql="""
|
|
244
|
+
SELECT
|
|
245
|
+
event_id, agent_id, tool_name,
|
|
246
|
+
input_summary, timestamp
|
|
247
|
+
FROM agent_events
|
|
248
|
+
WHERE event_type = 'tool_call'
|
|
249
|
+
ORDER BY timestamp DESC
|
|
250
|
+
LIMIT 20
|
|
251
|
+
""",
|
|
252
|
+
depends_on=["*events"],
|
|
253
|
+
)
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
# Query 4: Blocked features
|
|
257
|
+
self.register_query(
|
|
258
|
+
QueryDefinition(
|
|
259
|
+
query_id="blocked_features",
|
|
260
|
+
name="Blocked Features",
|
|
261
|
+
description="Features waiting on other features",
|
|
262
|
+
sql="""
|
|
263
|
+
SELECT id, title, status, created_at
|
|
264
|
+
FROM features
|
|
265
|
+
WHERE status = 'blocked'
|
|
266
|
+
ORDER BY created_at DESC
|
|
267
|
+
""",
|
|
268
|
+
depends_on=["*features"],
|
|
269
|
+
)
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
# Query 5: Cost trends (hourly)
|
|
273
|
+
self.register_query(
|
|
274
|
+
QueryDefinition(
|
|
275
|
+
query_id="cost_trends",
|
|
276
|
+
name="Cost Trends",
|
|
277
|
+
description="Cost aggregated by hour",
|
|
278
|
+
sql="""
|
|
279
|
+
SELECT
|
|
280
|
+
strftime('%Y-%m-%d %H:00', timestamp) as hour,
|
|
281
|
+
SUM(cost_tokens) as total_tokens,
|
|
282
|
+
COUNT(*) as event_count
|
|
283
|
+
FROM agent_events
|
|
284
|
+
WHERE cost_tokens > 0
|
|
285
|
+
GROUP BY hour
|
|
286
|
+
ORDER BY hour DESC
|
|
287
|
+
LIMIT 24
|
|
288
|
+
""",
|
|
289
|
+
depends_on=["*events"],
|
|
290
|
+
)
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
# Query 6: Active sessions
|
|
294
|
+
self.register_query(
|
|
295
|
+
QueryDefinition(
|
|
296
|
+
query_id="active_sessions",
|
|
297
|
+
name="Active Sessions",
|
|
298
|
+
description="Currently active agent sessions",
|
|
299
|
+
sql="""
|
|
300
|
+
SELECT
|
|
301
|
+
session_id, agent_assigned,
|
|
302
|
+
created_at, updated_at
|
|
303
|
+
FROM sessions
|
|
304
|
+
WHERE status = 'active'
|
|
305
|
+
ORDER BY updated_at DESC
|
|
306
|
+
""",
|
|
307
|
+
depends_on=["*sessions"],
|
|
308
|
+
)
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
logger.info(f"Registered {len(self.queries)} default queries")
|
|
312
|
+
|
|
313
|
+
async def on_resource_updated(self, resource_id: str, resource_type: str) -> None:
|
|
314
|
+
"""
|
|
315
|
+
Called when a resource (feature, track, event, etc.) is updated.
|
|
316
|
+
|
|
317
|
+
Invalidates all queries that depend on this resource.
|
|
318
|
+
|
|
319
|
+
Args:
|
|
320
|
+
resource_id: ID of updated resource (e.g., "feat-123")
|
|
321
|
+
resource_type: Type of resource (e.g., "feature", "event", "session")
|
|
322
|
+
"""
|
|
323
|
+
affected_queries: set[str] = set()
|
|
324
|
+
|
|
325
|
+
# Check exact match dependencies
|
|
326
|
+
if resource_id in self.dependencies:
|
|
327
|
+
affected_queries.update(self.dependencies[resource_id])
|
|
328
|
+
|
|
329
|
+
# Check wildcard dependencies (e.g., "*features")
|
|
330
|
+
wildcard_key = f"*{resource_type}s"
|
|
331
|
+
if wildcard_key in self.dependencies:
|
|
332
|
+
affected_queries.update(self.dependencies[wildcard_key])
|
|
333
|
+
|
|
334
|
+
# Re-execute affected queries and notify subscribers
|
|
335
|
+
for query_id in affected_queries:
|
|
336
|
+
if query_id in self.queries:
|
|
337
|
+
await self.invalidate_query(query_id)
|
|
338
|
+
|
|
339
|
+
async def invalidate_query(self, query_id: str) -> None:
|
|
340
|
+
"""
|
|
341
|
+
Re-execute query and broadcast new result to subscribers.
|
|
342
|
+
|
|
343
|
+
Args:
|
|
344
|
+
query_id: Query to invalidate
|
|
345
|
+
"""
|
|
346
|
+
if query_id not in self.queries:
|
|
347
|
+
logger.warning(f"Query not found: {query_id}")
|
|
348
|
+
return
|
|
349
|
+
|
|
350
|
+
query = self.queries[query_id]
|
|
351
|
+
|
|
352
|
+
# Execute query
|
|
353
|
+
result = await query.execute()
|
|
354
|
+
query.last_result = result
|
|
355
|
+
|
|
356
|
+
# Broadcast to subscribers
|
|
357
|
+
subscribers_notified = 0
|
|
358
|
+
for client_id in query.get_subscribers():
|
|
359
|
+
try:
|
|
360
|
+
# Send update via WebSocketManager's broadcast method
|
|
361
|
+
# Note: Using a pseudo-session "queries" for query subscriptions
|
|
362
|
+
sent = await self.websocket_manager.broadcast_to_session(
|
|
363
|
+
session_id="queries",
|
|
364
|
+
event={
|
|
365
|
+
"type": "query_update",
|
|
366
|
+
"query_id": query_id,
|
|
367
|
+
**result.to_dict(),
|
|
368
|
+
},
|
|
369
|
+
)
|
|
370
|
+
subscribers_notified += sent
|
|
371
|
+
except Exception as e:
|
|
372
|
+
logger.error(f"Error broadcasting to {client_id}: {e}")
|
|
373
|
+
query.remove_subscriber(client_id)
|
|
374
|
+
|
|
375
|
+
logger.debug(
|
|
376
|
+
f"Query {query_id} invalidated, notified {subscribers_notified} subscribers"
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
async def subscribe_to_query(
|
|
380
|
+
self, query_id: str, client_id: str
|
|
381
|
+
) -> QueryResult | None:
|
|
382
|
+
"""
|
|
383
|
+
Subscribe client to query and return initial result.
|
|
384
|
+
|
|
385
|
+
Args:
|
|
386
|
+
query_id: Query to subscribe to
|
|
387
|
+
client_id: Client subscribing
|
|
388
|
+
|
|
389
|
+
Returns:
|
|
390
|
+
Initial query result or None if query not found
|
|
391
|
+
"""
|
|
392
|
+
if query_id not in self.queries:
|
|
393
|
+
logger.warning(f"Query not found: {query_id}")
|
|
394
|
+
return None
|
|
395
|
+
|
|
396
|
+
query = self.queries[query_id]
|
|
397
|
+
query.add_subscriber(client_id)
|
|
398
|
+
|
|
399
|
+
# Return initial result (execute if not cached)
|
|
400
|
+
if query.last_result is None:
|
|
401
|
+
query.last_result = await query.execute()
|
|
402
|
+
|
|
403
|
+
logger.info(f"Client {client_id} subscribed to query {query_id}")
|
|
404
|
+
return query.last_result
|
|
405
|
+
|
|
406
|
+
def unsubscribe_from_query(self, query_id: str, client_id: str) -> None:
|
|
407
|
+
"""
|
|
408
|
+
Unsubscribe client from query.
|
|
409
|
+
|
|
410
|
+
Args:
|
|
411
|
+
query_id: Query to unsubscribe from
|
|
412
|
+
client_id: Client unsubscribing
|
|
413
|
+
"""
|
|
414
|
+
if query_id in self.queries:
|
|
415
|
+
self.queries[query_id].remove_subscriber(client_id)
|
|
416
|
+
logger.info(f"Client {client_id} unsubscribed from query {query_id}")
|
|
417
|
+
|
|
418
|
+
async def get_query_result(self, query_id: str) -> QueryResult | None:
|
|
419
|
+
"""
|
|
420
|
+
Get current result of a query.
|
|
421
|
+
|
|
422
|
+
Args:
|
|
423
|
+
query_id: Query to get result for
|
|
424
|
+
|
|
425
|
+
Returns:
|
|
426
|
+
Query result or None if not found
|
|
427
|
+
"""
|
|
428
|
+
if query_id not in self.queries:
|
|
429
|
+
return None
|
|
430
|
+
|
|
431
|
+
query = self.queries[query_id]
|
|
432
|
+
if query.last_result is None:
|
|
433
|
+
query.last_result = await query.execute()
|
|
434
|
+
|
|
435
|
+
return query.last_result
|
|
436
|
+
|
|
437
|
+
def list_queries(self) -> list[dict[str, Any]]:
|
|
438
|
+
"""
|
|
439
|
+
List all registered queries.
|
|
440
|
+
|
|
441
|
+
Returns:
|
|
442
|
+
List of query metadata
|
|
443
|
+
"""
|
|
444
|
+
return [
|
|
445
|
+
{
|
|
446
|
+
"query_id": q.definition.query_id,
|
|
447
|
+
"name": q.definition.name,
|
|
448
|
+
"description": q.definition.description,
|
|
449
|
+
"subscribers": len(q.get_subscribers()),
|
|
450
|
+
"last_execution": q.last_execution.isoformat()
|
|
451
|
+
if q.last_execution
|
|
452
|
+
else None,
|
|
453
|
+
}
|
|
454
|
+
for q in self.queries.values()
|
|
455
|
+
]
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Reactive Query API Routes - WebSocket and HTTP Endpoints
|
|
3
|
+
|
|
4
|
+
Provides API endpoints for:
|
|
5
|
+
- WebSocket subscriptions to reactive queries
|
|
6
|
+
- HTTP endpoints for query management
|
|
7
|
+
- Query listing and metadata
|
|
8
|
+
|
|
9
|
+
Example WebSocket usage:
|
|
10
|
+
const ws = new WebSocket('ws://localhost:8000/ws/query/features_by_status');
|
|
11
|
+
ws.onmessage = (event) => {
|
|
12
|
+
const result = JSON.parse(event.data);
|
|
13
|
+
// result contains: { type: "query_state", query_id, rows, row_count, ... }
|
|
14
|
+
updateDashboard(result);
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
Example HTTP usage:
|
|
18
|
+
GET /api/query/features_by_status
|
|
19
|
+
Returns: { query_id, timestamp, rows, row_count, execution_time_ms }
|
|
20
|
+
|
|
21
|
+
GET /api/queries
|
|
22
|
+
Returns: { queries: [...] }
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
import logging
|
|
26
|
+
import uuid
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
# Global reactive query manager instance (set by main app)
|
|
34
|
+
reactive_query_manager: Any = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def set_reactive_query_manager(manager: Any) -> None:
|
|
38
|
+
"""Set global reactive query manager instance."""
|
|
39
|
+
global reactive_query_manager
|
|
40
|
+
reactive_query_manager = manager
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def create_reactive_routes() -> APIRouter:
|
|
44
|
+
"""Create FastAPI router for reactive query endpoints."""
|
|
45
|
+
router = APIRouter(prefix="/api", tags=["reactive-queries"])
|
|
46
|
+
|
|
47
|
+
@router.websocket("/ws/query/{query_id}")
|
|
48
|
+
async def websocket_query_endpoint(websocket: WebSocket, query_id: str) -> None:
|
|
49
|
+
"""
|
|
50
|
+
WebSocket endpoint for reactive query subscriptions.
|
|
51
|
+
|
|
52
|
+
Client receives:
|
|
53
|
+
- Initial query result on connection
|
|
54
|
+
- Updates whenever query result changes
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
websocket: FastAPI WebSocket connection
|
|
58
|
+
query_id: Query to subscribe to
|
|
59
|
+
"""
|
|
60
|
+
if reactive_query_manager is None:
|
|
61
|
+
logger.error("ReactiveQueryManager not initialized")
|
|
62
|
+
await websocket.close(code=1011) # Internal error
|
|
63
|
+
return
|
|
64
|
+
|
|
65
|
+
client_id = str(uuid.uuid4())
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
await websocket.accept()
|
|
69
|
+
|
|
70
|
+
# Subscribe to query
|
|
71
|
+
initial_result = await reactive_query_manager.subscribe_to_query(
|
|
72
|
+
query_id, client_id
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
if initial_result is None:
|
|
76
|
+
logger.warning(f"Query not found: {query_id}")
|
|
77
|
+
await websocket.close(code=1008) # Policy violation (not found)
|
|
78
|
+
return
|
|
79
|
+
|
|
80
|
+
# Send initial result
|
|
81
|
+
await websocket.send_json(
|
|
82
|
+
{
|
|
83
|
+
"type": "query_state",
|
|
84
|
+
**initial_result.to_dict(),
|
|
85
|
+
}
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
logger.info(
|
|
89
|
+
f"Client {client_id} subscribed to query {query_id}, "
|
|
90
|
+
f"sent {initial_result.row_count} rows"
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
# Keep connection open to receive updates
|
|
94
|
+
# Updates are pushed from server via broadcast
|
|
95
|
+
while True:
|
|
96
|
+
try:
|
|
97
|
+
# Receive heartbeat/ping from client
|
|
98
|
+
data = await websocket.receive_text()
|
|
99
|
+
|
|
100
|
+
if data == "ping":
|
|
101
|
+
await websocket.send_json({"type": "pong"})
|
|
102
|
+
else:
|
|
103
|
+
logger.debug(f"Received message from {client_id}: {data}")
|
|
104
|
+
|
|
105
|
+
except WebSocketDisconnect:
|
|
106
|
+
break
|
|
107
|
+
except Exception as e:
|
|
108
|
+
logger.error(f"Error handling message from {client_id}: {e}")
|
|
109
|
+
break
|
|
110
|
+
|
|
111
|
+
except Exception as e:
|
|
112
|
+
logger.error(f"WebSocket error for query {query_id}: {e}")
|
|
113
|
+
|
|
114
|
+
finally:
|
|
115
|
+
# Cleanup
|
|
116
|
+
reactive_query_manager.unsubscribe_from_query(query_id, client_id)
|
|
117
|
+
logger.info(f"Client {client_id} unsubscribed from query {query_id}")
|
|
118
|
+
|
|
119
|
+
@router.get("/query/{query_id}")
|
|
120
|
+
async def get_query_result(query_id: str) -> dict[str, Any]:
|
|
121
|
+
"""
|
|
122
|
+
Get current result of a query (HTTP endpoint).
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
query_id: Query to retrieve
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
Query result with rows and metadata
|
|
129
|
+
|
|
130
|
+
Raises:
|
|
131
|
+
HTTPException: If query not found
|
|
132
|
+
"""
|
|
133
|
+
if reactive_query_manager is None:
|
|
134
|
+
raise HTTPException(
|
|
135
|
+
status_code=500, detail="ReactiveQueryManager not initialized"
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
result = await reactive_query_manager.get_query_result(query_id)
|
|
139
|
+
|
|
140
|
+
if result is None:
|
|
141
|
+
raise HTTPException(status_code=404, detail=f"Query not found: {query_id}")
|
|
142
|
+
|
|
143
|
+
result_dict: dict[str, Any] = result.to_dict()
|
|
144
|
+
return result_dict
|
|
145
|
+
|
|
146
|
+
@router.get("/queries")
|
|
147
|
+
async def list_queries() -> dict[str, Any]:
|
|
148
|
+
"""
|
|
149
|
+
List all registered queries.
|
|
150
|
+
|
|
151
|
+
Returns:
|
|
152
|
+
Dictionary with list of queries and metadata
|
|
153
|
+
"""
|
|
154
|
+
if reactive_query_manager is None:
|
|
155
|
+
raise HTTPException(
|
|
156
|
+
status_code=500, detail="ReactiveQueryManager not initialized"
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
queries = reactive_query_manager.list_queries()
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
"queries": queries,
|
|
163
|
+
"count": len(queries),
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
@router.post("/query/{query_id}/invalidate")
|
|
167
|
+
async def invalidate_query(query_id: str) -> dict[str, Any]:
|
|
168
|
+
"""
|
|
169
|
+
Manually invalidate a query (force re-execution).
|
|
170
|
+
|
|
171
|
+
Args:
|
|
172
|
+
query_id: Query to invalidate
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
Success message
|
|
176
|
+
|
|
177
|
+
Raises:
|
|
178
|
+
HTTPException: If query not found
|
|
179
|
+
"""
|
|
180
|
+
if reactive_query_manager is None:
|
|
181
|
+
raise HTTPException(
|
|
182
|
+
status_code=500, detail="ReactiveQueryManager not initialized"
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
if query_id not in reactive_query_manager.queries:
|
|
186
|
+
raise HTTPException(status_code=404, detail=f"Query not found: {query_id}")
|
|
187
|
+
|
|
188
|
+
await reactive_query_manager.invalidate_query(query_id)
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
"status": "success",
|
|
192
|
+
"message": f"Query {query_id} invalidated",
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return router
|