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.
@@ -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