adonisjs-server-stats 1.12.2 → 1.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -4
- package/dist/src/collectors/adonisjs_queue_collector.d.ts +21 -0
- package/dist/src/collectors/adonisjs_queue_collector.js +61 -0
- package/dist/src/collectors/auto_detect.js +8 -2
- package/dist/src/collectors/index.d.ts +2 -0
- package/dist/src/collectors/index.js +1 -0
- package/dist/src/dashboard/inspector_manager.d.ts +4 -4
- package/dist/src/dashboard/inspector_manager.js +20 -10
- package/dist/src/dashboard/integrations/adonisjs_queue_inspector.d.ts +42 -0
- package/dist/src/dashboard/integrations/adonisjs_queue_inspector.js +135 -0
- package/dist/src/dashboard/integrations/adonisjs_queue_store.d.ts +166 -0
- package/dist/src/dashboard/integrations/adonisjs_queue_store.js +629 -0
- package/dist/src/dashboard/integrations/index.d.ts +4 -1
- package/dist/src/dashboard/integrations/index.js +2 -0
- package/dist/src/dashboard/integrations/queue_inspector.d.ts +5 -61
- package/dist/src/dashboard/integrations/queue_inspector.js +5 -1
- package/dist/src/dashboard/integrations/queue_inspector_contract.d.ts +73 -0
- package/dist/src/dashboard/integrations/queue_inspector_contract.js +15 -0
- package/dist/src/provider/email_bridge.d.ts +14 -3
- package/dist/src/provider/email_bridge.js +19 -1
- package/dist/src/provider/toolbar_setup.js +15 -3
- package/dist/vue/composables/useDashboardData.d.ts +7 -0
- package/package.json +11 -1
|
@@ -0,0 +1,629 @@
|
|
|
1
|
+
import { appImport } from '../../utils/app_import.js';
|
|
2
|
+
// ---------------------------------------------------------------------------
|
|
3
|
+
// Status mapping
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
/** Map a @boringnode JobStatus → dashboard status. */
|
|
6
|
+
function mapStatus(s) {
|
|
7
|
+
switch (s) {
|
|
8
|
+
case 'pending': return 'waiting';
|
|
9
|
+
case 'active': return 'active';
|
|
10
|
+
case 'delayed': return 'delayed';
|
|
11
|
+
case 'completed': return 'completed';
|
|
12
|
+
case 'failed': return 'failed';
|
|
13
|
+
default: return 'waiting';
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
/** Map a dashboard status → @boringnode store statuses for queries. */
|
|
17
|
+
function mapStatusToStore(s) {
|
|
18
|
+
switch (s) {
|
|
19
|
+
case 'waiting': return ['pending'];
|
|
20
|
+
case 'active': return ['active'];
|
|
21
|
+
case 'delayed': return ['delayed'];
|
|
22
|
+
case 'completed': return ['completed'];
|
|
23
|
+
case 'failed': return ['failed'];
|
|
24
|
+
case 'paused': return []; // no paused concept in @boringnode/queue
|
|
25
|
+
case 'all':
|
|
26
|
+
default:
|
|
27
|
+
return ['pending', 'active', 'delayed', 'completed', 'failed'];
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// Named export: JobRecord → QueueJobSummary mapper (unit-testable without store)
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
/**
|
|
34
|
+
* Map a @boringnode `JobRecord` (plus optional DB `acquired_at` timestamp) to
|
|
35
|
+
* the `QueueJobSummary` shape used by the dashboard.
|
|
36
|
+
*
|
|
37
|
+
* Known gaps (no per-row data; documented inline):
|
|
38
|
+
* - `progress`: always 0 — @boringnode/queue does not persist per-row progress.
|
|
39
|
+
* - `returnValue`: always null — not stored in the queue table/hash.
|
|
40
|
+
* - `maxAttempts`: best-effort from `data.maxRetries`; falls back to `attempts`.
|
|
41
|
+
* - `stackTrace`: collapsed to `[record.error]`; no real stack trace in store.
|
|
42
|
+
*
|
|
43
|
+
* @param record The raw job record from the store.
|
|
44
|
+
* @param acquiredAtMs Unix-ms timestamp from DB `acquired_at` column, or null.
|
|
45
|
+
*/
|
|
46
|
+
export function mapJobRecordToSummary(record, acquiredAtMs) {
|
|
47
|
+
const { data } = record;
|
|
48
|
+
const processedAt = acquiredAtMs;
|
|
49
|
+
const finishedAt = record.finishedAt ?? null;
|
|
50
|
+
const createdAt = data.createdAt ?? 0;
|
|
51
|
+
const duration = processedAt !== null && finishedAt !== null ? finishedAt - processedAt : null;
|
|
52
|
+
return {
|
|
53
|
+
id: data.id,
|
|
54
|
+
name: cleanJobName(data.name),
|
|
55
|
+
status: mapStatus(record.status),
|
|
56
|
+
data: (data.payload ?? null),
|
|
57
|
+
payload: (data.payload ?? null),
|
|
58
|
+
attempts: data.attempts ?? 0,
|
|
59
|
+
// maxRetries is not persisted per row in @boringnode/queue; best effort
|
|
60
|
+
maxAttempts: data.maxRetries ?? data.attempts ?? 1,
|
|
61
|
+
// progress not persisted by @boringnode/queue
|
|
62
|
+
progress: 0,
|
|
63
|
+
failedReason: record.error ?? null,
|
|
64
|
+
createdAt,
|
|
65
|
+
timestamp: createdAt,
|
|
66
|
+
processedAt,
|
|
67
|
+
finishedAt,
|
|
68
|
+
duration,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Extract a human-readable job name from the raw name stored by @boringnode/queue.
|
|
73
|
+
* Mirrors the heuristic in QueueInspector for BullMQ jobs.
|
|
74
|
+
*/
|
|
75
|
+
function cleanJobName(raw) {
|
|
76
|
+
if (!raw || raw === '__default__')
|
|
77
|
+
return 'default';
|
|
78
|
+
// Strip file:// URLs down to the filename
|
|
79
|
+
if (raw.startsWith('file://') || raw.startsWith('/')) {
|
|
80
|
+
const filename = raw.split('/').pop() ?? raw;
|
|
81
|
+
const base = filename.replace(/\.(ts|js|mjs|cjs)$/, '');
|
|
82
|
+
return base
|
|
83
|
+
.split(/[-_]/)
|
|
84
|
+
.map((s) => s.charAt(0).toUpperCase() + s.slice(1))
|
|
85
|
+
.join('');
|
|
86
|
+
}
|
|
87
|
+
return raw;
|
|
88
|
+
}
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
// Helpers
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
/** Derive the list of queue names to aggregate from config. */
|
|
93
|
+
function resolveQueueNames(config) {
|
|
94
|
+
const names = new Set(['default']);
|
|
95
|
+
if (config.queues) {
|
|
96
|
+
for (const k of Object.keys(config.queues))
|
|
97
|
+
names.add(k);
|
|
98
|
+
}
|
|
99
|
+
return Array.from(names);
|
|
100
|
+
}
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
// DatabaseStoreReader — reads queue_jobs via Lucid knex
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
const TABLE = 'queue_jobs';
|
|
105
|
+
class DatabaseStoreReader {
|
|
106
|
+
#db;
|
|
107
|
+
#connectionName;
|
|
108
|
+
#queues;
|
|
109
|
+
constructor(db, connectionName, queues) {
|
|
110
|
+
this.#db = db;
|
|
111
|
+
this.#connectionName = connectionName;
|
|
112
|
+
this.#queues = queues;
|
|
113
|
+
}
|
|
114
|
+
#knex() {
|
|
115
|
+
return this.#db.connection(this.#connectionName).getWriteClient();
|
|
116
|
+
}
|
|
117
|
+
async getCounts() {
|
|
118
|
+
const defaults = { active: 0, waiting: 0, delayed: 0, completed: 0, failed: 0 };
|
|
119
|
+
try {
|
|
120
|
+
const rows = await this.#knex()(TABLE)
|
|
121
|
+
.whereIn('queue', this.#queues)
|
|
122
|
+
.select('status')
|
|
123
|
+
.count('* as count')
|
|
124
|
+
.groupBy('status');
|
|
125
|
+
const counts = { ...defaults };
|
|
126
|
+
for (const row of rows) {
|
|
127
|
+
const n = Number(row.count);
|
|
128
|
+
const ui = mapStatus(row.status);
|
|
129
|
+
if (ui === 'waiting')
|
|
130
|
+
counts.waiting += n;
|
|
131
|
+
else if (ui === 'active')
|
|
132
|
+
counts.active += n;
|
|
133
|
+
else if (ui === 'delayed')
|
|
134
|
+
counts.delayed += n;
|
|
135
|
+
else if (ui === 'completed')
|
|
136
|
+
counts.completed += n;
|
|
137
|
+
else if (ui === 'failed')
|
|
138
|
+
counts.failed += n;
|
|
139
|
+
}
|
|
140
|
+
return counts;
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
return defaults;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
async listJobs(status, page = 1, perPage = 25) {
|
|
147
|
+
try {
|
|
148
|
+
const storeStatuses = mapStatusToStore(status);
|
|
149
|
+
if (storeStatuses.length === 0)
|
|
150
|
+
return { jobs: [], total: 0 };
|
|
151
|
+
const offset = (page - 1) * perPage;
|
|
152
|
+
const knex = this.#knex();
|
|
153
|
+
const [rows, totalRows] = await Promise.all([
|
|
154
|
+
knex(TABLE)
|
|
155
|
+
.whereIn('queue', this.#queues)
|
|
156
|
+
.whereIn('status', storeStatuses)
|
|
157
|
+
.orderBy('finished_at', 'desc')
|
|
158
|
+
.limit(perPage)
|
|
159
|
+
.offset(offset),
|
|
160
|
+
knex(TABLE)
|
|
161
|
+
.whereIn('queue', this.#queues)
|
|
162
|
+
.whereIn('status', storeStatuses)
|
|
163
|
+
.count('* as count'),
|
|
164
|
+
]);
|
|
165
|
+
return {
|
|
166
|
+
jobs: rows.map((row) => this.#rowToSummary(row)),
|
|
167
|
+
total: Number(totalRows[0]?.count ?? 0),
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
return { jobs: [], total: 0 };
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
async getJob(id) {
|
|
175
|
+
try {
|
|
176
|
+
const row = await this.#knex()(TABLE)
|
|
177
|
+
.where('id', id)
|
|
178
|
+
.first();
|
|
179
|
+
if (!row)
|
|
180
|
+
return null;
|
|
181
|
+
const summary = this.#rowToSummary(row);
|
|
182
|
+
return {
|
|
183
|
+
...summary,
|
|
184
|
+
stackTrace: row.error ? [String(row.error)] : [],
|
|
185
|
+
returnValue: null,
|
|
186
|
+
opts: {},
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
async retryJob(id) {
|
|
194
|
+
try {
|
|
195
|
+
const updated = await this.#knex()(TABLE)
|
|
196
|
+
.where('id', id)
|
|
197
|
+
.andWhere('status', 'failed')
|
|
198
|
+
.update({
|
|
199
|
+
status: 'pending',
|
|
200
|
+
worker_id: null,
|
|
201
|
+
acquired_at: null,
|
|
202
|
+
finished_at: null,
|
|
203
|
+
error: null,
|
|
204
|
+
score: Date.now(),
|
|
205
|
+
});
|
|
206
|
+
return updated > 0;
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
async getWorkerCount() {
|
|
213
|
+
try {
|
|
214
|
+
// COUNT(DISTINCT worker_id) is a reasonable proxy for active workers
|
|
215
|
+
const result = await this.#knex().raw(`SELECT COUNT(DISTINCT worker_id) as count FROM ${TABLE} WHERE status = ? AND worker_id IS NOT NULL`, ['active']);
|
|
216
|
+
// pg-style: result.rows; sqlite/mysql-style: result[0] array
|
|
217
|
+
const rows = result?.rows
|
|
218
|
+
?? result?.[0]
|
|
219
|
+
?? [];
|
|
220
|
+
return Number(rows[0]?.count ?? 0);
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
return 0;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
#rowToSummary(row) {
|
|
227
|
+
let data;
|
|
228
|
+
try {
|
|
229
|
+
data = JSON.parse(String(row.data ?? '{}'));
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
data = { id: String(row.id ?? ''), name: 'unknown', attempts: 0 };
|
|
233
|
+
}
|
|
234
|
+
const record = {
|
|
235
|
+
status: row.status ?? 'pending',
|
|
236
|
+
data,
|
|
237
|
+
finishedAt: row.finished_at != null ? Number(row.finished_at) : undefined,
|
|
238
|
+
error: row.error != null ? String(row.error) : undefined,
|
|
239
|
+
};
|
|
240
|
+
return mapJobRecordToSummary(record, row.acquired_at != null ? Number(row.acquired_at) : null);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
// ---------------------------------------------------------------------------
|
|
244
|
+
// RedisStoreReader — reads jobs::{queue}::* keys via @adonisjs/redis
|
|
245
|
+
// ---------------------------------------------------------------------------
|
|
246
|
+
class RedisStoreReader {
|
|
247
|
+
#redis;
|
|
248
|
+
#connectionName;
|
|
249
|
+
#queues;
|
|
250
|
+
constructor(redis, connectionName, queues) {
|
|
251
|
+
this.#redis = redis;
|
|
252
|
+
this.#connectionName = connectionName;
|
|
253
|
+
this.#queues = queues;
|
|
254
|
+
}
|
|
255
|
+
#conn() {
|
|
256
|
+
return this.#redis.connection(this.#connectionName);
|
|
257
|
+
}
|
|
258
|
+
#key(queue, suffix) {
|
|
259
|
+
return `jobs::${queue}::${suffix}`;
|
|
260
|
+
}
|
|
261
|
+
async getCounts() {
|
|
262
|
+
const defaults = { active: 0, waiting: 0, delayed: 0, completed: 0, failed: 0 };
|
|
263
|
+
try {
|
|
264
|
+
const conn = this.#conn();
|
|
265
|
+
let active = 0, waiting = 0, delayed = 0, completed = 0, failed = 0;
|
|
266
|
+
await Promise.all(this.#queues.map(async (q) => {
|
|
267
|
+
const [w, d, a, c, f] = await Promise.all([
|
|
268
|
+
conn.zcard(this.#key(q, 'pending')),
|
|
269
|
+
conn.zcard(this.#key(q, 'delayed')),
|
|
270
|
+
conn.hlen(this.#key(q, 'active')),
|
|
271
|
+
conn.hlen(this.#key(q, 'completed')),
|
|
272
|
+
conn.hlen(this.#key(q, 'failed')),
|
|
273
|
+
]);
|
|
274
|
+
waiting += w;
|
|
275
|
+
delayed += d;
|
|
276
|
+
active += a;
|
|
277
|
+
completed += c;
|
|
278
|
+
failed += f;
|
|
279
|
+
}));
|
|
280
|
+
return { active, waiting, delayed, completed, failed };
|
|
281
|
+
}
|
|
282
|
+
catch {
|
|
283
|
+
return defaults;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
async listJobs(status, page = 1, perPage = 25) {
|
|
287
|
+
try {
|
|
288
|
+
const storeStatuses = mapStatusToStore(status);
|
|
289
|
+
if (storeStatuses.length === 0)
|
|
290
|
+
return { jobs: [], total: 0 };
|
|
291
|
+
const conn = this.#conn();
|
|
292
|
+
const allJobs = [];
|
|
293
|
+
for (const q of this.#queues) {
|
|
294
|
+
for (const ss of storeStatuses) {
|
|
295
|
+
const jobs = await this.#listByStatus(conn, q, ss);
|
|
296
|
+
allJobs.push(...jobs);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
// Sort newest-first then paginate in-memory (Redis has no ORDER BY)
|
|
300
|
+
allJobs.sort((a, b) => b.createdAt - a.createdAt);
|
|
301
|
+
const start = (page - 1) * perPage;
|
|
302
|
+
return { jobs: allJobs.slice(start, start + perPage), total: allJobs.length };
|
|
303
|
+
}
|
|
304
|
+
catch {
|
|
305
|
+
return { jobs: [], total: 0 };
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
async getJob(id) {
|
|
309
|
+
try {
|
|
310
|
+
const conn = this.#conn();
|
|
311
|
+
for (const q of this.#queues) {
|
|
312
|
+
// Try completed / failed hashes first — they contain full JobRecord JSON
|
|
313
|
+
for (const suffix of ['completed', 'failed']) {
|
|
314
|
+
const raw = await conn.hget(this.#key(q, suffix), id);
|
|
315
|
+
if (raw) {
|
|
316
|
+
try {
|
|
317
|
+
const record = JSON.parse(raw);
|
|
318
|
+
return {
|
|
319
|
+
...mapJobRecordToSummary(record, null),
|
|
320
|
+
stackTrace: record.error ? [record.error] : [],
|
|
321
|
+
returnValue: null,
|
|
322
|
+
opts: {},
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
catch {
|
|
326
|
+
// malformed — try next
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
// Fall back to ::data hash (pending / delayed / active)
|
|
331
|
+
const raw = await conn.hget(this.#key(q, 'data'), id);
|
|
332
|
+
if (raw) {
|
|
333
|
+
try {
|
|
334
|
+
const data = JSON.parse(raw);
|
|
335
|
+
const bStatus = await this.#detectStatus(conn, q, id);
|
|
336
|
+
return {
|
|
337
|
+
...mapJobRecordToSummary({ status: bStatus, data }, null),
|
|
338
|
+
stackTrace: [],
|
|
339
|
+
returnValue: null,
|
|
340
|
+
opts: {},
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
catch {
|
|
344
|
+
// malformed
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
return null;
|
|
349
|
+
}
|
|
350
|
+
catch {
|
|
351
|
+
return null;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
async retryJob(id) {
|
|
355
|
+
try {
|
|
356
|
+
const conn = this.#conn();
|
|
357
|
+
for (const q of this.#queues) {
|
|
358
|
+
const raw = await conn.hget(this.#key(q, 'failed'), id);
|
|
359
|
+
if (!raw)
|
|
360
|
+
continue;
|
|
361
|
+
try {
|
|
362
|
+
const record = JSON.parse(raw);
|
|
363
|
+
// Restore data entry and move back to pending ZSET
|
|
364
|
+
await conn.hset(this.#key(q, 'data'), id, JSON.stringify(record.data));
|
|
365
|
+
await conn.zadd(this.#key(q, 'pending'), Date.now(), id);
|
|
366
|
+
await conn.hdel(this.#key(q, 'failed'), id);
|
|
367
|
+
// Remove from optional failed index ZSET if it exists
|
|
368
|
+
try {
|
|
369
|
+
await conn.zrem(this.#key(q, 'failed::index'), id);
|
|
370
|
+
}
|
|
371
|
+
catch { /* optional */ }
|
|
372
|
+
return true;
|
|
373
|
+
}
|
|
374
|
+
catch {
|
|
375
|
+
// continue to next queue
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
return false;
|
|
379
|
+
}
|
|
380
|
+
catch {
|
|
381
|
+
return false;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
async getWorkerCount() {
|
|
385
|
+
try {
|
|
386
|
+
// Best-effort proxy: count of entries in ::active hashes
|
|
387
|
+
const conn = this.#conn();
|
|
388
|
+
let total = 0;
|
|
389
|
+
for (const q of this.#queues) {
|
|
390
|
+
total += await conn.hlen(this.#key(q, 'active'));
|
|
391
|
+
}
|
|
392
|
+
return total;
|
|
393
|
+
}
|
|
394
|
+
catch {
|
|
395
|
+
return 0;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
/** Enumerate jobs in one queue+status bucket. */
|
|
399
|
+
async #listByStatus(conn, queue, status) {
|
|
400
|
+
if (status === 'pending' || status === 'delayed') {
|
|
401
|
+
// Stored as a ZSET; members are job IDs; full data is in ::data hash
|
|
402
|
+
const setKey = this.#key(queue, status === 'pending' ? 'pending' : 'delayed');
|
|
403
|
+
const ids = await conn.zrange(setKey, 0, -1);
|
|
404
|
+
const jobs = [];
|
|
405
|
+
for (const id of ids) {
|
|
406
|
+
const raw = await conn.hget(this.#key(queue, 'data'), id);
|
|
407
|
+
if (!raw)
|
|
408
|
+
continue;
|
|
409
|
+
try {
|
|
410
|
+
const data = JSON.parse(raw);
|
|
411
|
+
jobs.push(mapJobRecordToSummary({ status, data }, null));
|
|
412
|
+
}
|
|
413
|
+
catch { /* skip malformed */ }
|
|
414
|
+
}
|
|
415
|
+
return jobs;
|
|
416
|
+
}
|
|
417
|
+
if (status === 'active') {
|
|
418
|
+
// Stored as a HASH (field=jobId, value=workerId); data is in ::data hash
|
|
419
|
+
const ids = await this.#hkeys(conn, this.#key(queue, 'active'));
|
|
420
|
+
const jobs = [];
|
|
421
|
+
for (const id of ids) {
|
|
422
|
+
const raw = await conn.hget(this.#key(queue, 'data'), id);
|
|
423
|
+
if (!raw)
|
|
424
|
+
continue;
|
|
425
|
+
try {
|
|
426
|
+
const data = JSON.parse(raw);
|
|
427
|
+
jobs.push(mapJobRecordToSummary({ status: 'active', data }, null));
|
|
428
|
+
}
|
|
429
|
+
catch { /* skip */ }
|
|
430
|
+
}
|
|
431
|
+
return jobs;
|
|
432
|
+
}
|
|
433
|
+
// completed | failed — HASH; values are full JobRecord JSON
|
|
434
|
+
return this.#scanHashAsRecords(conn, this.#key(queue, status));
|
|
435
|
+
}
|
|
436
|
+
/** HSCAN a hash and parse each value as a JobRecord, returning summaries. */
|
|
437
|
+
async #scanHashAsRecords(conn, key) {
|
|
438
|
+
const jobs = [];
|
|
439
|
+
let cursor = '0';
|
|
440
|
+
do {
|
|
441
|
+
const [nextCursor, entries] = await conn.hscan(key, cursor, 'COUNT', '100');
|
|
442
|
+
cursor = nextCursor;
|
|
443
|
+
// entries: [field0, value0, field1, value1, …]
|
|
444
|
+
for (let i = 1; i < entries.length; i += 2) {
|
|
445
|
+
try {
|
|
446
|
+
const record = JSON.parse(entries[i]);
|
|
447
|
+
jobs.push(mapJobRecordToSummary(record, null));
|
|
448
|
+
}
|
|
449
|
+
catch { /* skip */ }
|
|
450
|
+
}
|
|
451
|
+
} while (cursor !== '0');
|
|
452
|
+
return jobs;
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* Return all field names of a Redis hash.
|
|
456
|
+
* Uses native HKEYS if available; falls back to HSCAN for compatibility.
|
|
457
|
+
*/
|
|
458
|
+
async #hkeys(conn, key) {
|
|
459
|
+
if (typeof conn.hkeys === 'function') {
|
|
460
|
+
try {
|
|
461
|
+
return await conn.hkeys(key);
|
|
462
|
+
}
|
|
463
|
+
catch { /* fallback */ }
|
|
464
|
+
}
|
|
465
|
+
const keys = [];
|
|
466
|
+
let cursor = '0';
|
|
467
|
+
do {
|
|
468
|
+
const [nextCursor, entries] = await conn.hscan(key, cursor, 'COUNT', '100');
|
|
469
|
+
cursor = nextCursor;
|
|
470
|
+
for (let i = 0; i < entries.length; i += 2)
|
|
471
|
+
keys.push(entries[i]);
|
|
472
|
+
} while (cursor !== '0');
|
|
473
|
+
return keys;
|
|
474
|
+
}
|
|
475
|
+
/** Detect the current bucket of a job ID within a queue. */
|
|
476
|
+
async #detectStatus(conn, queue, id) {
|
|
477
|
+
const inActive = await conn.hget(this.#key(queue, 'active'), id);
|
|
478
|
+
if (inActive !== null)
|
|
479
|
+
return 'active';
|
|
480
|
+
const score = await conn.zscore(this.#key(queue, 'pending'), id);
|
|
481
|
+
if (score !== null)
|
|
482
|
+
return 'pending';
|
|
483
|
+
const dscore = await conn.zscore(this.#key(queue, 'delayed'), id);
|
|
484
|
+
if (dscore !== null)
|
|
485
|
+
return 'delayed';
|
|
486
|
+
return 'pending';
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
// ---------------------------------------------------------------------------
|
|
490
|
+
// Driver detection
|
|
491
|
+
// ---------------------------------------------------------------------------
|
|
492
|
+
function detectDriver(queueManager, config) {
|
|
493
|
+
try {
|
|
494
|
+
const name = queueManager.use()?.constructor?.name ?? '';
|
|
495
|
+
if (name === 'KnexAdapter')
|
|
496
|
+
return 'database';
|
|
497
|
+
if (name === 'RedisAdapter')
|
|
498
|
+
return 'redis';
|
|
499
|
+
}
|
|
500
|
+
catch {
|
|
501
|
+
// fall through
|
|
502
|
+
}
|
|
503
|
+
// Regex fallback on the default adapter key
|
|
504
|
+
const key = config.default ?? Object.keys(config.adapters ?? {})[0] ?? '';
|
|
505
|
+
if (/redis/i.test(key))
|
|
506
|
+
return 'redis';
|
|
507
|
+
if (/database|sql|knex/i.test(key))
|
|
508
|
+
return 'database';
|
|
509
|
+
return 'unknown';
|
|
510
|
+
}
|
|
511
|
+
/**
|
|
512
|
+
* Build a {@link QueueStoreReader} from already-resolved services.
|
|
513
|
+
*
|
|
514
|
+
* Both the inspector path (`app.container.make`) and the collector path
|
|
515
|
+
* (`appImport`) resolve their own services and pass them here.
|
|
516
|
+
* Returns a safe-defaults no-op reader if driver detection fails or the
|
|
517
|
+
* required service (db or redis) is absent.
|
|
518
|
+
*/
|
|
519
|
+
export function buildQueueStoreReader(services) {
|
|
520
|
+
const { queueManager, config, db, redis, connectionName } = services;
|
|
521
|
+
const queues = resolveQueueNames(config);
|
|
522
|
+
const driver = detectDriver(queueManager, config);
|
|
523
|
+
if (driver === 'database') {
|
|
524
|
+
if (!db)
|
|
525
|
+
return noopReader();
|
|
526
|
+
const cfgConn = config.adapters?.[config.default ?? '']?.connectionName;
|
|
527
|
+
const connName = connectionName ?? cfgConn ?? db.primaryConnectionName;
|
|
528
|
+
return new DatabaseStoreReader(db, connName, queues);
|
|
529
|
+
}
|
|
530
|
+
if (driver === 'redis') {
|
|
531
|
+
if (!redis)
|
|
532
|
+
return noopReader();
|
|
533
|
+
const cfgConn = config.adapters?.[config.default ?? '']?.connectionName;
|
|
534
|
+
const connName = connectionName ?? cfgConn ?? 'main';
|
|
535
|
+
return new RedisStoreReader(redis, connName, queues);
|
|
536
|
+
}
|
|
537
|
+
return noopReader();
|
|
538
|
+
}
|
|
539
|
+
/**
|
|
540
|
+
* Resolve services from an AdonisJS Application instance and build a store reader.
|
|
541
|
+
*
|
|
542
|
+
* This is the canonical resolver: the queue config lives in the app's
|
|
543
|
+
* `config/queue.ts` (there is no package-level config export), so it can only
|
|
544
|
+
* be read from `app.config.get('queue')`. Both the inspector and collector
|
|
545
|
+
* paths funnel through here.
|
|
546
|
+
*
|
|
547
|
+
* Returns null if @adonisjs/queue is not registered (e.g. wrong environment).
|
|
548
|
+
*/
|
|
549
|
+
export async function resolveFromApplication(app) {
|
|
550
|
+
try {
|
|
551
|
+
const queueManager = (await app.container.make('queue.manager'));
|
|
552
|
+
const config = (app.config.get('queue') ?? {});
|
|
553
|
+
// db and redis are optional; ignore failures
|
|
554
|
+
const [dbResult, redisResult] = await Promise.allSettled([
|
|
555
|
+
app.container.make('lucid.db'),
|
|
556
|
+
app.container.make('redis'),
|
|
557
|
+
]);
|
|
558
|
+
return buildQueueStoreReader({
|
|
559
|
+
queueManager,
|
|
560
|
+
config,
|
|
561
|
+
db: dbResult.status === 'fulfilled' ? dbResult.value : undefined,
|
|
562
|
+
redis: redisResult.status === 'fulfilled' ? redisResult.value : undefined,
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
catch {
|
|
566
|
+
return null;
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
// ---------------------------------------------------------------------------
|
|
570
|
+
// Convenience resolver — inspector path (ApplicationService)
|
|
571
|
+
// ---------------------------------------------------------------------------
|
|
572
|
+
/**
|
|
573
|
+
* Resolve services for the inspector path.
|
|
574
|
+
*
|
|
575
|
+
* Accepts either a full Application-like object (with `config` + `container`)
|
|
576
|
+
* or a bare container (legacy). When given a bare container it resolves the
|
|
577
|
+
* application via the `app` binding to read the queue config.
|
|
578
|
+
*
|
|
579
|
+
* Returns null if @adonisjs/queue is not registered (e.g. wrong environment).
|
|
580
|
+
*/
|
|
581
|
+
export async function resolveFromContainer(appOrContainer) {
|
|
582
|
+
// Full application instance (has its own config) — preferred path.
|
|
583
|
+
if ('config' in appOrContainer && 'container' in appOrContainer) {
|
|
584
|
+
return resolveFromApplication(appOrContainer);
|
|
585
|
+
}
|
|
586
|
+
// Bare container — recover the application from the `app` binding.
|
|
587
|
+
try {
|
|
588
|
+
const app = (await appOrContainer.make('app'));
|
|
589
|
+
return resolveFromApplication({ config: app.config, container: appOrContainer });
|
|
590
|
+
}
|
|
591
|
+
catch {
|
|
592
|
+
return null;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
// ---------------------------------------------------------------------------
|
|
596
|
+
// Convenience resolver — collector path (appImport)
|
|
597
|
+
// ---------------------------------------------------------------------------
|
|
598
|
+
/**
|
|
599
|
+
* Resolve services via `appImport` and build a store reader.
|
|
600
|
+
*
|
|
601
|
+
* Designed for the collector path where no IoC container reference is held.
|
|
602
|
+
* Resolves the running Application via `@adonisjs/core/services/app` (the same
|
|
603
|
+
* service the official `@adonisjs/queue` package uses), then reads the queue
|
|
604
|
+
* config and services from it.
|
|
605
|
+
*
|
|
606
|
+
* Returns null if @adonisjs/queue is not installed in the host application.
|
|
607
|
+
*/
|
|
608
|
+
export async function resolveFromAppImport() {
|
|
609
|
+
try {
|
|
610
|
+
const appMod = await appImport('@adonisjs/core/services/app');
|
|
611
|
+
return await resolveFromApplication(appMod.default);
|
|
612
|
+
}
|
|
613
|
+
catch {
|
|
614
|
+
return null;
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
// ---------------------------------------------------------------------------
|
|
618
|
+
// No-op reader — safe defaults when driver is unknown or services are absent
|
|
619
|
+
// ---------------------------------------------------------------------------
|
|
620
|
+
function noopReader() {
|
|
621
|
+
const zero = { active: 0, waiting: 0, delayed: 0, completed: 0, failed: 0 };
|
|
622
|
+
return {
|
|
623
|
+
async getCounts() { return { ...zero }; },
|
|
624
|
+
async listJobs() { return { jobs: [], total: 0 }; },
|
|
625
|
+
async getJob() { return null; },
|
|
626
|
+
async retryJob() { return false; },
|
|
627
|
+
async getWorkerCount() { return 0; },
|
|
628
|
+
};
|
|
629
|
+
}
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
export { CacheInspector } from './cache_inspector.js';
|
|
2
2
|
export type { CacheStats, CacheKeyEntry, CacheKeyListResult, CacheKeyDetail, } from './cache_inspector.js';
|
|
3
3
|
export { QueueInspector } from './queue_inspector.js';
|
|
4
|
-
export
|
|
4
|
+
export { AdonisQueueInspector } from './adonisjs_queue_inspector.js';
|
|
5
|
+
export type { QueueOverview, QueueJobSummary, QueueJobDetail, QueueJobListResult, JobStatus, ALL_STATUSES, QueueInspectorContract, } from './queue_inspector_contract.js';
|
|
5
6
|
export { ConfigInspector } from './config_inspector.js';
|
|
6
7
|
export type { SanitizedConfig, SanitizedEnvVars } from './config_inspector.js';
|
|
8
|
+
export { buildQueueStoreReader, mapJobRecordToSummary, resolveFromContainer, resolveFromAppImport } from './adonisjs_queue_store.js';
|
|
9
|
+
export type { QueueCounts, QueueStoreReader, QueueStoreReaderServices } from './adonisjs_queue_store.js';
|
|
@@ -1,3 +1,5 @@
|
|
|
1
1
|
export { CacheInspector } from './cache_inspector.js';
|
|
2
2
|
export { QueueInspector } from './queue_inspector.js';
|
|
3
|
+
export { AdonisQueueInspector } from './adonisjs_queue_inspector.js';
|
|
3
4
|
export { ConfigInspector } from './config_inspector.js';
|
|
5
|
+
export { buildQueueStoreReader, mapJobRecordToSummary, resolveFromContainer, resolveFromAppImport } from './adonisjs_queue_store.js';
|