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