@getworkbench/core 0.1.0
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.
- package/LICENSE +21 -0
- package/README.md +32 -0
- package/dist/index.d.ts +665 -0
- package/dist/index.js +2047 -0
- package/dist/index.js.map +1 -0
- package/dist/ui/assets/index.css +1 -0
- package/dist/ui/assets/index.js +454 -0
- package/dist/ui/index.html +32 -0
- package/package.json +81 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,2047 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import { dirname, join } from "path";
|
|
3
|
+
import { fileURLToPath } from "url";
|
|
4
|
+
|
|
5
|
+
// src/api/router.ts
|
|
6
|
+
import { Hono } from "hono";
|
|
7
|
+
function parseSort(sort) {
|
|
8
|
+
if (!sort) return void 0;
|
|
9
|
+
const [field, dir] = sort.split(":");
|
|
10
|
+
if (!field) return void 0;
|
|
11
|
+
return {
|
|
12
|
+
field,
|
|
13
|
+
direction: dir === "asc" ? "asc" : "desc"
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
function createApiRoutes(core) {
|
|
17
|
+
const app = new Hono();
|
|
18
|
+
const qm = core.queueManager;
|
|
19
|
+
app.post("/refresh", async (c) => {
|
|
20
|
+
qm.clearCache();
|
|
21
|
+
return c.json({ success: true });
|
|
22
|
+
});
|
|
23
|
+
app.get("/overview", async (c) => {
|
|
24
|
+
const stats = await qm.getOverview();
|
|
25
|
+
return c.json(stats);
|
|
26
|
+
});
|
|
27
|
+
app.get("/counts", async (c) => {
|
|
28
|
+
const counts = await qm.getQuickCounts();
|
|
29
|
+
return c.json(counts);
|
|
30
|
+
});
|
|
31
|
+
app.get("/runs", async (c) => {
|
|
32
|
+
const limit = Number(c.req.query("limit")) || 50;
|
|
33
|
+
const cursor = c.req.query("cursor");
|
|
34
|
+
const start = cursor ? Number(cursor) : 0;
|
|
35
|
+
const sort = parseSort(c.req.query("sort"));
|
|
36
|
+
const status = c.req.query("status");
|
|
37
|
+
const q = c.req.query("q");
|
|
38
|
+
const from = c.req.query("from");
|
|
39
|
+
const to = c.req.query("to");
|
|
40
|
+
const tagsParam = c.req.query("tags");
|
|
41
|
+
let tags;
|
|
42
|
+
if (tagsParam) {
|
|
43
|
+
try {
|
|
44
|
+
tags = JSON.parse(tagsParam);
|
|
45
|
+
} catch {
|
|
46
|
+
const tagPairs = tagsParam.split(",");
|
|
47
|
+
tags = {};
|
|
48
|
+
for (const pair of tagPairs) {
|
|
49
|
+
const [key, value] = pair.split(":");
|
|
50
|
+
if (key && value) {
|
|
51
|
+
tags[key.trim()] = value.trim();
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
let timeRange;
|
|
57
|
+
if (from && to) {
|
|
58
|
+
timeRange = {
|
|
59
|
+
start: Number(from),
|
|
60
|
+
end: Number(to)
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
let text;
|
|
64
|
+
if (q) {
|
|
65
|
+
if (!q.includes(":")) {
|
|
66
|
+
text = q;
|
|
67
|
+
} else {
|
|
68
|
+
const parts = q.split(" ");
|
|
69
|
+
const textParts = parts.filter((p) => !p.includes(":"));
|
|
70
|
+
if (textParts.length > 0) {
|
|
71
|
+
text = textParts.join(" ");
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const filters = status || tags || text || timeRange ? {
|
|
76
|
+
status,
|
|
77
|
+
tags,
|
|
78
|
+
text,
|
|
79
|
+
timeRange
|
|
80
|
+
} : void 0;
|
|
81
|
+
const result = await qm.getAllRuns(limit, start, sort, filters);
|
|
82
|
+
return c.json(result);
|
|
83
|
+
});
|
|
84
|
+
app.get("/schedulers", async (c) => {
|
|
85
|
+
const repeatableSort = parseSort(c.req.query("repeatableSort"));
|
|
86
|
+
const delayedSort = parseSort(c.req.query("delayedSort"));
|
|
87
|
+
const result = await qm.getSchedulers(repeatableSort, delayedSort);
|
|
88
|
+
return c.json(result);
|
|
89
|
+
});
|
|
90
|
+
app.post("/test", async (c) => {
|
|
91
|
+
if (core.options.readonly) {
|
|
92
|
+
return c.json({ error: "Dashboard is in readonly mode" }, 403);
|
|
93
|
+
}
|
|
94
|
+
const body = await c.req.json();
|
|
95
|
+
if (!body.queueName || !body.jobName) {
|
|
96
|
+
return c.json({ error: "queueName and jobName are required" }, 400);
|
|
97
|
+
}
|
|
98
|
+
try {
|
|
99
|
+
const result = await qm.enqueueJob(body);
|
|
100
|
+
return c.json(result);
|
|
101
|
+
} catch (e) {
|
|
102
|
+
return c.json({ error: e.message }, 400);
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
app.get("/queue-names", (c) => {
|
|
106
|
+
const names = qm.getQueueNames();
|
|
107
|
+
return c.json(names);
|
|
108
|
+
});
|
|
109
|
+
app.get("/queues", async (c) => {
|
|
110
|
+
const queues = await qm.getQueues();
|
|
111
|
+
return c.json(queues);
|
|
112
|
+
});
|
|
113
|
+
app.get("/metrics", async (c) => {
|
|
114
|
+
const metrics = await qm.getMetrics();
|
|
115
|
+
return c.json(metrics);
|
|
116
|
+
});
|
|
117
|
+
app.get("/activity", async (c) => {
|
|
118
|
+
const stats = await qm.getActivityStats();
|
|
119
|
+
return c.json(stats);
|
|
120
|
+
});
|
|
121
|
+
app.get("/queues/:name/jobs", async (c) => {
|
|
122
|
+
const { name } = c.req.param();
|
|
123
|
+
const status = c.req.query("status");
|
|
124
|
+
const limit = Number(c.req.query("limit")) || 50;
|
|
125
|
+
const cursor = c.req.query("cursor");
|
|
126
|
+
const start = cursor ? Number(cursor) : 0;
|
|
127
|
+
const sort = parseSort(c.req.query("sort"));
|
|
128
|
+
const result = await qm.getJobs(name, status, limit, start, sort);
|
|
129
|
+
return c.json(result);
|
|
130
|
+
});
|
|
131
|
+
app.get("/jobs/:queue/:id", async (c) => {
|
|
132
|
+
const { queue, id } = c.req.param();
|
|
133
|
+
const job = await qm.getJob(queue, id);
|
|
134
|
+
if (!job) {
|
|
135
|
+
return c.json({ error: "Job not found" }, 404);
|
|
136
|
+
}
|
|
137
|
+
return c.json(job);
|
|
138
|
+
});
|
|
139
|
+
app.post("/jobs/:queue/:id/retry", async (c) => {
|
|
140
|
+
if (core.options.readonly) {
|
|
141
|
+
return c.json({ error: "Dashboard is in readonly mode" }, 403);
|
|
142
|
+
}
|
|
143
|
+
const { queue, id } = c.req.param();
|
|
144
|
+
const success = await qm.retryJob(queue, id);
|
|
145
|
+
if (!success) {
|
|
146
|
+
return c.json({ error: "Failed to retry job" }, 400);
|
|
147
|
+
}
|
|
148
|
+
return c.json({ success: true });
|
|
149
|
+
});
|
|
150
|
+
app.post("/jobs/:queue/:id/remove", async (c) => {
|
|
151
|
+
if (core.options.readonly) {
|
|
152
|
+
return c.json({ error: "Dashboard is in readonly mode" }, 403);
|
|
153
|
+
}
|
|
154
|
+
const { queue, id } = c.req.param();
|
|
155
|
+
const success = await qm.removeJob(queue, id);
|
|
156
|
+
if (!success) {
|
|
157
|
+
return c.json({ error: "Failed to remove job" }, 400);
|
|
158
|
+
}
|
|
159
|
+
return c.json({ success: true });
|
|
160
|
+
});
|
|
161
|
+
app.post("/jobs/:queue/:id/promote", async (c) => {
|
|
162
|
+
if (core.options.readonly) {
|
|
163
|
+
return c.json({ error: "Dashboard is in readonly mode" }, 403);
|
|
164
|
+
}
|
|
165
|
+
const { queue, id } = c.req.param();
|
|
166
|
+
const success = await qm.promoteJob(queue, id);
|
|
167
|
+
if (!success) {
|
|
168
|
+
return c.json({ error: "Failed to promote job" }, 400);
|
|
169
|
+
}
|
|
170
|
+
return c.json({ success: true });
|
|
171
|
+
});
|
|
172
|
+
app.get("/search", async (c) => {
|
|
173
|
+
const query = c.req.query("q") || "";
|
|
174
|
+
const limit = Number(c.req.query("limit")) || 20;
|
|
175
|
+
if (!query) {
|
|
176
|
+
return c.json({ results: [] });
|
|
177
|
+
}
|
|
178
|
+
const results = await qm.search(query, limit);
|
|
179
|
+
return c.json({ results });
|
|
180
|
+
});
|
|
181
|
+
app.get("/tags/:field/values", async (c) => {
|
|
182
|
+
const { field } = c.req.param();
|
|
183
|
+
const limit = Number(c.req.query("limit")) || 50;
|
|
184
|
+
const tagFields = qm.getTagFields();
|
|
185
|
+
if (tagFields.length > 0 && !tagFields.includes(field)) {
|
|
186
|
+
return c.json(
|
|
187
|
+
{ error: `Field "${field}" is not a configured tag field` },
|
|
188
|
+
400
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
const values = await qm.getTagValues(field, limit);
|
|
192
|
+
return c.json({ field, values });
|
|
193
|
+
});
|
|
194
|
+
app.post("/queues/:name/clean", async (c) => {
|
|
195
|
+
if (core.options.readonly) {
|
|
196
|
+
return c.json({ error: "Dashboard is in readonly mode" }, 403);
|
|
197
|
+
}
|
|
198
|
+
const { name } = c.req.param();
|
|
199
|
+
const body = await c.req.json();
|
|
200
|
+
const count = await qm.cleanJobs(name, body.status, body.grace || 0);
|
|
201
|
+
return c.json({ removed: count });
|
|
202
|
+
});
|
|
203
|
+
app.post("/bulk/retry", async (c) => {
|
|
204
|
+
if (core.options.readonly) {
|
|
205
|
+
return c.json({ error: "Dashboard is in readonly mode" }, 403);
|
|
206
|
+
}
|
|
207
|
+
const body = await c.req.json();
|
|
208
|
+
const result = await qm.bulkRetry(body.jobs);
|
|
209
|
+
return c.json(result);
|
|
210
|
+
});
|
|
211
|
+
app.post("/bulk/delete", async (c) => {
|
|
212
|
+
if (core.options.readonly) {
|
|
213
|
+
return c.json({ error: "Dashboard is in readonly mode" }, 403);
|
|
214
|
+
}
|
|
215
|
+
const body = await c.req.json();
|
|
216
|
+
const result = await qm.bulkDelete(body.jobs);
|
|
217
|
+
return c.json(result);
|
|
218
|
+
});
|
|
219
|
+
app.post("/bulk/promote", async (c) => {
|
|
220
|
+
if (core.options.readonly) {
|
|
221
|
+
return c.json({ error: "Dashboard is in readonly mode" }, 403);
|
|
222
|
+
}
|
|
223
|
+
const body = await c.req.json();
|
|
224
|
+
const result = await qm.bulkPromote(body.jobs);
|
|
225
|
+
return c.json(result);
|
|
226
|
+
});
|
|
227
|
+
app.post("/queues/:name/pause", async (c) => {
|
|
228
|
+
if (core.options.readonly) {
|
|
229
|
+
return c.json({ error: "Dashboard is in readonly mode" }, 403);
|
|
230
|
+
}
|
|
231
|
+
const { name } = c.req.param();
|
|
232
|
+
try {
|
|
233
|
+
await qm.pauseQueue(name);
|
|
234
|
+
return c.json({ success: true, paused: true });
|
|
235
|
+
} catch (error) {
|
|
236
|
+
return c.json(
|
|
237
|
+
{
|
|
238
|
+
error: error instanceof Error ? error.message : "Failed to pause queue"
|
|
239
|
+
},
|
|
240
|
+
404
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
app.post("/queues/:name/resume", async (c) => {
|
|
245
|
+
if (core.options.readonly) {
|
|
246
|
+
return c.json({ error: "Dashboard is in readonly mode" }, 403);
|
|
247
|
+
}
|
|
248
|
+
const { name } = c.req.param();
|
|
249
|
+
try {
|
|
250
|
+
await qm.resumeQueue(name);
|
|
251
|
+
return c.json({ success: true, paused: false });
|
|
252
|
+
} catch (error) {
|
|
253
|
+
return c.json(
|
|
254
|
+
{
|
|
255
|
+
error: error instanceof Error ? error.message : "Failed to resume queue"
|
|
256
|
+
},
|
|
257
|
+
404
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
app.get("/flows", async (c) => {
|
|
262
|
+
const limit = Number(c.req.query("limit")) || 50;
|
|
263
|
+
const flows = await qm.getFlows(limit);
|
|
264
|
+
return c.json({ flows });
|
|
265
|
+
});
|
|
266
|
+
app.get("/flows/:queueName/:jobId", async (c) => {
|
|
267
|
+
const { queueName, jobId } = c.req.param();
|
|
268
|
+
const flow = await qm.getFlow(queueName, jobId);
|
|
269
|
+
if (!flow) {
|
|
270
|
+
return c.json({ error: "Flow not found" }, 404);
|
|
271
|
+
}
|
|
272
|
+
return c.json(flow);
|
|
273
|
+
});
|
|
274
|
+
app.post("/flows", async (c) => {
|
|
275
|
+
if (core.options.readonly) {
|
|
276
|
+
return c.json({ error: "Dashboard is in readonly mode" }, 403);
|
|
277
|
+
}
|
|
278
|
+
const body = await c.req.json();
|
|
279
|
+
if (!body.name || !body.queueName || !body.children?.length) {
|
|
280
|
+
return c.json(
|
|
281
|
+
{ error: "name, queueName, and children are required" },
|
|
282
|
+
400
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
try {
|
|
286
|
+
const result = await qm.createFlow(body);
|
|
287
|
+
return c.json(result);
|
|
288
|
+
} catch (e) {
|
|
289
|
+
return c.json({ error: e.message }, 400);
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
return app;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// src/core/queue-manager.ts
|
|
296
|
+
import { FlowProducer } from "bullmq";
|
|
297
|
+
import { LRUCache } from "lru-cache";
|
|
298
|
+
var QueueManager = class {
|
|
299
|
+
queues = /* @__PURE__ */ new Map();
|
|
300
|
+
tagFields = [];
|
|
301
|
+
flowProducer = null;
|
|
302
|
+
// LRU cache for expensive operations
|
|
303
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
304
|
+
cache = new LRUCache({
|
|
305
|
+
max: 100,
|
|
306
|
+
// Max 100 entries
|
|
307
|
+
ttl: 1e3 * 60,
|
|
308
|
+
// Default 1 minute TTL
|
|
309
|
+
allowStale: false,
|
|
310
|
+
// Don't return stale entries
|
|
311
|
+
updateAgeOnGet: true
|
|
312
|
+
// Reset TTL on access
|
|
313
|
+
});
|
|
314
|
+
CACHE_TTL = {
|
|
315
|
+
metrics: 5 * 60 * 1e3,
|
|
316
|
+
// 5 minutes - metrics are expensive
|
|
317
|
+
overview: 2 * 60 * 1e3,
|
|
318
|
+
// 2 minutes
|
|
319
|
+
queues: 2 * 60 * 1e3,
|
|
320
|
+
// 2 minutes
|
|
321
|
+
flows: 2 * 60 * 1e3,
|
|
322
|
+
// 2 minutes
|
|
323
|
+
activity: 5 * 60 * 1e3
|
|
324
|
+
// 5 minutes - activity timeline
|
|
325
|
+
};
|
|
326
|
+
constructor(queues, tagFields = []) {
|
|
327
|
+
for (const queue of queues) {
|
|
328
|
+
this.queues.set(queue.name, queue);
|
|
329
|
+
}
|
|
330
|
+
this.tagFields = tagFields;
|
|
331
|
+
const firstQueue = queues[0];
|
|
332
|
+
if (firstQueue) {
|
|
333
|
+
const connection = firstQueue.opts?.connection;
|
|
334
|
+
if (connection) {
|
|
335
|
+
this.flowProducer = new FlowProducer({ connection });
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* Get cached value or compute and cache
|
|
341
|
+
*/
|
|
342
|
+
async cached(key, ttl, compute) {
|
|
343
|
+
const cached = this.cache.get(key);
|
|
344
|
+
if (cached !== void 0) {
|
|
345
|
+
return cached;
|
|
346
|
+
}
|
|
347
|
+
const data = await compute();
|
|
348
|
+
this.cache.set(key, data, { ttl });
|
|
349
|
+
return data;
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* Execute a promise with a timeout
|
|
353
|
+
*/
|
|
354
|
+
async withTimeout(promise, timeoutMs, errorMessage) {
|
|
355
|
+
let timeoutId;
|
|
356
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
357
|
+
timeoutId = setTimeout(() => reject(new Error(errorMessage)), timeoutMs);
|
|
358
|
+
});
|
|
359
|
+
try {
|
|
360
|
+
return await Promise.race([promise, timeoutPromise]);
|
|
361
|
+
} finally {
|
|
362
|
+
clearTimeout(timeoutId);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* Get jobs by time range using Redis sorted sets (ZRANGEBYSCORE)
|
|
367
|
+
* This is more efficient than fetching all jobs and filtering in memory
|
|
368
|
+
*/
|
|
369
|
+
async getJobsByTimeRange(queue, status, startTime, endTime, limit) {
|
|
370
|
+
try {
|
|
371
|
+
const client = queue.client;
|
|
372
|
+
if (!client) {
|
|
373
|
+
const jobs2 = await queue.getJobs([status], 0, limit * 2);
|
|
374
|
+
return jobs2.filter(
|
|
375
|
+
(job) => job.finishedOn && job.finishedOn >= startTime && job.finishedOn <= endTime
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
const queueKey = `bull:${queue.name}:${status}`;
|
|
379
|
+
const jobIds = await client.zrangebyscore(
|
|
380
|
+
queueKey,
|
|
381
|
+
startTime,
|
|
382
|
+
endTime,
|
|
383
|
+
"LIMIT",
|
|
384
|
+
0,
|
|
385
|
+
limit
|
|
386
|
+
);
|
|
387
|
+
if (!jobIds || jobIds.length === 0) {
|
|
388
|
+
return [];
|
|
389
|
+
}
|
|
390
|
+
const jobPromises = jobIds.map((jobId) => queue.getJob(jobId));
|
|
391
|
+
const jobs = await Promise.all(jobPromises);
|
|
392
|
+
return jobs.filter(
|
|
393
|
+
(job) => job !== null && job !== void 0
|
|
394
|
+
);
|
|
395
|
+
} catch (_error) {
|
|
396
|
+
const jobs = await queue.getJobs([status], 0, limit * 2);
|
|
397
|
+
return jobs.filter(
|
|
398
|
+
(job) => job.finishedOn && job.finishedOn >= startTime && job.finishedOn <= endTime
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* Cache for job state lookups to avoid repeated Redis calls
|
|
404
|
+
*/
|
|
405
|
+
jobStateCache = new LRUCache({
|
|
406
|
+
max: 1e3,
|
|
407
|
+
ttl: 1e3 * 30
|
|
408
|
+
// 30 second TTL - job states don't change frequently
|
|
409
|
+
});
|
|
410
|
+
/**
|
|
411
|
+
* Cache for job counts to avoid repeated Redis calls
|
|
412
|
+
* Short TTL since counts change frequently but are expensive to fetch
|
|
413
|
+
*/
|
|
414
|
+
countCache = new LRUCache({
|
|
415
|
+
max: 100,
|
|
416
|
+
// Cache counts for up to 100 queues
|
|
417
|
+
ttl: 1e3 * 5
|
|
418
|
+
// 5 second TTL - counts change but not instantly
|
|
419
|
+
});
|
|
420
|
+
/**
|
|
421
|
+
* Get job counts with caching
|
|
422
|
+
*/
|
|
423
|
+
async getCachedJobCounts(queue) {
|
|
424
|
+
const cacheKey = queue.name;
|
|
425
|
+
const cached = this.countCache.get(cacheKey);
|
|
426
|
+
if (cached !== void 0) {
|
|
427
|
+
return cached;
|
|
428
|
+
}
|
|
429
|
+
const counts = await queue.getJobCounts();
|
|
430
|
+
this.countCache.set(cacheKey, counts);
|
|
431
|
+
return counts;
|
|
432
|
+
}
|
|
433
|
+
/**
|
|
434
|
+
* Invalidate caches related to a job or queue
|
|
435
|
+
*/
|
|
436
|
+
invalidateJobCache(queueName, jobId) {
|
|
437
|
+
this.countCache.delete(queueName);
|
|
438
|
+
if (jobId) {
|
|
439
|
+
const stateCacheKey = `${queueName}:${jobId}`;
|
|
440
|
+
this.jobStateCache.delete(stateCacheKey);
|
|
441
|
+
}
|
|
442
|
+
this.cache.delete("metrics");
|
|
443
|
+
this.cache.delete("overview");
|
|
444
|
+
this.cache.delete("activity");
|
|
445
|
+
}
|
|
446
|
+
/**
|
|
447
|
+
* Clear cache (useful after mutations)
|
|
448
|
+
*/
|
|
449
|
+
clearCache(prefix) {
|
|
450
|
+
if (prefix) {
|
|
451
|
+
for (const key of this.cache.keys()) {
|
|
452
|
+
if (key.startsWith(prefix)) {
|
|
453
|
+
this.cache.delete(key);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
} else {
|
|
457
|
+
this.cache.clear();
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
461
|
+
* Get quick job counts across all queues (lightweight, for smart polling)
|
|
462
|
+
* Returns total counts per status - cached and very fast
|
|
463
|
+
*/
|
|
464
|
+
async getQuickCounts() {
|
|
465
|
+
return this.cached("quick-counts", 2e3, async () => {
|
|
466
|
+
const totals = {
|
|
467
|
+
waiting: 0,
|
|
468
|
+
active: 0,
|
|
469
|
+
completed: 0,
|
|
470
|
+
failed: 0,
|
|
471
|
+
delayed: 0,
|
|
472
|
+
total: 0,
|
|
473
|
+
timestamp: Date.now()
|
|
474
|
+
};
|
|
475
|
+
await Promise.all(
|
|
476
|
+
Array.from(this.queues.values()).map(async (queue) => {
|
|
477
|
+
const counts = await this.getCachedJobCounts(queue);
|
|
478
|
+
totals.waiting += counts.waiting || 0;
|
|
479
|
+
totals.active += counts.active || 0;
|
|
480
|
+
totals.completed += counts.completed || 0;
|
|
481
|
+
totals.failed += counts.failed || 0;
|
|
482
|
+
totals.delayed += counts.delayed || 0;
|
|
483
|
+
})
|
|
484
|
+
);
|
|
485
|
+
totals.total = totals.waiting + totals.active + totals.completed + totals.failed + totals.delayed;
|
|
486
|
+
return totals;
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
/**
|
|
490
|
+
* Get configured tag field names
|
|
491
|
+
*/
|
|
492
|
+
getTagFields() {
|
|
493
|
+
return this.tagFields;
|
|
494
|
+
}
|
|
495
|
+
/**
|
|
496
|
+
* Get just queue names (very fast, no Redis calls)
|
|
497
|
+
* Used for sidebar initial render
|
|
498
|
+
*/
|
|
499
|
+
getQueueNames() {
|
|
500
|
+
return Array.from(this.queues.keys());
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* Get a queue by name
|
|
504
|
+
*/
|
|
505
|
+
getQueue(name) {
|
|
506
|
+
return this.queues.get(name);
|
|
507
|
+
}
|
|
508
|
+
/**
|
|
509
|
+
* Get information for all queues (cached)
|
|
510
|
+
*/
|
|
511
|
+
async getQueues() {
|
|
512
|
+
return this.cached("queues", this.CACHE_TTL.queues, async () => {
|
|
513
|
+
const queueEntries = Array.from(this.queues.entries());
|
|
514
|
+
const results = await Promise.all(
|
|
515
|
+
queueEntries.map(async ([name, queue]) => {
|
|
516
|
+
const [counts, isPaused] = await Promise.all([
|
|
517
|
+
this.getCachedJobCounts(queue),
|
|
518
|
+
queue.isPaused()
|
|
519
|
+
]);
|
|
520
|
+
return {
|
|
521
|
+
name,
|
|
522
|
+
counts: {
|
|
523
|
+
waiting: counts.waiting || 0,
|
|
524
|
+
active: counts.active || 0,
|
|
525
|
+
completed: counts.completed || 0,
|
|
526
|
+
failed: counts.failed || 0,
|
|
527
|
+
delayed: counts.delayed || 0,
|
|
528
|
+
paused: counts.paused || 0
|
|
529
|
+
},
|
|
530
|
+
isPaused
|
|
531
|
+
};
|
|
532
|
+
})
|
|
533
|
+
);
|
|
534
|
+
return results;
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
/**
|
|
538
|
+
* Get overview statistics (cached)
|
|
539
|
+
*/
|
|
540
|
+
async getOverview() {
|
|
541
|
+
return this.cached("overview", this.CACHE_TTL.overview, async () => {
|
|
542
|
+
const queues = await this.getQueues();
|
|
543
|
+
let totalJobs = 0;
|
|
544
|
+
let activeJobs = 0;
|
|
545
|
+
let failedJobs = 0;
|
|
546
|
+
for (const queue of queues) {
|
|
547
|
+
totalJobs += queue.counts.waiting + queue.counts.active + queue.counts.delayed;
|
|
548
|
+
activeJobs += queue.counts.active;
|
|
549
|
+
failedJobs += queue.counts.failed;
|
|
550
|
+
}
|
|
551
|
+
const completedToday = queues.reduce(
|
|
552
|
+
(sum, q) => sum + q.counts.completed,
|
|
553
|
+
0
|
|
554
|
+
);
|
|
555
|
+
return {
|
|
556
|
+
totalJobs,
|
|
557
|
+
activeJobs,
|
|
558
|
+
failedJobs,
|
|
559
|
+
completedToday,
|
|
560
|
+
avgDuration: 0,
|
|
561
|
+
// Would need metrics tracking
|
|
562
|
+
queues
|
|
563
|
+
};
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
567
|
+
// Queue Control (Pause/Resume)
|
|
568
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
569
|
+
/**
|
|
570
|
+
* Pause a queue - stops processing new jobs
|
|
571
|
+
*/
|
|
572
|
+
async pauseQueue(queueName) {
|
|
573
|
+
const queue = this.queues.get(queueName);
|
|
574
|
+
if (!queue) {
|
|
575
|
+
throw new Error(`Queue "${queueName}" not found`);
|
|
576
|
+
}
|
|
577
|
+
await queue.pause();
|
|
578
|
+
}
|
|
579
|
+
/**
|
|
580
|
+
* Resume a paused queue
|
|
581
|
+
*/
|
|
582
|
+
async resumeQueue(queueName) {
|
|
583
|
+
const queue = this.queues.get(queueName);
|
|
584
|
+
if (!queue) {
|
|
585
|
+
throw new Error(`Queue "${queueName}" not found`);
|
|
586
|
+
}
|
|
587
|
+
await queue.resume();
|
|
588
|
+
}
|
|
589
|
+
/**
|
|
590
|
+
* Check if a queue is paused
|
|
591
|
+
*/
|
|
592
|
+
async isQueuePaused(queueName) {
|
|
593
|
+
const queue = this.queues.get(queueName);
|
|
594
|
+
if (!queue) {
|
|
595
|
+
throw new Error(`Queue "${queueName}" not found`);
|
|
596
|
+
}
|
|
597
|
+
return queue.isPaused();
|
|
598
|
+
}
|
|
599
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
600
|
+
// Metrics
|
|
601
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
602
|
+
/**
|
|
603
|
+
* Get metrics for the last 24 hours (cached - expensive operation)
|
|
604
|
+
*/
|
|
605
|
+
async getMetrics() {
|
|
606
|
+
return this.cached("metrics", this.CACHE_TTL.metrics, async () => {
|
|
607
|
+
return this.withTimeout(
|
|
608
|
+
(async () => {
|
|
609
|
+
const now = Date.now();
|
|
610
|
+
const twentyFourHoursAgo = now - 24 * 60 * 60 * 1e3;
|
|
611
|
+
const createEmptyBuckets = () => {
|
|
612
|
+
const buckets = [];
|
|
613
|
+
const startHour = Math.floor(twentyFourHoursAgo / (60 * 60 * 1e3)) * (60 * 60 * 1e3);
|
|
614
|
+
for (let i = 0; i < 24; i++) {
|
|
615
|
+
buckets.push({
|
|
616
|
+
hour: startHour + i * 60 * 60 * 1e3,
|
|
617
|
+
completed: 0,
|
|
618
|
+
failed: 0,
|
|
619
|
+
avgDuration: 0,
|
|
620
|
+
avgWaitTime: 0
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
return buckets;
|
|
624
|
+
};
|
|
625
|
+
const queueMetricsMap = /* @__PURE__ */ new Map();
|
|
626
|
+
for (const queueName of this.queues.keys()) {
|
|
627
|
+
queueMetricsMap.set(queueName, {
|
|
628
|
+
buckets: createEmptyBuckets(),
|
|
629
|
+
durations: Array.from({ length: 24 }, () => []),
|
|
630
|
+
waitTimes: Array.from({ length: 24 }, () => [])
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
const allJobs = [];
|
|
634
|
+
const jobTypeStats = /* @__PURE__ */ new Map();
|
|
635
|
+
const queueEntries = Array.from(this.queues.entries());
|
|
636
|
+
const queueChecks = await Promise.all(
|
|
637
|
+
queueEntries.map(async ([queueName, queue]) => {
|
|
638
|
+
const counts = await this.getCachedJobCounts(queue);
|
|
639
|
+
return {
|
|
640
|
+
queueName,
|
|
641
|
+
queue,
|
|
642
|
+
hasRelevantJobs: (counts.completed || 0) > 0 || (counts.failed || 0) > 0
|
|
643
|
+
};
|
|
644
|
+
})
|
|
645
|
+
);
|
|
646
|
+
const relevantQueues = queueChecks.filter((q) => q.hasRelevantJobs);
|
|
647
|
+
const queueResults = await Promise.all(
|
|
648
|
+
relevantQueues.map(async ({ queueName, queue }) => {
|
|
649
|
+
const [completedJobs, failedJobs] = await Promise.all([
|
|
650
|
+
this.getJobsByTimeRange(
|
|
651
|
+
queue,
|
|
652
|
+
"completed",
|
|
653
|
+
twentyFourHoursAgo,
|
|
654
|
+
now,
|
|
655
|
+
100
|
|
656
|
+
// Reduced from 200 - only recent jobs needed
|
|
657
|
+
),
|
|
658
|
+
this.getJobsByTimeRange(
|
|
659
|
+
queue,
|
|
660
|
+
"failed",
|
|
661
|
+
twentyFourHoursAgo,
|
|
662
|
+
now,
|
|
663
|
+
100
|
|
664
|
+
// Reduced from 200 - only recent jobs needed
|
|
665
|
+
)
|
|
666
|
+
]);
|
|
667
|
+
return { queueName, completedJobs, failedJobs };
|
|
668
|
+
})
|
|
669
|
+
);
|
|
670
|
+
for (const { queueName, completedJobs, failedJobs } of queueResults) {
|
|
671
|
+
const metrics = queueMetricsMap.get(queueName);
|
|
672
|
+
for (const job of completedJobs) {
|
|
673
|
+
if (!job?.finishedOn || job.finishedOn < twentyFourHoursAgo)
|
|
674
|
+
continue;
|
|
675
|
+
const bucketIndex = Math.floor(
|
|
676
|
+
(job.finishedOn - (metrics.buckets[0]?.hour || 0)) / (60 * 60 * 1e3)
|
|
677
|
+
);
|
|
678
|
+
if (bucketIndex >= 0 && bucketIndex < 24) {
|
|
679
|
+
metrics.buckets[bucketIndex].completed++;
|
|
680
|
+
const duration = job.processedOn ? job.finishedOn - job.processedOn : 0;
|
|
681
|
+
const waitTime = job.processedOn ? job.processedOn - job.timestamp : 0;
|
|
682
|
+
if (duration > 0) {
|
|
683
|
+
metrics.durations[bucketIndex].push(duration);
|
|
684
|
+
allJobs.push({
|
|
685
|
+
name: job.name,
|
|
686
|
+
queueName,
|
|
687
|
+
duration,
|
|
688
|
+
jobId: job.id || ""
|
|
689
|
+
});
|
|
690
|
+
}
|
|
691
|
+
if (waitTime > 0) {
|
|
692
|
+
metrics.waitTimes[bucketIndex].push(waitTime);
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
const key = `${queueName}:${job.name}`;
|
|
696
|
+
const stats = jobTypeStats.get(key) || {
|
|
697
|
+
name: job.name,
|
|
698
|
+
queueName,
|
|
699
|
+
completed: 0,
|
|
700
|
+
failed: 0
|
|
701
|
+
};
|
|
702
|
+
stats.completed++;
|
|
703
|
+
jobTypeStats.set(key, stats);
|
|
704
|
+
}
|
|
705
|
+
for (const job of failedJobs) {
|
|
706
|
+
if (!job?.finishedOn || job.finishedOn < twentyFourHoursAgo)
|
|
707
|
+
continue;
|
|
708
|
+
const bucketIndex = Math.floor(
|
|
709
|
+
(job.finishedOn - (metrics.buckets[0]?.hour || 0)) / (60 * 60 * 1e3)
|
|
710
|
+
);
|
|
711
|
+
if (bucketIndex >= 0 && bucketIndex < 24) {
|
|
712
|
+
metrics.buckets[bucketIndex].failed++;
|
|
713
|
+
}
|
|
714
|
+
const key = `${queueName}:${job.name}`;
|
|
715
|
+
const stats = jobTypeStats.get(key) || {
|
|
716
|
+
name: job.name,
|
|
717
|
+
queueName,
|
|
718
|
+
completed: 0,
|
|
719
|
+
failed: 0
|
|
720
|
+
};
|
|
721
|
+
stats.failed++;
|
|
722
|
+
jobTypeStats.set(key, stats);
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
for (const metrics of queueMetricsMap.values()) {
|
|
726
|
+
for (let i = 0; i < 24; i++) {
|
|
727
|
+
const durations = metrics.durations[i];
|
|
728
|
+
const waitTimes = metrics.waitTimes[i];
|
|
729
|
+
if (durations.length > 0) {
|
|
730
|
+
metrics.buckets[i].avgDuration = Math.round(
|
|
731
|
+
durations.reduce((a, b) => a + b, 0) / durations.length
|
|
732
|
+
);
|
|
733
|
+
}
|
|
734
|
+
if (waitTimes.length > 0) {
|
|
735
|
+
metrics.buckets[i].avgWaitTime = Math.round(
|
|
736
|
+
waitTimes.reduce((a, b) => a + b, 0) / waitTimes.length
|
|
737
|
+
);
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
const aggregateBuckets = createEmptyBuckets();
|
|
742
|
+
const aggregateDurations = Array.from(
|
|
743
|
+
{ length: 24 },
|
|
744
|
+
() => []
|
|
745
|
+
);
|
|
746
|
+
const aggregateWaitTimes = Array.from(
|
|
747
|
+
{ length: 24 },
|
|
748
|
+
() => []
|
|
749
|
+
);
|
|
750
|
+
for (const metrics of queueMetricsMap.values()) {
|
|
751
|
+
for (let i = 0; i < 24; i++) {
|
|
752
|
+
aggregateBuckets[i].completed += metrics.buckets[i].completed;
|
|
753
|
+
aggregateBuckets[i].failed += metrics.buckets[i].failed;
|
|
754
|
+
aggregateDurations[i].push(...metrics.durations[i]);
|
|
755
|
+
aggregateWaitTimes[i].push(...metrics.waitTimes[i]);
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
for (let i = 0; i < 24; i++) {
|
|
759
|
+
if (aggregateDurations[i].length > 0) {
|
|
760
|
+
aggregateBuckets[i].avgDuration = Math.round(
|
|
761
|
+
aggregateDurations[i].reduce((a, b) => a + b, 0) / aggregateDurations[i].length
|
|
762
|
+
);
|
|
763
|
+
}
|
|
764
|
+
if (aggregateWaitTimes[i].length > 0) {
|
|
765
|
+
aggregateBuckets[i].avgWaitTime = Math.round(
|
|
766
|
+
aggregateWaitTimes[i].reduce((a, b) => a + b, 0) / aggregateWaitTimes[i].length
|
|
767
|
+
);
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
const totalCompleted = aggregateBuckets.reduce(
|
|
771
|
+
(sum, b) => sum + b.completed,
|
|
772
|
+
0
|
|
773
|
+
);
|
|
774
|
+
const totalFailed = aggregateBuckets.reduce(
|
|
775
|
+
(sum, b) => sum + b.failed,
|
|
776
|
+
0
|
|
777
|
+
);
|
|
778
|
+
const allDurations = aggregateDurations.flat();
|
|
779
|
+
const allWaitTimes = aggregateWaitTimes.flat();
|
|
780
|
+
const slowestJobs = allJobs.sort((a, b) => b.duration - a.duration).slice(0, 10);
|
|
781
|
+
const mostFailingTypes = Array.from(jobTypeStats.values()).filter((s) => s.failed > 0).map((s) => ({
|
|
782
|
+
name: s.name,
|
|
783
|
+
queueName: s.queueName,
|
|
784
|
+
failCount: s.failed,
|
|
785
|
+
totalCount: s.completed + s.failed,
|
|
786
|
+
errorRate: s.failed / (s.completed + s.failed)
|
|
787
|
+
})).sort((a, b) => b.failCount - a.failCount).slice(0, 10);
|
|
788
|
+
return {
|
|
789
|
+
queues: [],
|
|
790
|
+
// Empty - per-queue metrics not used by frontend
|
|
791
|
+
aggregate: {
|
|
792
|
+
queueName: "all",
|
|
793
|
+
buckets: aggregateBuckets,
|
|
794
|
+
summary: {
|
|
795
|
+
totalCompleted,
|
|
796
|
+
totalFailed,
|
|
797
|
+
errorRate: totalCompleted + totalFailed > 0 ? totalFailed / (totalCompleted + totalFailed) : 0,
|
|
798
|
+
avgDuration: allDurations.length > 0 ? Math.round(
|
|
799
|
+
allDurations.reduce((a, b) => a + b, 0) / allDurations.length
|
|
800
|
+
) : 0,
|
|
801
|
+
avgWaitTime: allWaitTimes.length > 0 ? Math.round(
|
|
802
|
+
allWaitTimes.reduce((a, b) => a + b, 0) / allWaitTimes.length
|
|
803
|
+
) : 0,
|
|
804
|
+
throughputPerHour: Math.round(
|
|
805
|
+
(totalCompleted + totalFailed) / 24
|
|
806
|
+
)
|
|
807
|
+
}
|
|
808
|
+
},
|
|
809
|
+
slowestJobs,
|
|
810
|
+
mostFailingTypes,
|
|
811
|
+
computedAt: now
|
|
812
|
+
};
|
|
813
|
+
})(),
|
|
814
|
+
45e3,
|
|
815
|
+
// 45 second timeout (before proxy timeout)
|
|
816
|
+
"Metrics computation timed out after 45 seconds"
|
|
817
|
+
);
|
|
818
|
+
});
|
|
819
|
+
}
|
|
820
|
+
/**
|
|
821
|
+
* Get activity stats for the last 7 days (cached)
|
|
822
|
+
* Returns 4-hour buckets for the activity timeline
|
|
823
|
+
*/
|
|
824
|
+
async getActivityStats() {
|
|
825
|
+
return this.cached("activity", this.CACHE_TTL.activity, async () => {
|
|
826
|
+
const now = Date.now();
|
|
827
|
+
const bucketSize = 4 * 60 * 60 * 1e3;
|
|
828
|
+
const bucketCount = 42;
|
|
829
|
+
const startDate = new Date(now);
|
|
830
|
+
startDate.setHours(0, 0, 0, 0);
|
|
831
|
+
startDate.setDate(startDate.getDate() - 6);
|
|
832
|
+
const startTime = startDate.getTime();
|
|
833
|
+
const buckets = [];
|
|
834
|
+
for (let i = 0; i < bucketCount; i++) {
|
|
835
|
+
buckets.push({
|
|
836
|
+
time: startTime + i * bucketSize,
|
|
837
|
+
completed: 0,
|
|
838
|
+
failed: 0
|
|
839
|
+
});
|
|
840
|
+
}
|
|
841
|
+
const queueEntries = Array.from(this.queues.entries());
|
|
842
|
+
const queueChecks = await Promise.all(
|
|
843
|
+
queueEntries.map(async ([, queue]) => {
|
|
844
|
+
const counts = await this.getCachedJobCounts(queue);
|
|
845
|
+
return {
|
|
846
|
+
queue,
|
|
847
|
+
hasRelevantJobs: (counts.completed || 0) > 0 || (counts.failed || 0) > 0
|
|
848
|
+
};
|
|
849
|
+
})
|
|
850
|
+
);
|
|
851
|
+
const relevantQueues = queueChecks.filter((q) => q.hasRelevantJobs);
|
|
852
|
+
const queueResults = await Promise.all(
|
|
853
|
+
relevantQueues.map(async ({ queue }) => {
|
|
854
|
+
const [completedJobs, failedJobs] = await Promise.all([
|
|
855
|
+
this.getJobsByTimeRange(
|
|
856
|
+
queue,
|
|
857
|
+
"completed",
|
|
858
|
+
startTime,
|
|
859
|
+
now,
|
|
860
|
+
200
|
|
861
|
+
// Reduced from 500 - only jobs in time range needed
|
|
862
|
+
),
|
|
863
|
+
this.getJobsByTimeRange(
|
|
864
|
+
queue,
|
|
865
|
+
"failed",
|
|
866
|
+
startTime,
|
|
867
|
+
now,
|
|
868
|
+
200
|
|
869
|
+
// Reduced from 500 - only jobs in time range needed
|
|
870
|
+
)
|
|
871
|
+
]);
|
|
872
|
+
return { completedJobs, failedJobs };
|
|
873
|
+
})
|
|
874
|
+
);
|
|
875
|
+
for (const { completedJobs, failedJobs } of queueResults) {
|
|
876
|
+
for (const job of completedJobs) {
|
|
877
|
+
if (!job?.finishedOn || job.finishedOn < startTime) continue;
|
|
878
|
+
const bucketIndex = Math.floor(
|
|
879
|
+
(job.finishedOn - startTime) / bucketSize
|
|
880
|
+
);
|
|
881
|
+
if (bucketIndex >= 0 && bucketIndex < bucketCount) {
|
|
882
|
+
buckets[bucketIndex].completed++;
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
for (const job of failedJobs) {
|
|
886
|
+
if (!job?.finishedOn || job.finishedOn < startTime) continue;
|
|
887
|
+
const bucketIndex = Math.floor(
|
|
888
|
+
(job.finishedOn - startTime) / bucketSize
|
|
889
|
+
);
|
|
890
|
+
if (bucketIndex >= 0 && bucketIndex < bucketCount) {
|
|
891
|
+
buckets[bucketIndex].failed++;
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
const totalCompleted = buckets.reduce((sum, b) => sum + b.completed, 0);
|
|
896
|
+
const totalFailed = buckets.reduce((sum, b) => sum + b.failed, 0);
|
|
897
|
+
return {
|
|
898
|
+
buckets,
|
|
899
|
+
startTime,
|
|
900
|
+
endTime: now,
|
|
901
|
+
bucketSize,
|
|
902
|
+
totalCompleted,
|
|
903
|
+
totalFailed,
|
|
904
|
+
computedAt: now
|
|
905
|
+
};
|
|
906
|
+
});
|
|
907
|
+
}
|
|
908
|
+
/**
|
|
909
|
+
* Get jobs for a specific queue with pagination and sorting
|
|
910
|
+
*/
|
|
911
|
+
async getJobs(queueName, status, limit = 50, start = 0, sort) {
|
|
912
|
+
const queue = this.queues.get(queueName);
|
|
913
|
+
if (!queue) {
|
|
914
|
+
return { data: [], total: 0, hasMore: false };
|
|
915
|
+
}
|
|
916
|
+
const types = status ? [status] : ["waiting", "active", "completed", "failed", "delayed"];
|
|
917
|
+
const counts = await this.getCachedJobCounts(queue);
|
|
918
|
+
const jobsWithState = [];
|
|
919
|
+
let total = 0;
|
|
920
|
+
for (const type of types) {
|
|
921
|
+
const typeJobs = await queue.getJobs(type, start, start + limit);
|
|
922
|
+
jobsWithState.push(
|
|
923
|
+
...typeJobs.map((job) => ({ job, state: type }))
|
|
924
|
+
);
|
|
925
|
+
const typeCount = counts[type] || 0;
|
|
926
|
+
total += typeCount;
|
|
927
|
+
}
|
|
928
|
+
const jobInfos = await Promise.all(
|
|
929
|
+
jobsWithState.map(({ job, state }) => this.jobToInfo(job, "full", state))
|
|
930
|
+
);
|
|
931
|
+
const sortField = sort?.field ?? "timestamp";
|
|
932
|
+
const sortDir = sort?.direction === "asc" ? 1 : -1;
|
|
933
|
+
jobInfos.sort((a, b) => {
|
|
934
|
+
const aVal = this.getSortValue(a, sortField);
|
|
935
|
+
const bVal = this.getSortValue(b, sortField);
|
|
936
|
+
if (aVal < bVal) return -1 * sortDir;
|
|
937
|
+
if (aVal > bVal) return 1 * sortDir;
|
|
938
|
+
return 0;
|
|
939
|
+
});
|
|
940
|
+
const data = jobInfos.slice(0, limit);
|
|
941
|
+
return {
|
|
942
|
+
data,
|
|
943
|
+
total,
|
|
944
|
+
hasMore: start + limit < total,
|
|
945
|
+
cursor: start + limit < total ? String(start + limit) : void 0
|
|
946
|
+
};
|
|
947
|
+
}
|
|
948
|
+
/**
|
|
949
|
+
* Get a single job by ID
|
|
950
|
+
*/
|
|
951
|
+
async getJob(queueName, jobId) {
|
|
952
|
+
const queue = this.queues.get(queueName);
|
|
953
|
+
if (!queue) return null;
|
|
954
|
+
const job = await queue.getJob(jobId);
|
|
955
|
+
if (!job) return null;
|
|
956
|
+
return this.jobToInfo(job, "full");
|
|
957
|
+
}
|
|
958
|
+
/**
|
|
959
|
+
* Retry a failed job
|
|
960
|
+
*/
|
|
961
|
+
async retryJob(queueName, jobId) {
|
|
962
|
+
const queue = this.queues.get(queueName);
|
|
963
|
+
if (!queue) return false;
|
|
964
|
+
const job = await queue.getJob(jobId);
|
|
965
|
+
if (!job) return false;
|
|
966
|
+
await job.retry();
|
|
967
|
+
this.invalidateJobCache(queueName, jobId);
|
|
968
|
+
return true;
|
|
969
|
+
}
|
|
970
|
+
/**
|
|
971
|
+
* Remove a job
|
|
972
|
+
*/
|
|
973
|
+
async removeJob(queueName, jobId) {
|
|
974
|
+
const queue = this.queues.get(queueName);
|
|
975
|
+
if (!queue) return false;
|
|
976
|
+
const job = await queue.getJob(jobId);
|
|
977
|
+
if (!job) return false;
|
|
978
|
+
await job.remove();
|
|
979
|
+
this.invalidateJobCache(queueName, jobId);
|
|
980
|
+
return true;
|
|
981
|
+
}
|
|
982
|
+
/**
|
|
983
|
+
* Promote a delayed job to waiting
|
|
984
|
+
*/
|
|
985
|
+
async promoteJob(queueName, jobId) {
|
|
986
|
+
const queue = this.queues.get(queueName);
|
|
987
|
+
if (!queue) return false;
|
|
988
|
+
const job = await queue.getJob(jobId);
|
|
989
|
+
if (!job) return false;
|
|
990
|
+
await job.promote();
|
|
991
|
+
this.invalidateJobCache(queueName, jobId);
|
|
992
|
+
return true;
|
|
993
|
+
}
|
|
994
|
+
/**
|
|
995
|
+
* Parse search query for field:value filters
|
|
996
|
+
* Returns { filters: { field: value }, text: remainingText }
|
|
997
|
+
*/
|
|
998
|
+
parseSearchQuery(query) {
|
|
999
|
+
const filters = {};
|
|
1000
|
+
const textParts = [];
|
|
1001
|
+
const len = query.length;
|
|
1002
|
+
let i = 0;
|
|
1003
|
+
while (i < len) {
|
|
1004
|
+
while (i < len && query[i] === " ") i++;
|
|
1005
|
+
if (i >= len) break;
|
|
1006
|
+
let j = i;
|
|
1007
|
+
while (j < len && /\w/.test(query[j])) j++;
|
|
1008
|
+
if (j > i && j < len && query[j] === ":") {
|
|
1009
|
+
const field = query.slice(i, j);
|
|
1010
|
+
j++;
|
|
1011
|
+
let value;
|
|
1012
|
+
if (j < len && query[j] === '"') {
|
|
1013
|
+
j++;
|
|
1014
|
+
const closeQuote = query.indexOf('"', j);
|
|
1015
|
+
if (closeQuote !== -1) {
|
|
1016
|
+
value = query.slice(j, closeQuote);
|
|
1017
|
+
j = closeQuote + 1;
|
|
1018
|
+
} else {
|
|
1019
|
+
value = query.slice(j);
|
|
1020
|
+
j = len;
|
|
1021
|
+
}
|
|
1022
|
+
} else {
|
|
1023
|
+
const valueStart = j;
|
|
1024
|
+
while (j < len && query[j] !== " ") j++;
|
|
1025
|
+
value = query.slice(valueStart, j);
|
|
1026
|
+
}
|
|
1027
|
+
if (value) {
|
|
1028
|
+
filters[field] = value;
|
|
1029
|
+
} else {
|
|
1030
|
+
textParts.push(`${field}:`);
|
|
1031
|
+
}
|
|
1032
|
+
i = j;
|
|
1033
|
+
} else {
|
|
1034
|
+
const start = i;
|
|
1035
|
+
while (i < len && query[i] !== " ") i++;
|
|
1036
|
+
textParts.push(query.slice(start, i));
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
return {
|
|
1040
|
+
filters,
|
|
1041
|
+
text: textParts.filter(Boolean).join(" ")
|
|
1042
|
+
};
|
|
1043
|
+
}
|
|
1044
|
+
/**
|
|
1045
|
+
* Check if a raw job matches all provided filters (before conversion)
|
|
1046
|
+
* This is more efficient than converting to JobInfo first
|
|
1047
|
+
*/
|
|
1048
|
+
jobMatchesAllFilters(job, filters) {
|
|
1049
|
+
if (filters.timeRange) {
|
|
1050
|
+
const jobTime = job.processedOn || job.finishedOn || job.timestamp || 0;
|
|
1051
|
+
if (jobTime < filters.timeRange.start || jobTime > filters.timeRange.end) {
|
|
1052
|
+
return false;
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
if (filters.tags && Object.keys(filters.tags).length > 0) {
|
|
1056
|
+
if (!job.data || typeof job.data !== "object") {
|
|
1057
|
+
return false;
|
|
1058
|
+
}
|
|
1059
|
+
const dataObj = job.data;
|
|
1060
|
+
for (const [field, value] of Object.entries(filters.tags)) {
|
|
1061
|
+
const jobValue = dataObj[field];
|
|
1062
|
+
if (jobValue === void 0 || jobValue === null) {
|
|
1063
|
+
return false;
|
|
1064
|
+
}
|
|
1065
|
+
const strJobValue = String(jobValue).toLowerCase();
|
|
1066
|
+
const strFilterValue = value.toLowerCase();
|
|
1067
|
+
if (!strJobValue.includes(strFilterValue)) {
|
|
1068
|
+
return false;
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
if (filters.text) {
|
|
1073
|
+
const lowerText = filters.text.toLowerCase();
|
|
1074
|
+
const matchesId = job.id?.toLowerCase().includes(lowerText);
|
|
1075
|
+
const matchesName = job.name?.toLowerCase().includes(lowerText);
|
|
1076
|
+
if (!matchesId && !matchesName) {
|
|
1077
|
+
const stringifiedData = JSON.stringify(job.data).toLowerCase();
|
|
1078
|
+
if (!stringifiedData.includes(lowerText)) {
|
|
1079
|
+
return false;
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
return true;
|
|
1084
|
+
}
|
|
1085
|
+
/**
|
|
1086
|
+
* Check if a job matches the given tag filters
|
|
1087
|
+
*/
|
|
1088
|
+
jobMatchesFilters(job, filters) {
|
|
1089
|
+
if (!job.data || typeof job.data !== "object") {
|
|
1090
|
+
return Object.keys(filters).length === 0;
|
|
1091
|
+
}
|
|
1092
|
+
const dataObj = job.data;
|
|
1093
|
+
for (const [field, value] of Object.entries(filters)) {
|
|
1094
|
+
const jobValue = dataObj[field];
|
|
1095
|
+
if (jobValue === void 0 || jobValue === null) {
|
|
1096
|
+
return false;
|
|
1097
|
+
}
|
|
1098
|
+
const strJobValue = String(jobValue).toLowerCase();
|
|
1099
|
+
const strFilterValue = value.toLowerCase();
|
|
1100
|
+
if (!strJobValue.includes(strFilterValue)) {
|
|
1101
|
+
return false;
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
return true;
|
|
1105
|
+
}
|
|
1106
|
+
/**
|
|
1107
|
+
* Search jobs across all queues
|
|
1108
|
+
* Supports field:value syntax (e.g., "teamId:abc-123 invoice")
|
|
1109
|
+
* Optimized with parallel processing, early exits, and count checks
|
|
1110
|
+
*/
|
|
1111
|
+
async search(query, limit = 20) {
|
|
1112
|
+
const { filters, text } = this.parseSearchQuery(query);
|
|
1113
|
+
const lowerText = text.toLowerCase();
|
|
1114
|
+
const hasFilters = Object.keys(filters).length > 0;
|
|
1115
|
+
const hasText = lowerText.length > 0;
|
|
1116
|
+
if (!hasFilters && !hasText) {
|
|
1117
|
+
return [];
|
|
1118
|
+
}
|
|
1119
|
+
const queueEntries = Array.from(this.queues.entries());
|
|
1120
|
+
const queueChecks = await Promise.all(
|
|
1121
|
+
queueEntries.map(async ([queueName, queue]) => {
|
|
1122
|
+
const counts = await this.getCachedJobCounts(queue);
|
|
1123
|
+
const hasJobs = (counts.waiting || 0) > 0 || (counts.active || 0) > 0 || (counts.completed || 0) > 0 || (counts.failed || 0) > 0 || (counts.delayed || 0) > 0;
|
|
1124
|
+
return { queueName, queue, hasJobs };
|
|
1125
|
+
})
|
|
1126
|
+
);
|
|
1127
|
+
const relevantQueues = queueChecks.filter((q) => q.hasJobs);
|
|
1128
|
+
if (relevantQueues.length === 0) {
|
|
1129
|
+
return [];
|
|
1130
|
+
}
|
|
1131
|
+
const types = ["waiting", "active", "completed", "failed", "delayed"];
|
|
1132
|
+
const fetchLimit = Math.min(limit * 2, 50);
|
|
1133
|
+
const stringifiedDataCache = /* @__PURE__ */ new WeakMap();
|
|
1134
|
+
const queueResults = await Promise.allSettled(
|
|
1135
|
+
relevantQueues.map(async ({ queueName, queue }) => {
|
|
1136
|
+
const typeResults = await Promise.all(
|
|
1137
|
+
types.map(async (type) => {
|
|
1138
|
+
try {
|
|
1139
|
+
const jobs = await queue.getJobs(type, 0, fetchLimit);
|
|
1140
|
+
const matches = [];
|
|
1141
|
+
for (const job of jobs) {
|
|
1142
|
+
if (hasFilters && !this.jobMatchesFilters(job, filters)) {
|
|
1143
|
+
continue;
|
|
1144
|
+
}
|
|
1145
|
+
if (hasText) {
|
|
1146
|
+
const matchesId = job.id?.toLowerCase().includes(lowerText);
|
|
1147
|
+
const matchesName = job.name?.toLowerCase().includes(lowerText);
|
|
1148
|
+
let matchesData = false;
|
|
1149
|
+
if (!matchesId && !matchesName) {
|
|
1150
|
+
let stringifiedData = stringifiedDataCache.get(job);
|
|
1151
|
+
if (!stringifiedData) {
|
|
1152
|
+
stringifiedData = JSON.stringify(job.data).toLowerCase();
|
|
1153
|
+
stringifiedDataCache.set(job, stringifiedData);
|
|
1154
|
+
}
|
|
1155
|
+
matchesData = stringifiedData.includes(lowerText);
|
|
1156
|
+
}
|
|
1157
|
+
if (!matchesId && !matchesName && !matchesData) {
|
|
1158
|
+
continue;
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
matches.push({
|
|
1162
|
+
queue: queueName,
|
|
1163
|
+
job: await this.jobToInfo(
|
|
1164
|
+
job,
|
|
1165
|
+
"full",
|
|
1166
|
+
type
|
|
1167
|
+
)
|
|
1168
|
+
});
|
|
1169
|
+
}
|
|
1170
|
+
return matches;
|
|
1171
|
+
} catch {
|
|
1172
|
+
return [];
|
|
1173
|
+
}
|
|
1174
|
+
})
|
|
1175
|
+
);
|
|
1176
|
+
return typeResults.flat();
|
|
1177
|
+
})
|
|
1178
|
+
);
|
|
1179
|
+
const allMatches = [];
|
|
1180
|
+
for (const result of queueResults) {
|
|
1181
|
+
if (result.status === "fulfilled") {
|
|
1182
|
+
allMatches.push(...result.value);
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
return allMatches.slice(0, limit);
|
|
1186
|
+
}
|
|
1187
|
+
/**
|
|
1188
|
+
* Clean jobs from a queue
|
|
1189
|
+
*/
|
|
1190
|
+
async cleanJobs(queueName, status, grace = 0) {
|
|
1191
|
+
const queue = this.queues.get(queueName);
|
|
1192
|
+
if (!queue) return 0;
|
|
1193
|
+
const removed = await queue.clean(grace, 1e3, status);
|
|
1194
|
+
return removed.length;
|
|
1195
|
+
}
|
|
1196
|
+
/**
|
|
1197
|
+
* FAST PATH: Get latest runs without filters
|
|
1198
|
+
* Optimized for the common case of viewing newest jobs (timestamp desc, no filters)
|
|
1199
|
+
* - Single getJobs call per queue (not per status type)
|
|
1200
|
+
* - No count checks needed
|
|
1201
|
+
* - Minimal Redis round-trips
|
|
1202
|
+
*/
|
|
1203
|
+
async getLatestRuns(limit, start) {
|
|
1204
|
+
const queueEntries = Array.from(this.queues.entries());
|
|
1205
|
+
const numQueues = queueEntries.length;
|
|
1206
|
+
if (numQueues === 0) {
|
|
1207
|
+
return { data: [], total: -1, hasMore: false, cursor: void 0 };
|
|
1208
|
+
}
|
|
1209
|
+
const perQueueFetch = Math.max(5, Math.ceil((limit + 10) / numQueues) + 2);
|
|
1210
|
+
const allTypes = [
|
|
1211
|
+
"waiting",
|
|
1212
|
+
"active",
|
|
1213
|
+
"completed",
|
|
1214
|
+
"failed",
|
|
1215
|
+
"delayed"
|
|
1216
|
+
];
|
|
1217
|
+
const results = await Promise.all(
|
|
1218
|
+
queueEntries.map(async ([queueName, queue]) => {
|
|
1219
|
+
const jobs = await queue.getJobs(allTypes, 0, perQueueFetch);
|
|
1220
|
+
return jobs.map((job) => ({ job, queueName }));
|
|
1221
|
+
})
|
|
1222
|
+
);
|
|
1223
|
+
const allJobs = results.flat();
|
|
1224
|
+
allJobs.sort((a, b) => {
|
|
1225
|
+
const timeDiff = (b.job.timestamp || 0) - (a.job.timestamp || 0);
|
|
1226
|
+
if (timeDiff !== 0) return timeDiff;
|
|
1227
|
+
return a.queueName.localeCompare(b.queueName);
|
|
1228
|
+
});
|
|
1229
|
+
const jobsToConvert = allJobs.slice(start, start + limit);
|
|
1230
|
+
const runInfos = await Promise.all(
|
|
1231
|
+
jobsToConvert.map(async ({ job, queueName }) => {
|
|
1232
|
+
let state = "waiting";
|
|
1233
|
+
if (job.finishedOn) {
|
|
1234
|
+
state = job.failedReason ? "failed" : "completed";
|
|
1235
|
+
} else if (job.processedOn) {
|
|
1236
|
+
state = "active";
|
|
1237
|
+
} else if (job.delay && job.delay > 0) {
|
|
1238
|
+
state = "delayed";
|
|
1239
|
+
}
|
|
1240
|
+
const info = await this.jobToInfo(job, "list", state);
|
|
1241
|
+
return { ...info, queueName };
|
|
1242
|
+
})
|
|
1243
|
+
);
|
|
1244
|
+
const hasMore = allJobs.length > start + limit;
|
|
1245
|
+
return {
|
|
1246
|
+
data: runInfos,
|
|
1247
|
+
total: -1,
|
|
1248
|
+
// Don't calculate total for fast path - not needed for UI
|
|
1249
|
+
hasMore,
|
|
1250
|
+
cursor: hasMore ? String(start + runInfos.length) : void 0
|
|
1251
|
+
};
|
|
1252
|
+
}
|
|
1253
|
+
/**
|
|
1254
|
+
* Get all runs (jobs) across all queues with sorting and filtering
|
|
1255
|
+
* Uses fast path for common case (no filters, timestamp desc)
|
|
1256
|
+
*/
|
|
1257
|
+
async getAllRuns(limit = 50, start = 0, sort, filters) {
|
|
1258
|
+
const sortField = sort?.field ?? "timestamp";
|
|
1259
|
+
const sortDir = sort?.direction === "asc" ? 1 : -1;
|
|
1260
|
+
const hasFilters = !!(filters?.status || filters?.tags || filters?.text || filters?.timeRange);
|
|
1261
|
+
const isTimestampSort = sortField === "timestamp";
|
|
1262
|
+
if (!hasFilters && isTimestampSort && sortDir === -1) {
|
|
1263
|
+
return this.getLatestRuns(limit, start);
|
|
1264
|
+
}
|
|
1265
|
+
const queueEntries = Array.from(this.queues.entries());
|
|
1266
|
+
const types = filters?.status ? [filters.status] : ["waiting", "active", "completed", "failed", "delayed"];
|
|
1267
|
+
const hasTimeRange = !!filters?.timeRange;
|
|
1268
|
+
const numQueues = Math.max(queueEntries.length, 1);
|
|
1269
|
+
if (queueEntries.length === 0) {
|
|
1270
|
+
return {
|
|
1271
|
+
data: [],
|
|
1272
|
+
total: 0,
|
|
1273
|
+
hasMore: false,
|
|
1274
|
+
cursor: void 0
|
|
1275
|
+
};
|
|
1276
|
+
}
|
|
1277
|
+
const baseFetchPerQueue = Math.max(
|
|
1278
|
+
Math.ceil(limit * 2 / numQueues) + 3,
|
|
1279
|
+
5
|
|
1280
|
+
);
|
|
1281
|
+
let allJobs = [];
|
|
1282
|
+
const fetchFromQueue = async (queueName, queue, fetchCount) => {
|
|
1283
|
+
if (hasTimeRange && filters?.timeRange) {
|
|
1284
|
+
const timeRangeJobs = [];
|
|
1285
|
+
if (types.includes("completed")) {
|
|
1286
|
+
const completedJobs = await this.getJobsByTimeRange(
|
|
1287
|
+
queue,
|
|
1288
|
+
"completed",
|
|
1289
|
+
filters.timeRange.start,
|
|
1290
|
+
filters.timeRange.end,
|
|
1291
|
+
fetchCount
|
|
1292
|
+
);
|
|
1293
|
+
timeRangeJobs.push(
|
|
1294
|
+
...completedJobs.map((job) => ({
|
|
1295
|
+
job,
|
|
1296
|
+
state: "completed"
|
|
1297
|
+
}))
|
|
1298
|
+
);
|
|
1299
|
+
}
|
|
1300
|
+
if (types.includes("failed")) {
|
|
1301
|
+
const failedJobs = await this.getJobsByTimeRange(
|
|
1302
|
+
queue,
|
|
1303
|
+
"failed",
|
|
1304
|
+
filters.timeRange.start,
|
|
1305
|
+
filters.timeRange.end,
|
|
1306
|
+
fetchCount
|
|
1307
|
+
);
|
|
1308
|
+
timeRangeJobs.push(
|
|
1309
|
+
...failedJobs.map((job) => ({
|
|
1310
|
+
job,
|
|
1311
|
+
state: "failed"
|
|
1312
|
+
}))
|
|
1313
|
+
);
|
|
1314
|
+
}
|
|
1315
|
+
const otherTypes = types.filter(
|
|
1316
|
+
(t) => t !== "completed" && t !== "failed"
|
|
1317
|
+
);
|
|
1318
|
+
if (otherTypes.length > 0) {
|
|
1319
|
+
const otherJobArrays = await Promise.all(
|
|
1320
|
+
otherTypes.map(async (type) => {
|
|
1321
|
+
const jobs = await queue.getJobs(type, 0, fetchCount);
|
|
1322
|
+
return jobs.map((job) => ({ job, state: type }));
|
|
1323
|
+
})
|
|
1324
|
+
);
|
|
1325
|
+
timeRangeJobs.push(...otherJobArrays.flat());
|
|
1326
|
+
}
|
|
1327
|
+
return timeRangeJobs.map(({ job, state }) => ({
|
|
1328
|
+
job,
|
|
1329
|
+
queueName,
|
|
1330
|
+
state
|
|
1331
|
+
}));
|
|
1332
|
+
}
|
|
1333
|
+
if (filters?.status) {
|
|
1334
|
+
const jobs = await queue.getJobs(filters.status, 0, fetchCount);
|
|
1335
|
+
return jobs.map((job) => ({
|
|
1336
|
+
job,
|
|
1337
|
+
queueName,
|
|
1338
|
+
state: filters.status
|
|
1339
|
+
}));
|
|
1340
|
+
}
|
|
1341
|
+
const jobArrays = await Promise.all(
|
|
1342
|
+
types.map(async (type) => {
|
|
1343
|
+
const jobs = await queue.getJobs(type, 0, fetchCount);
|
|
1344
|
+
return jobs.map((job) => ({ job, state: type }));
|
|
1345
|
+
})
|
|
1346
|
+
);
|
|
1347
|
+
return jobArrays.flat().map(({ job, state }) => ({ job, queueName, state }));
|
|
1348
|
+
};
|
|
1349
|
+
const results = await Promise.all(
|
|
1350
|
+
queueEntries.map(
|
|
1351
|
+
([queueName, queue]) => fetchFromQueue(queueName, queue, baseFetchPerQueue)
|
|
1352
|
+
)
|
|
1353
|
+
);
|
|
1354
|
+
allJobs = results.flat();
|
|
1355
|
+
if (filters) {
|
|
1356
|
+
allJobs = allJobs.filter(
|
|
1357
|
+
({ job }) => this.jobMatchesAllFilters(job, filters)
|
|
1358
|
+
);
|
|
1359
|
+
}
|
|
1360
|
+
if (isTimestampSort) {
|
|
1361
|
+
allJobs.sort((a, b) => {
|
|
1362
|
+
const aTime = a.job.timestamp || 0;
|
|
1363
|
+
const bTime = b.job.timestamp || 0;
|
|
1364
|
+
const timeDiff = sortDir === -1 ? bTime - aTime : aTime - bTime;
|
|
1365
|
+
if (timeDiff !== 0) return timeDiff;
|
|
1366
|
+
const queueDiff = a.queueName.localeCompare(b.queueName);
|
|
1367
|
+
if (queueDiff !== 0) return queueDiff;
|
|
1368
|
+
return (a.job.id || "").localeCompare(b.job.id || "");
|
|
1369
|
+
});
|
|
1370
|
+
}
|
|
1371
|
+
const jobsToConvert = allJobs.slice(start, start + limit);
|
|
1372
|
+
const runInfos = await Promise.all(
|
|
1373
|
+
jobsToConvert.map(async ({ job, queueName, state }) => {
|
|
1374
|
+
const info = await this.jobToInfo(job, "list", state);
|
|
1375
|
+
return { ...info, queueName };
|
|
1376
|
+
})
|
|
1377
|
+
);
|
|
1378
|
+
if (!isTimestampSort) {
|
|
1379
|
+
runInfos.sort((a, b) => {
|
|
1380
|
+
const aVal = this.getSortValueForList(a, sortField);
|
|
1381
|
+
const bVal = this.getSortValueForList(b, sortField);
|
|
1382
|
+
if (aVal < bVal) return -1 * sortDir;
|
|
1383
|
+
if (aVal > bVal) return 1 * sortDir;
|
|
1384
|
+
return 0;
|
|
1385
|
+
});
|
|
1386
|
+
}
|
|
1387
|
+
const hasMore = allJobs.length > start + limit;
|
|
1388
|
+
return {
|
|
1389
|
+
data: runInfos,
|
|
1390
|
+
total: -1,
|
|
1391
|
+
// Don't calculate total - not needed for UI
|
|
1392
|
+
hasMore,
|
|
1393
|
+
cursor: hasMore ? String(start + runInfos.length) : void 0
|
|
1394
|
+
};
|
|
1395
|
+
}
|
|
1396
|
+
/**
|
|
1397
|
+
* Get all schedulers (repeatable and delayed jobs) with sorting
|
|
1398
|
+
*/
|
|
1399
|
+
async getSchedulers(repeatableSort, delayedSort) {
|
|
1400
|
+
const repeatable = [];
|
|
1401
|
+
const delayed = [];
|
|
1402
|
+
const queueEntries = Array.from(this.queues.entries());
|
|
1403
|
+
const results = await Promise.all(
|
|
1404
|
+
queueEntries.map(async ([queueName, queue]) => {
|
|
1405
|
+
const [repeatableJobs, delayedJobs] = await Promise.all([
|
|
1406
|
+
queue.getRepeatableJobs(),
|
|
1407
|
+
queue.getJobs("delayed", 0, 50)
|
|
1408
|
+
]);
|
|
1409
|
+
return { queueName, repeatableJobs, delayedJobs };
|
|
1410
|
+
})
|
|
1411
|
+
);
|
|
1412
|
+
for (const { queueName, repeatableJobs, delayedJobs } of results) {
|
|
1413
|
+
for (const job of repeatableJobs) {
|
|
1414
|
+
repeatable.push({
|
|
1415
|
+
key: job.key,
|
|
1416
|
+
name: job.name || "unnamed",
|
|
1417
|
+
queueName,
|
|
1418
|
+
pattern: job.pattern ?? void 0,
|
|
1419
|
+
every: job.every ? Number(job.every) : void 0,
|
|
1420
|
+
next: job.next ?? void 0,
|
|
1421
|
+
endDate: job.endDate ?? void 0,
|
|
1422
|
+
tz: job.tz ?? void 0
|
|
1423
|
+
});
|
|
1424
|
+
}
|
|
1425
|
+
for (const job of delayedJobs) {
|
|
1426
|
+
const delay = job.opts.delay || 0;
|
|
1427
|
+
delayed.push({
|
|
1428
|
+
id: job.id || "",
|
|
1429
|
+
name: job.name,
|
|
1430
|
+
queueName,
|
|
1431
|
+
delay,
|
|
1432
|
+
processAt: job.timestamp + delay,
|
|
1433
|
+
data: job.data
|
|
1434
|
+
});
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
const repeatableField = repeatableSort?.field ?? "name";
|
|
1438
|
+
const repeatableDir = repeatableSort?.direction === "desc" ? -1 : 1;
|
|
1439
|
+
repeatable.sort((a, b) => {
|
|
1440
|
+
const aVal = this.getSchedulerSortValue(a, repeatableField);
|
|
1441
|
+
const bVal = this.getSchedulerSortValue(b, repeatableField);
|
|
1442
|
+
if (aVal < bVal) return -1 * repeatableDir;
|
|
1443
|
+
if (aVal > bVal) return 1 * repeatableDir;
|
|
1444
|
+
return 0;
|
|
1445
|
+
});
|
|
1446
|
+
const delayedField = delayedSort?.field ?? "processAt";
|
|
1447
|
+
const delayedDir = delayedSort?.direction === "desc" ? -1 : 1;
|
|
1448
|
+
delayed.sort((a, b) => {
|
|
1449
|
+
const aVal = this.getDelayedSortValue(a, delayedField);
|
|
1450
|
+
const bVal = this.getDelayedSortValue(b, delayedField);
|
|
1451
|
+
if (aVal < bVal) return -1 * delayedDir;
|
|
1452
|
+
if (aVal > bVal) return 1 * delayedDir;
|
|
1453
|
+
return 0;
|
|
1454
|
+
});
|
|
1455
|
+
return { repeatable, delayed };
|
|
1456
|
+
}
|
|
1457
|
+
/**
|
|
1458
|
+
* Enqueue a new job (for testing)
|
|
1459
|
+
*/
|
|
1460
|
+
async enqueueJob(request) {
|
|
1461
|
+
const queue = this.queues.get(request.queueName);
|
|
1462
|
+
if (!queue) {
|
|
1463
|
+
throw new Error(`Queue "${request.queueName}" not found`);
|
|
1464
|
+
}
|
|
1465
|
+
const job = await queue.add(request.jobName, request.data, {
|
|
1466
|
+
delay: request.opts?.delay,
|
|
1467
|
+
priority: request.opts?.priority,
|
|
1468
|
+
attempts: request.opts?.attempts
|
|
1469
|
+
});
|
|
1470
|
+
return { id: job.id || "" };
|
|
1471
|
+
}
|
|
1472
|
+
/**
|
|
1473
|
+
* Extract tag values from job data based on configured tag fields
|
|
1474
|
+
*/
|
|
1475
|
+
extractTags(data) {
|
|
1476
|
+
if (!this.tagFields.length || !data || typeof data !== "object") {
|
|
1477
|
+
return void 0;
|
|
1478
|
+
}
|
|
1479
|
+
const tags = {};
|
|
1480
|
+
const dataObj = data;
|
|
1481
|
+
for (const field of this.tagFields) {
|
|
1482
|
+
const value = dataObj[field];
|
|
1483
|
+
if (value !== void 0 && (typeof value === "string" || typeof value === "number" || typeof value === "boolean" || value === null)) {
|
|
1484
|
+
tags[field] = value;
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
return Object.keys(tags).length > 0 ? tags : void 0;
|
|
1488
|
+
}
|
|
1489
|
+
/**
|
|
1490
|
+
* Get unique values for a specific tag field across all jobs
|
|
1491
|
+
*/
|
|
1492
|
+
async getTagValues(field, limit = 50) {
|
|
1493
|
+
const valueMap = /* @__PURE__ */ new Map();
|
|
1494
|
+
const types = ["waiting", "active", "completed", "failed", "delayed"];
|
|
1495
|
+
const queueEntries = Array.from(this.queues.entries());
|
|
1496
|
+
const queueResults = await Promise.all(
|
|
1497
|
+
queueEntries.map(async ([, queue]) => {
|
|
1498
|
+
const jobArrays = await Promise.all(
|
|
1499
|
+
types.map((type) => queue.getJobs(type, 0, 100))
|
|
1500
|
+
);
|
|
1501
|
+
return jobArrays.flat();
|
|
1502
|
+
})
|
|
1503
|
+
);
|
|
1504
|
+
for (const jobs of queueResults) {
|
|
1505
|
+
for (const job of jobs) {
|
|
1506
|
+
if (job.data && typeof job.data === "object") {
|
|
1507
|
+
const dataObj = job.data;
|
|
1508
|
+
const value = dataObj[field];
|
|
1509
|
+
if (value !== void 0 && value !== null) {
|
|
1510
|
+
const strValue = String(value);
|
|
1511
|
+
valueMap.set(strValue, (valueMap.get(strValue) || 0) + 1);
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
const sorted = Array.from(valueMap.entries()).sort((a, b) => b[1] - a[1]).slice(0, limit).map(([value, count]) => ({ value, count }));
|
|
1517
|
+
return sorted;
|
|
1518
|
+
}
|
|
1519
|
+
/**
|
|
1520
|
+
* Get sortable value from JobInfo/RunInfo
|
|
1521
|
+
*/
|
|
1522
|
+
getSortValue(item, field) {
|
|
1523
|
+
switch (field) {
|
|
1524
|
+
case "timestamp":
|
|
1525
|
+
return item.timestamp ?? 0;
|
|
1526
|
+
case "name":
|
|
1527
|
+
return item.name.toLowerCase();
|
|
1528
|
+
case "status":
|
|
1529
|
+
return item.status;
|
|
1530
|
+
case "duration":
|
|
1531
|
+
return item.duration ?? 0;
|
|
1532
|
+
case "queueName":
|
|
1533
|
+
return "queueName" in item ? item.queueName.toLowerCase() : "";
|
|
1534
|
+
case "processedOn":
|
|
1535
|
+
return item.processedOn ?? 0;
|
|
1536
|
+
default:
|
|
1537
|
+
return item.timestamp ?? 0;
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
1540
|
+
/**
|
|
1541
|
+
* Get sortable value from RunInfoList (lightweight version)
|
|
1542
|
+
*/
|
|
1543
|
+
getSortValueForList(item, field) {
|
|
1544
|
+
switch (field) {
|
|
1545
|
+
case "timestamp":
|
|
1546
|
+
return item.timestamp ?? 0;
|
|
1547
|
+
case "name":
|
|
1548
|
+
return item.name.toLowerCase();
|
|
1549
|
+
case "status":
|
|
1550
|
+
return item.status;
|
|
1551
|
+
case "duration":
|
|
1552
|
+
return item.duration ?? 0;
|
|
1553
|
+
case "queueName":
|
|
1554
|
+
return item.queueName.toLowerCase();
|
|
1555
|
+
case "processedOn":
|
|
1556
|
+
return item.processedOn ?? 0;
|
|
1557
|
+
default:
|
|
1558
|
+
return item.timestamp ?? 0;
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
/**
|
|
1562
|
+
* Get sortable value from SchedulerInfo
|
|
1563
|
+
*/
|
|
1564
|
+
getSchedulerSortValue(item, field) {
|
|
1565
|
+
switch (field) {
|
|
1566
|
+
case "name":
|
|
1567
|
+
return item.name.toLowerCase();
|
|
1568
|
+
case "queueName":
|
|
1569
|
+
return item.queueName.toLowerCase();
|
|
1570
|
+
case "pattern":
|
|
1571
|
+
return item.pattern?.toLowerCase() ?? "";
|
|
1572
|
+
case "next":
|
|
1573
|
+
return item.next ?? 0;
|
|
1574
|
+
case "tz":
|
|
1575
|
+
return item.tz?.toLowerCase() ?? "";
|
|
1576
|
+
default:
|
|
1577
|
+
return item.name.toLowerCase();
|
|
1578
|
+
}
|
|
1579
|
+
}
|
|
1580
|
+
/**
|
|
1581
|
+
* Get sortable value from DelayedJobInfo
|
|
1582
|
+
*/
|
|
1583
|
+
getDelayedSortValue(item, field) {
|
|
1584
|
+
switch (field) {
|
|
1585
|
+
case "name":
|
|
1586
|
+
return item.name.toLowerCase();
|
|
1587
|
+
case "queueName":
|
|
1588
|
+
return item.queueName.toLowerCase();
|
|
1589
|
+
case "processAt":
|
|
1590
|
+
return item.processAt;
|
|
1591
|
+
case "delay":
|
|
1592
|
+
return item.delay;
|
|
1593
|
+
default:
|
|
1594
|
+
return item.processAt;
|
|
1595
|
+
}
|
|
1596
|
+
}
|
|
1597
|
+
/**
|
|
1598
|
+
* Convert a BullMQ Job to JobInfo or RunInfoList
|
|
1599
|
+
* @param job - The BullMQ job to convert
|
|
1600
|
+
* @param fields - "list" for lightweight list view, "full" for complete job details
|
|
1601
|
+
* @param knownState - Optional: skip getState() call if state is already known from fetch
|
|
1602
|
+
*/
|
|
1603
|
+
async jobToInfo(job, _fields = "full", knownState) {
|
|
1604
|
+
let state = knownState;
|
|
1605
|
+
if (!state) {
|
|
1606
|
+
const cacheKey = `${job.queueName}:${job.id}`;
|
|
1607
|
+
state = this.jobStateCache.get(cacheKey);
|
|
1608
|
+
if (!state) {
|
|
1609
|
+
state = await job.getState();
|
|
1610
|
+
this.jobStateCache.set(cacheKey, state);
|
|
1611
|
+
}
|
|
1612
|
+
}
|
|
1613
|
+
const duration = job.finishedOn && job.processedOn ? job.finishedOn - job.processedOn : void 0;
|
|
1614
|
+
let progress = 0;
|
|
1615
|
+
if (typeof job.progress === "number") {
|
|
1616
|
+
progress = job.progress;
|
|
1617
|
+
} else if (typeof job.progress === "object" && job.progress !== null) {
|
|
1618
|
+
progress = job.progress;
|
|
1619
|
+
}
|
|
1620
|
+
const tags = this.extractTags(job.data);
|
|
1621
|
+
let parent;
|
|
1622
|
+
if (job.parent?.id) {
|
|
1623
|
+
parent = {
|
|
1624
|
+
id: job.parent.id,
|
|
1625
|
+
queueName: job.parent.queueKey?.split(":")[1] || job.parent.queueKey || ""
|
|
1626
|
+
};
|
|
1627
|
+
} else if (job.parentKey) {
|
|
1628
|
+
const parts = job.parentKey.split(":");
|
|
1629
|
+
if (parts.length >= 3) {
|
|
1630
|
+
parent = {
|
|
1631
|
+
id: parts[parts.length - 1] || "",
|
|
1632
|
+
queueName: parts[parts.length - 2] || ""
|
|
1633
|
+
};
|
|
1634
|
+
}
|
|
1635
|
+
}
|
|
1636
|
+
return {
|
|
1637
|
+
id: job.id || "",
|
|
1638
|
+
name: job.name,
|
|
1639
|
+
data: job.data,
|
|
1640
|
+
opts: {
|
|
1641
|
+
attempts: job.opts.attempts,
|
|
1642
|
+
delay: job.opts.delay,
|
|
1643
|
+
priority: job.opts.priority
|
|
1644
|
+
},
|
|
1645
|
+
progress,
|
|
1646
|
+
attemptsMade: job.attemptsMade,
|
|
1647
|
+
processedOn: job.processedOn,
|
|
1648
|
+
finishedOn: job.finishedOn,
|
|
1649
|
+
timestamp: job.timestamp,
|
|
1650
|
+
failedReason: job.failedReason,
|
|
1651
|
+
stacktrace: job.stacktrace ?? void 0,
|
|
1652
|
+
returnvalue: job.returnvalue,
|
|
1653
|
+
status: state,
|
|
1654
|
+
duration,
|
|
1655
|
+
tags,
|
|
1656
|
+
parent
|
|
1657
|
+
};
|
|
1658
|
+
}
|
|
1659
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1660
|
+
// Bulk Operations
|
|
1661
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1662
|
+
/**
|
|
1663
|
+
* Retry multiple jobs across queues
|
|
1664
|
+
* Processed in parallel for better performance
|
|
1665
|
+
*/
|
|
1666
|
+
async bulkRetry(jobs) {
|
|
1667
|
+
const results = await Promise.allSettled(
|
|
1668
|
+
jobs.map(async ({ queueName, jobId }) => {
|
|
1669
|
+
const queue = this.queues.get(queueName);
|
|
1670
|
+
if (!queue) {
|
|
1671
|
+
throw new Error("Queue not found");
|
|
1672
|
+
}
|
|
1673
|
+
const job = await queue.getJob(jobId);
|
|
1674
|
+
if (!job) {
|
|
1675
|
+
throw new Error("Job not found");
|
|
1676
|
+
}
|
|
1677
|
+
await job.retry();
|
|
1678
|
+
this.invalidateJobCache(queueName, jobId);
|
|
1679
|
+
return { success: true };
|
|
1680
|
+
})
|
|
1681
|
+
);
|
|
1682
|
+
let success = 0;
|
|
1683
|
+
let failed = 0;
|
|
1684
|
+
for (const result of results) {
|
|
1685
|
+
if (result.status === "fulfilled") {
|
|
1686
|
+
success++;
|
|
1687
|
+
} else {
|
|
1688
|
+
failed++;
|
|
1689
|
+
}
|
|
1690
|
+
}
|
|
1691
|
+
return { success, failed };
|
|
1692
|
+
}
|
|
1693
|
+
/**
|
|
1694
|
+
* Delete multiple jobs across queues
|
|
1695
|
+
* Processed in parallel for better performance
|
|
1696
|
+
*/
|
|
1697
|
+
async bulkDelete(jobs) {
|
|
1698
|
+
const results = await Promise.allSettled(
|
|
1699
|
+
jobs.map(async ({ queueName, jobId }) => {
|
|
1700
|
+
const queue = this.queues.get(queueName);
|
|
1701
|
+
if (!queue) {
|
|
1702
|
+
throw new Error("Queue not found");
|
|
1703
|
+
}
|
|
1704
|
+
const job = await queue.getJob(jobId);
|
|
1705
|
+
if (!job) {
|
|
1706
|
+
throw new Error("Job not found");
|
|
1707
|
+
}
|
|
1708
|
+
await job.remove();
|
|
1709
|
+
this.invalidateJobCache(queueName, jobId);
|
|
1710
|
+
return { success: true };
|
|
1711
|
+
})
|
|
1712
|
+
);
|
|
1713
|
+
let success = 0;
|
|
1714
|
+
let failed = 0;
|
|
1715
|
+
for (const result of results) {
|
|
1716
|
+
if (result.status === "fulfilled") {
|
|
1717
|
+
success++;
|
|
1718
|
+
} else {
|
|
1719
|
+
failed++;
|
|
1720
|
+
}
|
|
1721
|
+
}
|
|
1722
|
+
return { success, failed };
|
|
1723
|
+
}
|
|
1724
|
+
/**
|
|
1725
|
+
* Promote multiple delayed jobs across queues (move to waiting)
|
|
1726
|
+
* Processed in parallel for better performance
|
|
1727
|
+
*/
|
|
1728
|
+
async bulkPromote(jobs) {
|
|
1729
|
+
const results = await Promise.allSettled(
|
|
1730
|
+
jobs.map(async ({ queueName, jobId }) => {
|
|
1731
|
+
const queue = this.queues.get(queueName);
|
|
1732
|
+
if (!queue) {
|
|
1733
|
+
throw new Error("Queue not found");
|
|
1734
|
+
}
|
|
1735
|
+
const job = await queue.getJob(jobId);
|
|
1736
|
+
if (!job) {
|
|
1737
|
+
throw new Error("Job not found");
|
|
1738
|
+
}
|
|
1739
|
+
await job.promote();
|
|
1740
|
+
this.invalidateJobCache(queueName, jobId);
|
|
1741
|
+
return { success: true };
|
|
1742
|
+
})
|
|
1743
|
+
);
|
|
1744
|
+
let success = 0;
|
|
1745
|
+
let failed = 0;
|
|
1746
|
+
for (const result of results) {
|
|
1747
|
+
if (result.status === "fulfilled") {
|
|
1748
|
+
success++;
|
|
1749
|
+
} else {
|
|
1750
|
+
failed++;
|
|
1751
|
+
}
|
|
1752
|
+
}
|
|
1753
|
+
return { success, failed };
|
|
1754
|
+
}
|
|
1755
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1756
|
+
// Flow Operations
|
|
1757
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1758
|
+
/**
|
|
1759
|
+
* Get all flows (jobs that have children or are part of a flow) - cached
|
|
1760
|
+
* Optimized to focus on waiting-children type first and early exit
|
|
1761
|
+
*/
|
|
1762
|
+
async getFlows(limit = 50) {
|
|
1763
|
+
if (!this.flowProducer) {
|
|
1764
|
+
return [];
|
|
1765
|
+
}
|
|
1766
|
+
return this.cached(`flows:${limit}`, this.CACHE_TTL.flows, async () => {
|
|
1767
|
+
const queueEntries = Array.from(this.queues.entries());
|
|
1768
|
+
const queueChecks = await Promise.all(
|
|
1769
|
+
queueEntries.map(async ([queueName, queue]) => {
|
|
1770
|
+
const counts = await this.getCachedJobCounts(queue);
|
|
1771
|
+
const hasRelevantJobs = (counts.waiting || 0) > 0 || (counts["waiting-children"] || 0) > 0 || (counts.active || 0) > 0;
|
|
1772
|
+
return { queueName, queue, hasRelevantJobs };
|
|
1773
|
+
})
|
|
1774
|
+
);
|
|
1775
|
+
const relevantQueues = queueChecks.filter((q) => q.hasRelevantJobs);
|
|
1776
|
+
if (relevantQueues.length === 0) {
|
|
1777
|
+
return [];
|
|
1778
|
+
}
|
|
1779
|
+
const queueResults = await Promise.all(
|
|
1780
|
+
relevantQueues.map(async ({ queueName, queue }) => {
|
|
1781
|
+
try {
|
|
1782
|
+
const waitingChildrenJobs = await queue.getJobs(
|
|
1783
|
+
["waiting-children"],
|
|
1784
|
+
0,
|
|
1785
|
+
50
|
|
1786
|
+
);
|
|
1787
|
+
if (waitingChildrenJobs.length >= limit) {
|
|
1788
|
+
return { queueName, jobs: waitingChildrenJobs };
|
|
1789
|
+
}
|
|
1790
|
+
const otherTypes = [
|
|
1791
|
+
"waiting",
|
|
1792
|
+
"active",
|
|
1793
|
+
"completed",
|
|
1794
|
+
"failed",
|
|
1795
|
+
"delayed"
|
|
1796
|
+
];
|
|
1797
|
+
const otherJobArrays = await Promise.all(
|
|
1798
|
+
otherTypes.map(async (type) => {
|
|
1799
|
+
try {
|
|
1800
|
+
return await queue.getJobs(type, 0, 30);
|
|
1801
|
+
} catch {
|
|
1802
|
+
return [];
|
|
1803
|
+
}
|
|
1804
|
+
})
|
|
1805
|
+
);
|
|
1806
|
+
const allJobs = [...waitingChildrenJobs, ...otherJobArrays.flat()];
|
|
1807
|
+
return { queueName, jobs: allJobs };
|
|
1808
|
+
} catch {
|
|
1809
|
+
return { queueName, jobs: [] };
|
|
1810
|
+
}
|
|
1811
|
+
})
|
|
1812
|
+
);
|
|
1813
|
+
const seenJobIds = /* @__PURE__ */ new Set();
|
|
1814
|
+
const potentialRoots = [];
|
|
1815
|
+
for (const { queueName, jobs } of queueResults) {
|
|
1816
|
+
if (potentialRoots.length >= limit * 2) {
|
|
1817
|
+
break;
|
|
1818
|
+
}
|
|
1819
|
+
for (const job of jobs) {
|
|
1820
|
+
if (!job?.id) continue;
|
|
1821
|
+
const jobKey = `${queueName}:${job.id}`;
|
|
1822
|
+
if (seenJobIds.has(jobKey)) continue;
|
|
1823
|
+
seenJobIds.add(jobKey);
|
|
1824
|
+
const hasParent = !!job.parent || !!job.parentKey;
|
|
1825
|
+
if (!hasParent) {
|
|
1826
|
+
potentialRoots.push({ queueName, job });
|
|
1827
|
+
if (potentialRoots.length >= limit * 2) {
|
|
1828
|
+
break;
|
|
1829
|
+
}
|
|
1830
|
+
}
|
|
1831
|
+
}
|
|
1832
|
+
}
|
|
1833
|
+
const batchSize = 20;
|
|
1834
|
+
const flows = [];
|
|
1835
|
+
for (let i = 0; i < potentialRoots.length && flows.length < limit; i += batchSize) {
|
|
1836
|
+
const batch = potentialRoots.slice(i, i + batchSize);
|
|
1837
|
+
const batchResults = await Promise.all(
|
|
1838
|
+
batch.map(async ({ queueName, job }) => {
|
|
1839
|
+
try {
|
|
1840
|
+
const flowTree = await this.flowProducer.getFlow({
|
|
1841
|
+
id: job.id,
|
|
1842
|
+
queueName
|
|
1843
|
+
});
|
|
1844
|
+
if (flowTree?.children && flowTree.children.length > 0) {
|
|
1845
|
+
const stats = this.countFlowStats(flowTree);
|
|
1846
|
+
const state = await job.getState();
|
|
1847
|
+
return {
|
|
1848
|
+
id: job.id,
|
|
1849
|
+
name: job.name,
|
|
1850
|
+
queueName,
|
|
1851
|
+
status: state,
|
|
1852
|
+
totalJobs: stats.total,
|
|
1853
|
+
completedJobs: stats.completed,
|
|
1854
|
+
failedJobs: stats.failed,
|
|
1855
|
+
timestamp: job.timestamp,
|
|
1856
|
+
duration: job.finishedOn && job.processedOn ? job.finishedOn - job.processedOn : void 0
|
|
1857
|
+
};
|
|
1858
|
+
}
|
|
1859
|
+
} catch {
|
|
1860
|
+
}
|
|
1861
|
+
return null;
|
|
1862
|
+
})
|
|
1863
|
+
);
|
|
1864
|
+
for (const result of batchResults) {
|
|
1865
|
+
if (result && flows.length < limit) {
|
|
1866
|
+
flows.push(result);
|
|
1867
|
+
}
|
|
1868
|
+
}
|
|
1869
|
+
}
|
|
1870
|
+
return flows.sort((a, b) => b.timestamp - a.timestamp);
|
|
1871
|
+
});
|
|
1872
|
+
}
|
|
1873
|
+
/**
|
|
1874
|
+
* Get a single flow tree by root job ID
|
|
1875
|
+
*/
|
|
1876
|
+
async getFlow(queueName, jobId) {
|
|
1877
|
+
if (!this.flowProducer) {
|
|
1878
|
+
return null;
|
|
1879
|
+
}
|
|
1880
|
+
try {
|
|
1881
|
+
const flowTree = await this.flowProducer.getFlow({
|
|
1882
|
+
id: jobId,
|
|
1883
|
+
queueName
|
|
1884
|
+
});
|
|
1885
|
+
if (!flowTree) {
|
|
1886
|
+
return null;
|
|
1887
|
+
}
|
|
1888
|
+
return this.convertFlowTree(flowTree);
|
|
1889
|
+
} catch {
|
|
1890
|
+
return null;
|
|
1891
|
+
}
|
|
1892
|
+
}
|
|
1893
|
+
/**
|
|
1894
|
+
* Create a new flow
|
|
1895
|
+
*/
|
|
1896
|
+
async createFlow(request) {
|
|
1897
|
+
if (!this.flowProducer) {
|
|
1898
|
+
throw new Error("FlowProducer not initialized");
|
|
1899
|
+
}
|
|
1900
|
+
const flowJob = this.buildFlowJob(request);
|
|
1901
|
+
const result = await this.flowProducer.add(flowJob);
|
|
1902
|
+
return { id: result.job.id || "" };
|
|
1903
|
+
}
|
|
1904
|
+
/**
|
|
1905
|
+
* Build a FlowJob from CreateFlowRequest or CreateFlowChildRequest
|
|
1906
|
+
*/
|
|
1907
|
+
buildFlowJob(request) {
|
|
1908
|
+
const result = {
|
|
1909
|
+
name: request.name,
|
|
1910
|
+
queueName: request.queueName,
|
|
1911
|
+
data: request.data || {}
|
|
1912
|
+
};
|
|
1913
|
+
if (request.children && request.children.length > 0) {
|
|
1914
|
+
result.children = request.children.map(
|
|
1915
|
+
(child) => this.buildFlowJob(child)
|
|
1916
|
+
);
|
|
1917
|
+
}
|
|
1918
|
+
return result;
|
|
1919
|
+
}
|
|
1920
|
+
/**
|
|
1921
|
+
* Convert BullMQ flow tree to our FlowNode structure
|
|
1922
|
+
*/
|
|
1923
|
+
async convertFlowTree(tree) {
|
|
1924
|
+
const job = tree.job;
|
|
1925
|
+
const state = await job.getState();
|
|
1926
|
+
const duration = job.finishedOn && job.processedOn ? job.finishedOn - job.processedOn : void 0;
|
|
1927
|
+
const jobInfo = {
|
|
1928
|
+
id: job.id || "",
|
|
1929
|
+
name: job.name,
|
|
1930
|
+
data: job.data,
|
|
1931
|
+
opts: {
|
|
1932
|
+
attempts: job.opts?.attempts,
|
|
1933
|
+
delay: job.opts?.delay,
|
|
1934
|
+
priority: job.opts?.priority
|
|
1935
|
+
},
|
|
1936
|
+
progress: typeof job.progress === "number" ? job.progress : typeof job.progress === "object" ? job.progress : 0,
|
|
1937
|
+
attemptsMade: job.attemptsMade || 0,
|
|
1938
|
+
processedOn: job.processedOn,
|
|
1939
|
+
finishedOn: job.finishedOn,
|
|
1940
|
+
timestamp: job.timestamp,
|
|
1941
|
+
failedReason: job.failedReason,
|
|
1942
|
+
stacktrace: job.stacktrace ?? void 0,
|
|
1943
|
+
returnvalue: job.returnvalue,
|
|
1944
|
+
status: state,
|
|
1945
|
+
duration,
|
|
1946
|
+
tags: this.extractTags(job.data)
|
|
1947
|
+
};
|
|
1948
|
+
const children = [];
|
|
1949
|
+
if (tree.children && tree.children.length > 0) {
|
|
1950
|
+
for (const child of tree.children) {
|
|
1951
|
+
children.push(await this.convertFlowTree(child));
|
|
1952
|
+
}
|
|
1953
|
+
}
|
|
1954
|
+
return {
|
|
1955
|
+
job: jobInfo,
|
|
1956
|
+
queueName: job.queueName || tree.queueName || "",
|
|
1957
|
+
children: children.length > 0 ? children : void 0
|
|
1958
|
+
};
|
|
1959
|
+
}
|
|
1960
|
+
/**
|
|
1961
|
+
* Count statistics for a flow tree
|
|
1962
|
+
*/
|
|
1963
|
+
countFlowStats(tree) {
|
|
1964
|
+
let total = 1;
|
|
1965
|
+
let completed = 0;
|
|
1966
|
+
let failed = 0;
|
|
1967
|
+
const job = tree.job;
|
|
1968
|
+
if (job.finishedOn && !job.failedReason) {
|
|
1969
|
+
completed = 1;
|
|
1970
|
+
} else if (job.failedReason) {
|
|
1971
|
+
failed = 1;
|
|
1972
|
+
}
|
|
1973
|
+
if (tree.children) {
|
|
1974
|
+
for (const child of tree.children) {
|
|
1975
|
+
const childStats = this.countFlowStats(child);
|
|
1976
|
+
total += childStats.total;
|
|
1977
|
+
completed += childStats.completed;
|
|
1978
|
+
failed += childStats.failed;
|
|
1979
|
+
}
|
|
1980
|
+
}
|
|
1981
|
+
return { total, completed, failed };
|
|
1982
|
+
}
|
|
1983
|
+
};
|
|
1984
|
+
|
|
1985
|
+
// src/core/workbench.ts
|
|
1986
|
+
var WorkbenchCore = class {
|
|
1987
|
+
options;
|
|
1988
|
+
queueManager;
|
|
1989
|
+
constructor(options) {
|
|
1990
|
+
const opts = Array.isArray(options) ? { queues: options } : options;
|
|
1991
|
+
this.options = {
|
|
1992
|
+
title: "Workbench",
|
|
1993
|
+
readonly: false,
|
|
1994
|
+
...opts
|
|
1995
|
+
};
|
|
1996
|
+
if (!this.options.queues || this.options.queues.length === 0) {
|
|
1997
|
+
throw new Error(
|
|
1998
|
+
"Workbench requires at least one queue. Pass queues directly or provide a redis connection for auto-discovery."
|
|
1999
|
+
);
|
|
2000
|
+
}
|
|
2001
|
+
this.queueManager = new QueueManager(
|
|
2002
|
+
this.options.queues,
|
|
2003
|
+
this.options.tags || []
|
|
2004
|
+
);
|
|
2005
|
+
}
|
|
2006
|
+
/**
|
|
2007
|
+
* Get the queue manager instance
|
|
2008
|
+
*/
|
|
2009
|
+
getQueueManager() {
|
|
2010
|
+
return this.queueManager;
|
|
2011
|
+
}
|
|
2012
|
+
/**
|
|
2013
|
+
* Check if authentication is required
|
|
2014
|
+
*/
|
|
2015
|
+
requiresAuth() {
|
|
2016
|
+
return !!(this.options.auth?.username && this.options.auth?.password);
|
|
2017
|
+
}
|
|
2018
|
+
/**
|
|
2019
|
+
* Validate authentication credentials
|
|
2020
|
+
*/
|
|
2021
|
+
validateAuth(username, password) {
|
|
2022
|
+
if (!this.requiresAuth()) return true;
|
|
2023
|
+
return username === this.options.auth?.username && password === this.options.auth?.password;
|
|
2024
|
+
}
|
|
2025
|
+
/**
|
|
2026
|
+
* Get dashboard configuration for the UI
|
|
2027
|
+
*/
|
|
2028
|
+
getConfig() {
|
|
2029
|
+
return {
|
|
2030
|
+
title: this.options.title,
|
|
2031
|
+
logo: this.options.logo,
|
|
2032
|
+
readonly: this.options.readonly,
|
|
2033
|
+
queues: this.queueManager.getQueueNames(),
|
|
2034
|
+
tags: this.queueManager.getTagFields()
|
|
2035
|
+
};
|
|
2036
|
+
}
|
|
2037
|
+
};
|
|
2038
|
+
|
|
2039
|
+
// src/index.ts
|
|
2040
|
+
var UI_DIST_PATH = join(dirname(fileURLToPath(import.meta.url)), "ui");
|
|
2041
|
+
export {
|
|
2042
|
+
QueueManager,
|
|
2043
|
+
UI_DIST_PATH,
|
|
2044
|
+
WorkbenchCore,
|
|
2045
|
+
createApiRoutes
|
|
2046
|
+
};
|
|
2047
|
+
//# sourceMappingURL=index.js.map
|