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,450 @@
1
+ document.addEventListener("alpine:init", () => {
2
+ Alpine.data("dashboard", () => ({
3
+ tab: "overview",
4
+ theme: localStorage.getItem("bq-theme") || "dark",
5
+ connected: false,
6
+ ws: null,
7
+ jobsWS: null,
8
+ jobsWSConnected: false,
9
+ sidebarCollapsed: false,
10
+ sidebarMobileOpen: false,
11
+
12
+ totals: { pending: 0, processing: 0, completed: 0, failed: 0, queues: 0, total: 0 },
13
+ rates: { pending_per_min: 0, processing_per_min: 0, completed_per_min: 0, failed_per_min: 0 },
14
+ queues: [],
15
+ supervisors: [],
16
+ recentJobs: [],
17
+
18
+ jobs: [],
19
+ jobsPage: 1,
20
+ jobsTotal: 0,
21
+ jobsFilter: { queue: "", status: "", tag: "" },
22
+ selectedJob: null,
23
+
24
+ dateFrom: "",
25
+ dateTo: "",
26
+
27
+ refreshTimer: null,
28
+
29
+ get pageTitle() {
30
+ const titles = { overview: "Overview", jobs: "Jobs", queues: "Queues", workers: "Workers" };
31
+ return titles[this.tab] || "Dashboard";
32
+ },
33
+
34
+ init() {
35
+ document.documentElement.setAttribute("data-theme", this.theme);
36
+ this.fetchOverview();
37
+ this.fetchSupervisors();
38
+ this.fetchRecentJobs();
39
+ this.connectWS();
40
+ this.refreshTimer = setInterval(() => {
41
+ if (this.tab === "overview") {
42
+ this.fetchOverview();
43
+ this.fetchRecentJobs();
44
+ }
45
+ if (this.tab === "jobs" && !this.jobsWSConnected) this.fetchJobs();
46
+ if (this.tab === "workers") this.fetchSupervisors();
47
+ }, 3000);
48
+ },
49
+
50
+ destroy() {
51
+ if (this.refreshTimer) clearInterval(this.refreshTimer);
52
+ if (this.ws) this.ws.close();
53
+ this.disconnectJobsWS();
54
+ },
55
+
56
+ toggleTheme() {
57
+ this.theme = this.theme === "dark" ? "light" : "dark";
58
+ localStorage.setItem("bq-theme", this.theme);
59
+ document.documentElement.setAttribute("data-theme", this.theme);
60
+ },
61
+
62
+ switchTab(t) {
63
+ const prev = this.tab;
64
+ this.tab = t;
65
+ this.sidebarMobileOpen = false;
66
+ if (t === "jobs") {
67
+ this.fetchJobs();
68
+ this.connectJobsWS();
69
+ } else if (prev === "jobs") {
70
+ this.disconnectJobsWS();
71
+ }
72
+ if (t === "overview") {
73
+ this.fetchOverview();
74
+ this.fetchRecentJobs();
75
+ } else if (t === "workers") {
76
+ this.fetchSupervisors();
77
+ }
78
+ },
79
+
80
+ // ── Date Filters ────────────────────────────────────────
81
+
82
+ getDateParams() {
83
+ const params = {};
84
+ if (this.dateFrom) {
85
+ params.created_from = new Date(this.dateFrom).getTime() / 1000;
86
+ }
87
+ if (this.dateTo) {
88
+ params.created_to = new Date(this.dateTo).getTime() / 1000;
89
+ }
90
+ return params;
91
+ },
92
+
93
+ onDateFilterChange() {
94
+ this.fetchOverview();
95
+ this.fetchRecentJobs();
96
+ if (this.tab === "jobs") {
97
+ this.jobsPage = 1;
98
+ this.fetchJobs();
99
+ }
100
+ },
101
+
102
+ clearDateFilter() {
103
+ this.dateFrom = "";
104
+ this.dateTo = "";
105
+ this.onDateFilterChange();
106
+ },
107
+
108
+ setPreset(preset) {
109
+ const now = new Date();
110
+ let from = new Date();
111
+
112
+ if (preset === "1h") from.setHours(now.getHours() - 1);
113
+ else if (preset === "24h") from.setDate(now.getDate() - 1);
114
+ else if (preset === "7d") from.setDate(now.getDate() - 7);
115
+ else if (preset === "30d") from.setDate(now.getDate() - 30);
116
+
117
+ this.dateFrom = this._toLocalISOString(from);
118
+ this.dateTo = this._toLocalISOString(now);
119
+ this.onDateFilterChange();
120
+ },
121
+
122
+ _toLocalISOString(d) {
123
+ const pad = (n) => String(n).padStart(2, "0");
124
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
125
+ },
126
+
127
+ // ── WebSocket ───────────────────────────────────────────
128
+
129
+ connectWS() {
130
+ const proto = location.protocol === "https:" ? "wss:" : "ws:";
131
+ const url = `${proto}//${location.host}/ws`;
132
+ try {
133
+ this.ws = new WebSocket(url);
134
+ this.ws.onopen = () => { this.connected = true; };
135
+ this.ws.onclose = () => {
136
+ this.connected = false;
137
+ setTimeout(() => this.connectWS(), 3000);
138
+ };
139
+ this.ws.onmessage = (e) => {
140
+ const data = JSON.parse(e.data);
141
+ if (!this.dateFrom && !this.dateTo) {
142
+ this.totals = data.totals || this.totals;
143
+ this.queues = data.queues || this.queues;
144
+ }
145
+ if (data.rates) this.rates = data.rates;
146
+ this.supervisors = data.supervisors || this.supervisors;
147
+ };
148
+ } catch (err) {
149
+ setTimeout(() => this.connectWS(), 3000);
150
+ }
151
+ },
152
+
153
+ // ── Jobs WebSocket ──────────────────────────────────────
154
+
155
+ connectJobsWS() {
156
+ if (this.jobsWS) return;
157
+ const proto = location.protocol === "https:" ? "wss:" : "ws:";
158
+ const url = `${proto}//${location.host}/ws/jobs`;
159
+ try {
160
+ const ws = new WebSocket(url);
161
+ this.jobsWS = ws;
162
+ ws.onopen = () => {
163
+ this.jobsWSConnected = true;
164
+ this._sendJobsSubscription();
165
+ };
166
+ ws.onmessage = (e) => {
167
+ try {
168
+ const data = JSON.parse(e.data);
169
+ this._mergeJobs(data.jobs || []);
170
+ this.jobsTotal = data.total || 0;
171
+ } catch (_err) { /* ignore */ }
172
+ };
173
+ ws.onclose = () => {
174
+ this.jobsWSConnected = false;
175
+ this.jobsWS = null;
176
+ // If user is still on the jobs tab, reconnect after a short delay.
177
+ if (this.tab === "jobs") setTimeout(() => this.connectJobsWS(), 2000);
178
+ };
179
+ } catch (_err) { /* ignore */ }
180
+ },
181
+
182
+ disconnectJobsWS() {
183
+ this.jobsWSConnected = false;
184
+ if (this.jobsWS) {
185
+ const ws = this.jobsWS;
186
+ this.jobsWS = null;
187
+ ws.onclose = null;
188
+ try { ws.close(); } catch (_e) { /* ignore */ }
189
+ }
190
+ },
191
+
192
+ _sendJobsSubscription() {
193
+ if (!this.jobsWS || this.jobsWS.readyState !== WebSocket.OPEN) return;
194
+ const dp = this.getDateParams();
195
+ this.jobsWS.send(JSON.stringify({
196
+ type: "subscribe",
197
+ filters: {
198
+ queue: this.jobsFilter.queue || null,
199
+ status: this.jobsFilter.status || null,
200
+ tag: this.jobsFilter.tag || null,
201
+ page: this.jobsPage,
202
+ per_page: 25,
203
+ created_from: dp.created_from || null,
204
+ created_to: dp.created_to || null,
205
+ },
206
+ }));
207
+ },
208
+
209
+ _mergeJobs(newList) {
210
+ const oldMap = new Map((this.jobs || []).map((j) => [j.id, j]));
211
+ const now = Date.now();
212
+ this.jobs = newList.map((j) => {
213
+ const old = oldMap.get(j.id);
214
+ if (!old) return { ...j, _entered: now };
215
+ if (
216
+ old.status !== j.status ||
217
+ old.attempts !== j.attempts ||
218
+ old.updated_at !== j.updated_at
219
+ ) {
220
+ return { ...j, _flash: now };
221
+ }
222
+ return j;
223
+ });
224
+ },
225
+
226
+ // ── API calls ───────────────────────────────────────────
227
+
228
+ async fetchOverview() {
229
+ try {
230
+ const dp = this.getDateParams();
231
+ if (dp.created_from || dp.created_to) {
232
+ const params = new URLSearchParams();
233
+ if (dp.created_from) params.set("created_from", dp.created_from);
234
+ if (dp.created_to) params.set("created_to", dp.created_to);
235
+ const r = await fetch(`/api/stats?${params}`);
236
+ const data = await r.json();
237
+ this.totals = {
238
+ pending: data.statuses?.pending || 0,
239
+ processing: data.statuses?.processing || 0,
240
+ completed: data.statuses?.completed || 0,
241
+ failed: data.statuses?.failed || 0,
242
+ total: data.total || 0,
243
+ queues: Object.keys(data.queues || {}).length,
244
+ };
245
+ this.queues = Object.entries(data.queues || {}).map(([name, s]) => ({
246
+ name,
247
+ pending: s.pending || 0,
248
+ processing: s.processing || 0,
249
+ completed: s.completed || 0,
250
+ failed: s.failed || 0,
251
+ }));
252
+ } else {
253
+ const r = await fetch("/api/overview");
254
+ const data = await r.json();
255
+ this.totals = data.totals || this.totals;
256
+ this.queues = data.queues || this.queues;
257
+ this.supervisors = data.supervisors || this.supervisors;
258
+ if (data.rates) this.rates = data.rates;
259
+ }
260
+ } catch (e) { /* ignore */ }
261
+ },
262
+
263
+ async fetchRecentJobs() {
264
+ try {
265
+ const params = new URLSearchParams({ per_page: "10", page: "1" });
266
+ const dp = this.getDateParams();
267
+ if (dp.created_from) params.set("created_from", dp.created_from);
268
+ if (dp.created_to) params.set("created_to", dp.created_to);
269
+ const r = await fetch(`/api/jobs?${params}`);
270
+ const data = await r.json();
271
+ this.recentJobs = data.jobs || [];
272
+ } catch (e) { /* ignore */ }
273
+ },
274
+
275
+ async fetchSupervisors() {
276
+ try {
277
+ const r = await fetch("/api/supervisors");
278
+ const data = await r.json();
279
+ this.supervisors = data.supervisors || [];
280
+ } catch (e) { /* ignore */ }
281
+ },
282
+
283
+ async fetchJobs() {
284
+ // When the live WS is connected, just resubscribe with current filters —
285
+ // the server pushes the snapshot back on the same channel.
286
+ if (this.jobsWSConnected) {
287
+ this._sendJobsSubscription();
288
+ return;
289
+ }
290
+ try {
291
+ const params = new URLSearchParams();
292
+ if (this.jobsFilter.queue) params.set("queue", this.jobsFilter.queue);
293
+ if (this.jobsFilter.status) params.set("status", this.jobsFilter.status);
294
+ if (this.jobsFilter.tag) params.set("tag", this.jobsFilter.tag);
295
+ params.set("page", this.jobsPage);
296
+ params.set("per_page", "25");
297
+
298
+ const dp = this.getDateParams();
299
+ if (dp.created_from) params.set("created_from", dp.created_from);
300
+ if (dp.created_to) params.set("created_to", dp.created_to);
301
+
302
+ const r = await fetch(`/api/jobs?${params}`);
303
+ const data = await r.json();
304
+ this._mergeJobs(data.jobs || []);
305
+ this.jobsTotal = data.total || 0;
306
+ } catch (e) { /* ignore */ }
307
+ },
308
+
309
+ async viewJob(jobId) {
310
+ try {
311
+ const r = await fetch(`/api/jobs/${jobId}`);
312
+ this.selectedJob = await r.json();
313
+ } catch (e) { /* ignore */ }
314
+ },
315
+
316
+ closeModal() { this.selectedJob = null; },
317
+
318
+ async retryJob(jobId) {
319
+ await fetch(`/api/jobs/${jobId}/retry`, { method: "POST" });
320
+ this.closeModal();
321
+ this.fetchJobs();
322
+ this.fetchOverview();
323
+ },
324
+
325
+ async retryAllFailed() {
326
+ const parts = [];
327
+ if (this.jobsFilter.queue) parts.push(`queue "${this.jobsFilter.queue}"`);
328
+ if (this.jobsFilter.tag) parts.push(`tag "${this.jobsFilter.tag}"`);
329
+ const scope = parts.length ? ` (${parts.join(", ")})` : "";
330
+ if (!confirm(`Retry ${this.jobsTotal} failed job(s)${scope}?`)) return;
331
+ try {
332
+ const params = new URLSearchParams();
333
+ if (this.jobsFilter.queue) params.set("queue", this.jobsFilter.queue);
334
+ if (this.jobsFilter.tag) params.set("tag", this.jobsFilter.tag);
335
+ const dp = this.getDateParams();
336
+ if (dp.created_from) params.set("created_from", dp.created_from);
337
+ if (dp.created_to) params.set("created_to", dp.created_to);
338
+ await fetch(`/api/jobs/retry-failed?${params}`, { method: "POST" });
339
+ this.fetchJobs();
340
+ this.fetchOverview();
341
+ } catch (_e) { /* ignore */ }
342
+ },
343
+
344
+ async deleteJob(jobId) {
345
+ await fetch(`/api/jobs/${jobId}`, { method: "DELETE" });
346
+ this.closeModal();
347
+ this.fetchJobs();
348
+ this.fetchOverview();
349
+ },
350
+
351
+ prevPage() {
352
+ if (this.jobsPage > 1) { this.jobsPage--; this.fetchJobs(); }
353
+ },
354
+
355
+ nextPage() {
356
+ const maxPage = Math.ceil(this.jobsTotal / 25);
357
+ if (this.jobsPage < maxPage) { this.jobsPage++; this.fetchJobs(); }
358
+ },
359
+
360
+ // ── Helpers ─────────────────────────────────────────────
361
+
362
+ queueTotal(q) {
363
+ return (q.pending || 0) + (q.processing || 0) + (q.completed || 0) + (q.failed || 0);
364
+ },
365
+
366
+ queuePct(q, key) {
367
+ const total = this.queueTotal(q);
368
+ if (total === 0) return "0%";
369
+ return ((q[key] || 0) / total * 100).toFixed(1) + "%";
370
+ },
371
+
372
+ pctOf(value, total) {
373
+ if (!total || total === 0) return "0%";
374
+ return Math.min((value / total) * 100, 100).toFixed(1) + "%";
375
+ },
376
+
377
+ formatNumber(n) {
378
+ if (n === undefined || n === null) return "0";
379
+ if (n >= 1000000) return (n / 1000000).toFixed(1) + "M";
380
+ if (n >= 1000) return (n / 1000).toFixed(1) + "K";
381
+ return String(n);
382
+ },
383
+
384
+ formatTime(ts) {
385
+ if (!ts) return "-";
386
+ const d = new Date(ts * 1000);
387
+ return d.toLocaleString(undefined, {
388
+ month: "short", day: "numeric",
389
+ hour: "2-digit", minute: "2-digit", second: "2-digit"
390
+ });
391
+ },
392
+
393
+ formatTimeFull(ts) {
394
+ if (!ts) return "-";
395
+ const d = new Date(ts * 1000);
396
+ return d.toLocaleString(undefined, {
397
+ year: "numeric", month: "short", day: "numeric",
398
+ hour: "2-digit", minute: "2-digit", second: "2-digit",
399
+ fractionalSecondDigits: 3,
400
+ });
401
+ },
402
+
403
+ timeAgo(ts) {
404
+ if (!ts) return "-";
405
+ const now = Date.now() / 1000;
406
+ const diff = now - ts;
407
+ if (diff < 5) return "just now";
408
+ if (diff < 60) return Math.floor(diff) + "s ago";
409
+ if (diff < 3600) return Math.floor(diff / 60) + "m ago";
410
+ if (diff < 86400) return Math.floor(diff / 3600) + "h ago";
411
+ return Math.floor(diff / 86400) + "d ago";
412
+ },
413
+
414
+ isScheduled(job) {
415
+ if (!job || !job.delay_until) return false;
416
+ if (job.status !== "pending") return false;
417
+ return job.delay_until * 1000 > Date.now();
418
+ },
419
+
420
+ scheduledIn(ts) {
421
+ if (!ts) return "";
422
+ const diff = ts - Date.now() / 1000;
423
+ if (diff <= 0) return "now";
424
+ if (diff < 60) return `in ${Math.ceil(diff)}s`;
425
+ if (diff < 3600) return `in ${Math.ceil(diff / 60)}m`;
426
+ if (diff < 86400) return `in ${Math.ceil(diff / 3600)}h`;
427
+ return `in ${Math.ceil(diff / 86400)}d`;
428
+ },
429
+
430
+ jobDuration(job) {
431
+ if (!job.started_at) return "-";
432
+ const end = job.completed_at || job.failed_at || (Date.now() / 1000);
433
+ const diff = end - job.started_at;
434
+ if (diff < 0.001) return "<1ms";
435
+ if (diff < 1) return Math.round(diff * 1000) + "ms";
436
+ if (diff < 60) return diff.toFixed(1) + "s";
437
+ return Math.floor(diff / 60) + "m " + Math.floor(diff % 60) + "s";
438
+ },
439
+
440
+ shortId(id) {
441
+ return id ? id.substring(0, 12) : "-";
442
+ },
443
+
444
+ shortClass(cls) {
445
+ if (!cls) return "-";
446
+ const parts = cls.split(".");
447
+ return parts[parts.length - 1];
448
+ },
449
+ }));
450
+ });