baqueue 0.1.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.
- baqueue/__init__.py +19 -0
- baqueue/balancer.py +108 -0
- baqueue/batch.py +159 -0
- baqueue/cli.py +459 -0
- baqueue/config.py +79 -0
- baqueue/dashboard/__init__.py +1 -0
- baqueue/dashboard/api.py +193 -0
- baqueue/dashboard/server.py +263 -0
- baqueue/dashboard/static/app.js +450 -0
- baqueue/dashboard/static/index.html +580 -0
- baqueue/dashboard/static/style.css +1415 -0
- baqueue/drivers/__init__.py +1 -0
- baqueue/drivers/base.py +212 -0
- baqueue/drivers/memory_driver.py +318 -0
- baqueue/drivers/postgres_driver.py +656 -0
- baqueue/drivers/redis_driver.py +656 -0
- baqueue/drivers/sqlite_driver.py +706 -0
- baqueue/events.py +64 -0
- baqueue/job.py +128 -0
- baqueue/pruner.py +128 -0
- baqueue/queue.py +225 -0
- baqueue/retry.py +55 -0
- baqueue/scheduler.py +101 -0
- baqueue/serializer.py +124 -0
- baqueue/supervisor.py +206 -0
- baqueue/worker.py +165 -0
- baqueue-0.1.0.dist-info/METADATA +609 -0
- baqueue-0.1.0.dist-info/RECORD +32 -0
- baqueue-0.1.0.dist-info/WHEEL +5 -0
- baqueue-0.1.0.dist-info/entry_points.txt +2 -0
- baqueue-0.1.0.dist-info/licenses/LICENSE +21 -0
- baqueue-0.1.0.dist-info/top_level.txt +1 -0
baqueue/dashboard/api.py
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""REST API endpoints for the BaQueue dashboard."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from baqueue.drivers.base import BaseDriver
|
|
8
|
+
from baqueue.serializer import _now_ts
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class DashboardAPI:
|
|
12
|
+
"""Provides data for the dashboard REST endpoints."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, driver: BaseDriver):
|
|
15
|
+
self.driver = driver
|
|
16
|
+
self._supervisor_stats: list[dict[str, Any]] = []
|
|
17
|
+
|
|
18
|
+
def set_supervisor_stats(self, stats: list[dict[str, Any]]) -> None:
|
|
19
|
+
self._supervisor_stats = stats
|
|
20
|
+
|
|
21
|
+
async def overview(self) -> dict[str, Any]:
|
|
22
|
+
queues = await self.driver.queues()
|
|
23
|
+
metrics = await self.driver.get_metrics()
|
|
24
|
+
now = _now_ts()
|
|
25
|
+
rate_window_from = now - 60
|
|
26
|
+
|
|
27
|
+
total_pending = 0
|
|
28
|
+
total_processing = 0
|
|
29
|
+
total_completed = 0
|
|
30
|
+
total_failed = 0
|
|
31
|
+
queue_details = []
|
|
32
|
+
|
|
33
|
+
for q in queues:
|
|
34
|
+
m = metrics.get(q, {})
|
|
35
|
+
pending = m.get("pending", 0)
|
|
36
|
+
processing = m.get("processing", 0)
|
|
37
|
+
completed = m.get("completed", 0)
|
|
38
|
+
failed = m.get("failed", 0)
|
|
39
|
+
|
|
40
|
+
total_pending += pending
|
|
41
|
+
total_processing += processing
|
|
42
|
+
total_completed += completed
|
|
43
|
+
total_failed += failed
|
|
44
|
+
|
|
45
|
+
q_pending_rate = await self.driver.count_jobs(queue=q, created_from=rate_window_from)
|
|
46
|
+
q_throughput = await self.driver.recent_throughput(seconds=60, queue=q)
|
|
47
|
+
|
|
48
|
+
queue_details.append({
|
|
49
|
+
"name": q,
|
|
50
|
+
"pending": pending,
|
|
51
|
+
"processing": processing,
|
|
52
|
+
"completed": completed,
|
|
53
|
+
"failed": failed,
|
|
54
|
+
"rates": {
|
|
55
|
+
"pending_per_min": q_pending_rate,
|
|
56
|
+
"processing_per_min": q_throughput.get("processing", 0),
|
|
57
|
+
"completed_per_min": q_throughput.get("completed", 0),
|
|
58
|
+
"failed_per_min": q_throughput.get("failed", 0),
|
|
59
|
+
},
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
dispatch_rate = await self.driver.count_jobs(created_from=rate_window_from)
|
|
63
|
+
throughput = await self.driver.recent_throughput(seconds=60)
|
|
64
|
+
supervisors = await self.driver.get_supervisor_stats()
|
|
65
|
+
if not supervisors:
|
|
66
|
+
supervisors = self._supervisor_stats
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
"totals": {
|
|
70
|
+
"pending": total_pending,
|
|
71
|
+
"processing": total_processing,
|
|
72
|
+
"completed": total_completed,
|
|
73
|
+
"failed": total_failed,
|
|
74
|
+
"queues": len(queues),
|
|
75
|
+
"total": total_pending + total_processing + total_completed + total_failed,
|
|
76
|
+
},
|
|
77
|
+
"rates": {
|
|
78
|
+
"pending_per_min": dispatch_rate,
|
|
79
|
+
"processing_per_min": throughput.get("processing", 0),
|
|
80
|
+
"completed_per_min": throughput.get("completed", 0),
|
|
81
|
+
"failed_per_min": throughput.get("failed", 0),
|
|
82
|
+
},
|
|
83
|
+
"queues": queue_details,
|
|
84
|
+
"supervisors": supervisors,
|
|
85
|
+
"timestamp": now,
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async def queue_detail(self, queue: str) -> dict[str, Any]:
|
|
89
|
+
metrics = await self.driver.get_metrics(queue)
|
|
90
|
+
size = await self.driver.size(queue)
|
|
91
|
+
m = metrics.get(queue, {})
|
|
92
|
+
return {
|
|
93
|
+
"name": queue,
|
|
94
|
+
"size": size,
|
|
95
|
+
"metrics": m,
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async def jobs_list(
|
|
99
|
+
self,
|
|
100
|
+
queue: str | None = None,
|
|
101
|
+
status: str | None = None,
|
|
102
|
+
tag: str | None = None,
|
|
103
|
+
batch_id: str | None = None,
|
|
104
|
+
page: int = 1,
|
|
105
|
+
per_page: int = 25,
|
|
106
|
+
created_from: float | None = None,
|
|
107
|
+
created_to: float | None = None,
|
|
108
|
+
) -> dict[str, Any]:
|
|
109
|
+
offset = (page - 1) * per_page
|
|
110
|
+
jobs = await self.driver.get_jobs(
|
|
111
|
+
queue=queue, status=status, tag=tag, batch_id=batch_id,
|
|
112
|
+
offset=offset, limit=per_page,
|
|
113
|
+
created_from=created_from, created_to=created_to,
|
|
114
|
+
)
|
|
115
|
+
total = await self.driver.count_jobs(
|
|
116
|
+
queue=queue, status=status,
|
|
117
|
+
created_from=created_from, created_to=created_to,
|
|
118
|
+
)
|
|
119
|
+
return {
|
|
120
|
+
"jobs": [j.to_dict() for j in jobs],
|
|
121
|
+
"page": page,
|
|
122
|
+
"per_page": per_page,
|
|
123
|
+
"count": len(jobs),
|
|
124
|
+
"total": total,
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async def job_detail(self, job_id: str) -> dict[str, Any] | None:
|
|
128
|
+
job = await self.driver.get_job(job_id)
|
|
129
|
+
return job.to_dict() if job else None
|
|
130
|
+
|
|
131
|
+
async def retry_job(self, job_id: str) -> bool:
|
|
132
|
+
job = await self.driver.get_job(job_id)
|
|
133
|
+
if not job or job.status != "failed":
|
|
134
|
+
return False
|
|
135
|
+
await self.driver.release(job, delay=0)
|
|
136
|
+
return True
|
|
137
|
+
|
|
138
|
+
async def retry_failed_jobs(
|
|
139
|
+
self,
|
|
140
|
+
queue: str | None = None,
|
|
141
|
+
tag: str | None = None,
|
|
142
|
+
created_from: float | None = None,
|
|
143
|
+
created_to: float | None = None,
|
|
144
|
+
) -> int:
|
|
145
|
+
"""Retry all failed jobs matching the optional filters. Returns count."""
|
|
146
|
+
from baqueue.queue import Queue
|
|
147
|
+
return await Queue.retry_failed(
|
|
148
|
+
queue=queue, tag=tag,
|
|
149
|
+
created_from=created_from, created_to=created_to,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
async def delete_job(self, job_id: str) -> bool:
|
|
153
|
+
await self.driver.delete(job_id)
|
|
154
|
+
return True
|
|
155
|
+
|
|
156
|
+
async def batch_detail(self, batch_id: str) -> dict[str, Any] | None:
|
|
157
|
+
return await self.driver.get_batch(batch_id)
|
|
158
|
+
|
|
159
|
+
async def prune_jobs(
|
|
160
|
+
self,
|
|
161
|
+
status: str | None = None,
|
|
162
|
+
tag: str | None = None,
|
|
163
|
+
hours: float | None = None,
|
|
164
|
+
) -> int:
|
|
165
|
+
older_than = hours * 3600 if hours else None
|
|
166
|
+
return await self.driver.prune(status=status, tag=tag, older_than_seconds=older_than)
|
|
167
|
+
|
|
168
|
+
async def metrics_snapshot(self) -> dict[str, Any]:
|
|
169
|
+
return await self.driver.get_metrics()
|
|
170
|
+
|
|
171
|
+
async def supervisors_snapshot(self) -> list[dict[str, Any]]:
|
|
172
|
+
data = await self.driver.get_supervisor_stats()
|
|
173
|
+
if data:
|
|
174
|
+
return data
|
|
175
|
+
return self._supervisor_stats
|
|
176
|
+
|
|
177
|
+
async def stats(self, created_from: float | None = None, created_to: float | None = None) -> dict[str, Any]:
|
|
178
|
+
"""Aggregate statistics for the stats panel."""
|
|
179
|
+
queues = await self.driver.queues()
|
|
180
|
+
result: dict[str, Any] = {"queues": {}, "statuses": {}}
|
|
181
|
+
|
|
182
|
+
for st in ("pending", "processing", "completed", "failed"):
|
|
183
|
+
result["statuses"][st] = await self.driver.count_jobs(status=st, created_from=created_from, created_to=created_to)
|
|
184
|
+
|
|
185
|
+
for q in queues:
|
|
186
|
+
q_stats: dict[str, int] = {}
|
|
187
|
+
for st in ("pending", "processing", "completed", "failed"):
|
|
188
|
+
q_stats[st] = await self.driver.count_jobs(queue=q, status=st, created_from=created_from, created_to=created_to)
|
|
189
|
+
q_stats["total"] = sum(q_stats.values())
|
|
190
|
+
result["queues"][q] = q_stats
|
|
191
|
+
|
|
192
|
+
result["total"] = sum(result["statuses"].values())
|
|
193
|
+
return result
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""FastAPI server for the BaQueue monitoring dashboard.
|
|
2
|
+
|
|
3
|
+
NOTE: Do NOT use `from __future__ import annotations` in this module.
|
|
4
|
+
FastAPI relies on runtime type inspection for WebSocket parameter resolution,
|
|
5
|
+
which breaks with PEP 563 deferred annotations.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import logging
|
|
10
|
+
from contextlib import asynccontextmanager
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Optional
|
|
13
|
+
|
|
14
|
+
from baqueue.config import BaQueueConfig
|
|
15
|
+
from baqueue.dashboard.api import DashboardAPI
|
|
16
|
+
from baqueue.drivers.base import BaseDriver
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger("baqueue.dashboard")
|
|
19
|
+
|
|
20
|
+
STATIC_DIR = Path(__file__).parent / "static"
|
|
21
|
+
|
|
22
|
+
OVERVIEW_BROADCAST_INTERVAL = 2.0
|
|
23
|
+
JOBS_PUSH_INTERVAL = 1.5
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def create_app(driver: BaseDriver, config: Optional[BaQueueConfig] = None) -> Any:
|
|
27
|
+
"""Create and return a FastAPI application for the dashboard."""
|
|
28
|
+
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Query
|
|
29
|
+
from fastapi.responses import JSONResponse, FileResponse
|
|
30
|
+
|
|
31
|
+
api = DashboardAPI(driver)
|
|
32
|
+
connected_ws: list[WebSocket] = []
|
|
33
|
+
|
|
34
|
+
async def _broadcast_loop() -> None:
|
|
35
|
+
"""One overview() per interval, fanned out to every connected WS.
|
|
36
|
+
Replaces N independent driver queries when N tabs are open."""
|
|
37
|
+
while True:
|
|
38
|
+
try:
|
|
39
|
+
await asyncio.sleep(OVERVIEW_BROADCAST_INTERVAL)
|
|
40
|
+
if not connected_ws:
|
|
41
|
+
continue
|
|
42
|
+
data = await api.overview()
|
|
43
|
+
stale: list[WebSocket] = []
|
|
44
|
+
for ws in list(connected_ws):
|
|
45
|
+
try:
|
|
46
|
+
await ws.send_json(data)
|
|
47
|
+
except Exception:
|
|
48
|
+
stale.append(ws)
|
|
49
|
+
for ws in stale:
|
|
50
|
+
if ws in connected_ws:
|
|
51
|
+
connected_ws.remove(ws)
|
|
52
|
+
except asyncio.CancelledError:
|
|
53
|
+
raise
|
|
54
|
+
except Exception:
|
|
55
|
+
logger.exception("Error in overview broadcast loop")
|
|
56
|
+
|
|
57
|
+
@asynccontextmanager
|
|
58
|
+
async def lifespan(_app):
|
|
59
|
+
task = asyncio.create_task(_broadcast_loop())
|
|
60
|
+
try:
|
|
61
|
+
yield
|
|
62
|
+
finally:
|
|
63
|
+
task.cancel()
|
|
64
|
+
try:
|
|
65
|
+
await task
|
|
66
|
+
except (asyncio.CancelledError, Exception):
|
|
67
|
+
pass
|
|
68
|
+
|
|
69
|
+
app = FastAPI(title="BaQueue Dashboard", version="0.2.0", lifespan=lifespan)
|
|
70
|
+
|
|
71
|
+
# ── Static files ────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
@app.get("/")
|
|
74
|
+
async def index():
|
|
75
|
+
return FileResponse(STATIC_DIR / "index.html")
|
|
76
|
+
|
|
77
|
+
@app.get("/app.js")
|
|
78
|
+
async def app_js():
|
|
79
|
+
return FileResponse(STATIC_DIR / "app.js", media_type="application/javascript")
|
|
80
|
+
|
|
81
|
+
@app.get("/style.css")
|
|
82
|
+
async def app_css():
|
|
83
|
+
return FileResponse(STATIC_DIR / "style.css", media_type="text/css")
|
|
84
|
+
|
|
85
|
+
# ── REST API ────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
@app.get("/api/overview")
|
|
88
|
+
async def get_overview():
|
|
89
|
+
data = await api.overview()
|
|
90
|
+
return JSONResponse(data)
|
|
91
|
+
|
|
92
|
+
@app.get("/api/stats")
|
|
93
|
+
async def get_stats(
|
|
94
|
+
created_from: Optional[float] = Query(None),
|
|
95
|
+
created_to: Optional[float] = Query(None),
|
|
96
|
+
):
|
|
97
|
+
data = await api.stats(created_from=created_from, created_to=created_to)
|
|
98
|
+
return JSONResponse(data)
|
|
99
|
+
|
|
100
|
+
@app.get("/api/queues")
|
|
101
|
+
async def get_queues():
|
|
102
|
+
queues = await driver.queues()
|
|
103
|
+
return JSONResponse({"queues": queues})
|
|
104
|
+
|
|
105
|
+
@app.get("/api/queues/{queue}")
|
|
106
|
+
async def get_queue(queue: str):
|
|
107
|
+
data = await api.queue_detail(queue)
|
|
108
|
+
return JSONResponse(data)
|
|
109
|
+
|
|
110
|
+
@app.get("/api/jobs")
|
|
111
|
+
async def get_jobs(
|
|
112
|
+
queue: Optional[str] = Query(None),
|
|
113
|
+
status: Optional[str] = Query(None),
|
|
114
|
+
tag: Optional[str] = Query(None),
|
|
115
|
+
batch_id: Optional[str] = Query(None),
|
|
116
|
+
page: int = Query(1),
|
|
117
|
+
per_page: int = Query(25),
|
|
118
|
+
created_from: Optional[float] = Query(None),
|
|
119
|
+
created_to: Optional[float] = Query(None),
|
|
120
|
+
):
|
|
121
|
+
data = await api.jobs_list(
|
|
122
|
+
queue=queue, status=status, tag=tag,
|
|
123
|
+
batch_id=batch_id, page=page, per_page=per_page,
|
|
124
|
+
created_from=created_from, created_to=created_to,
|
|
125
|
+
)
|
|
126
|
+
return JSONResponse(data)
|
|
127
|
+
|
|
128
|
+
@app.get("/api/jobs/{job_id}")
|
|
129
|
+
async def get_job(job_id: str):
|
|
130
|
+
data = await api.job_detail(job_id)
|
|
131
|
+
if data is None:
|
|
132
|
+
return JSONResponse({"error": "Job not found"}, status_code=404)
|
|
133
|
+
return JSONResponse(data)
|
|
134
|
+
|
|
135
|
+
@app.post("/api/jobs/retry-failed")
|
|
136
|
+
async def retry_failed(
|
|
137
|
+
queue: Optional[str] = Query(None),
|
|
138
|
+
tag: Optional[str] = Query(None),
|
|
139
|
+
created_from: Optional[float] = Query(None),
|
|
140
|
+
created_to: Optional[float] = Query(None),
|
|
141
|
+
):
|
|
142
|
+
count = await api.retry_failed_jobs(
|
|
143
|
+
queue=queue, tag=tag,
|
|
144
|
+
created_from=created_from, created_to=created_to,
|
|
145
|
+
)
|
|
146
|
+
return JSONResponse({"retried": count})
|
|
147
|
+
|
|
148
|
+
@app.post("/api/jobs/{job_id}/retry")
|
|
149
|
+
async def retry_job(job_id: str):
|
|
150
|
+
ok = await api.retry_job(job_id)
|
|
151
|
+
return JSONResponse({"success": ok})
|
|
152
|
+
|
|
153
|
+
@app.delete("/api/jobs/{job_id}")
|
|
154
|
+
async def delete_job(job_id: str):
|
|
155
|
+
ok = await api.delete_job(job_id)
|
|
156
|
+
return JSONResponse({"success": ok})
|
|
157
|
+
|
|
158
|
+
@app.get("/api/batches/{batch_id}")
|
|
159
|
+
async def get_batch(batch_id: str):
|
|
160
|
+
data = await api.batch_detail(batch_id)
|
|
161
|
+
if data is None:
|
|
162
|
+
return JSONResponse({"error": "Batch not found"}, status_code=404)
|
|
163
|
+
return JSONResponse(data)
|
|
164
|
+
|
|
165
|
+
@app.post("/api/prune")
|
|
166
|
+
async def prune_jobs(
|
|
167
|
+
status: Optional[str] = Query(None),
|
|
168
|
+
tag: Optional[str] = Query(None),
|
|
169
|
+
hours: Optional[float] = Query(None),
|
|
170
|
+
):
|
|
171
|
+
count = await api.prune_jobs(status=status, tag=tag, hours=hours)
|
|
172
|
+
return JSONResponse({"pruned": count})
|
|
173
|
+
|
|
174
|
+
@app.get("/api/metrics")
|
|
175
|
+
async def get_metrics():
|
|
176
|
+
data = await api.metrics_snapshot()
|
|
177
|
+
return JSONResponse(data)
|
|
178
|
+
|
|
179
|
+
@app.get("/api/supervisors")
|
|
180
|
+
async def get_supervisors():
|
|
181
|
+
data = await api.supervisors_snapshot()
|
|
182
|
+
return JSONResponse({"supervisors": data})
|
|
183
|
+
|
|
184
|
+
# ── WebSocket ───────────────────────────────────────────────
|
|
185
|
+
|
|
186
|
+
@app.websocket("/ws")
|
|
187
|
+
async def websocket_endpoint(ws: WebSocket):
|
|
188
|
+
await ws.accept()
|
|
189
|
+
connected_ws.append(ws)
|
|
190
|
+
try:
|
|
191
|
+
data = await api.overview()
|
|
192
|
+
await ws.send_json(data)
|
|
193
|
+
while True:
|
|
194
|
+
# Keep the connection alive; the broadcaster pushes updates.
|
|
195
|
+
await ws.receive_text()
|
|
196
|
+
except WebSocketDisconnect:
|
|
197
|
+
pass
|
|
198
|
+
except Exception:
|
|
199
|
+
pass
|
|
200
|
+
finally:
|
|
201
|
+
if ws in connected_ws:
|
|
202
|
+
connected_ws.remove(ws)
|
|
203
|
+
|
|
204
|
+
@app.websocket("/ws/jobs")
|
|
205
|
+
async def jobs_websocket(ws: WebSocket):
|
|
206
|
+
"""Per-connection jobs feed. Subscriber sends a {"type":"subscribe","filters":{...}}
|
|
207
|
+
message; server pushes filtered jobs_list snapshots on a tick until disconnect."""
|
|
208
|
+
await ws.accept()
|
|
209
|
+
filters: dict[str, Any] = {
|
|
210
|
+
"queue": None, "status": None, "tag": None, "batch_id": None,
|
|
211
|
+
"page": 1, "per_page": 25,
|
|
212
|
+
"created_from": None, "created_to": None,
|
|
213
|
+
}
|
|
214
|
+
push_lock = asyncio.Lock()
|
|
215
|
+
|
|
216
|
+
async def push_snapshot() -> bool:
|
|
217
|
+
async with push_lock:
|
|
218
|
+
try:
|
|
219
|
+
data = await api.jobs_list(**filters)
|
|
220
|
+
await ws.send_json(data)
|
|
221
|
+
return True
|
|
222
|
+
except WebSocketDisconnect:
|
|
223
|
+
return False
|
|
224
|
+
except Exception:
|
|
225
|
+
logger.exception("ws/jobs push failed")
|
|
226
|
+
return True
|
|
227
|
+
|
|
228
|
+
async def push_loop() -> None:
|
|
229
|
+
while True:
|
|
230
|
+
await asyncio.sleep(JOBS_PUSH_INTERVAL)
|
|
231
|
+
if not await push_snapshot():
|
|
232
|
+
return
|
|
233
|
+
|
|
234
|
+
push_task = asyncio.create_task(push_loop())
|
|
235
|
+
try:
|
|
236
|
+
await push_snapshot()
|
|
237
|
+
while True:
|
|
238
|
+
msg = await ws.receive_json()
|
|
239
|
+
if msg.get("type") == "subscribe":
|
|
240
|
+
incoming = msg.get("filters") or {}
|
|
241
|
+
for k in filters:
|
|
242
|
+
if k not in incoming:
|
|
243
|
+
continue
|
|
244
|
+
v = incoming[k]
|
|
245
|
+
if k in ("page", "per_page"):
|
|
246
|
+
filters[k] = int(v) if v is not None else filters[k]
|
|
247
|
+
elif k in ("created_from", "created_to"):
|
|
248
|
+
filters[k] = float(v) if v not in (None, "") else None
|
|
249
|
+
else:
|
|
250
|
+
filters[k] = v if v not in (None, "") else None
|
|
251
|
+
await push_snapshot()
|
|
252
|
+
except WebSocketDisconnect:
|
|
253
|
+
pass
|
|
254
|
+
except Exception:
|
|
255
|
+
pass
|
|
256
|
+
finally:
|
|
257
|
+
push_task.cancel()
|
|
258
|
+
try:
|
|
259
|
+
await push_task
|
|
260
|
+
except (asyncio.CancelledError, Exception):
|
|
261
|
+
pass
|
|
262
|
+
|
|
263
|
+
return app
|