@nicnocquee/dataqueue-dashboard 1.0.0 → 1.26.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/dist/next.js CHANGED
@@ -1,29 +1,1153 @@
1
- import { handleRequest } from './core/api-handlers.js';
2
- /**
3
- * Create a dataqueue dashboard handler for Next.js App Router.
4
- *
5
- * Usage:
6
- * ```ts
7
- * // app/admin/dataqueue/[[...path]]/route.ts
8
- * import { createDataqueueDashboard } from '@nicnocquee/dataqueue-dashboard/next'
9
- * import { getJobQueue, jobHandlers } from '@/lib/queue'
10
- *
11
- * const { GET, POST } = createDataqueueDashboard({
12
- * jobQueue: getJobQueue(),
13
- * jobHandlers,
14
- * basePath: '/admin/dataqueue',
15
- * })
16
- *
17
- * export { GET, POST }
18
- * ```
19
- */
20
- export function createDataqueueDashboard(config) {
21
- const handler = async (request) => {
22
- return handleRequest(request, config);
23
- };
24
- return {
25
- GET: handler,
26
- POST: handler,
27
- };
1
+ // src/core/html.ts
2
+ function generateDashboardHTML(basePath) {
3
+ const normalizedBase = basePath.endsWith("/") ? basePath.slice(0, -1) : basePath;
4
+ return `<!DOCTYPE html>
5
+ <html lang="en">
6
+ <head>
7
+ <meta charset="UTF-8" />
8
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
9
+ <title>Dataqueue Dashboard</title>
10
+ <style>${CSS}</style>
11
+ </head>
12
+ <body>
13
+ <div id="root"></div>
14
+ <script>window.__DQ_BASE_PATH__ = ${JSON.stringify(normalizedBase)};</script>
15
+ <script type="importmap">
16
+ {
17
+ "imports": {
18
+ "react": "https://esm.sh/react@19?dev",
19
+ "react-dom/client": "https://esm.sh/react-dom@19/client?dev"
20
+ }
21
+ }
22
+ </script>
23
+ <script type="module">${CLIENT_JS}</script>
24
+ </body>
25
+ </html>`;
28
26
  }
27
+ var CSS = (
28
+ /* css */
29
+ `
30
+ *,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
31
+
32
+ :root {
33
+ --bg: #ffffff;
34
+ --bg-secondary: #f8f9fa;
35
+ --bg-hover: #f1f3f5;
36
+ --border: #dee2e6;
37
+ --text: #212529;
38
+ --text-secondary: #6c757d;
39
+ --text-muted: #adb5bd;
40
+ --primary: #228be6;
41
+ --primary-hover: #1c7ed6;
42
+ --primary-light: #e7f5ff;
43
+ --success: #40c057;
44
+ --success-light: #ebfbee;
45
+ --danger: #fa5252;
46
+ --danger-light: #fff5f5;
47
+ --warning: #fab005;
48
+ --warning-light: #fff9db;
49
+ --info: #15aabf;
50
+ --info-light: #e3fafc;
51
+ --radius: 6px;
52
+ --radius-lg: 8px;
53
+ --shadow: 0 1px 3px rgba(0,0,0,0.08), 0 1px 2px rgba(0,0,0,0.06);
54
+ --shadow-md: 0 4px 6px rgba(0,0,0,0.07), 0 2px 4px rgba(0,0,0,0.06);
55
+ --font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
56
+ --font-mono: "SF Mono", "Fira Code", "Fira Mono", "Roboto Mono", Menlo, monospace;
57
+ --transition: 150ms ease;
58
+ }
59
+
60
+ @media (prefers-color-scheme: dark) {
61
+ :root {
62
+ --bg: #1a1b1e;
63
+ --bg-secondary: #25262b;
64
+ --bg-hover: #2c2e33;
65
+ --border: #373a40;
66
+ --text: #c1c2c5;
67
+ --text-secondary: #909296;
68
+ --text-muted: #5c5f66;
69
+ --primary: #4dabf7;
70
+ --primary-hover: #339af0;
71
+ --primary-light: #1b2838;
72
+ --success: #51cf66;
73
+ --success-light: #1b2e1b;
74
+ --danger: #ff6b6b;
75
+ --danger-light: #2e1b1b;
76
+ --warning: #fcc419;
77
+ --warning-light: #2e2a1b;
78
+ --info: #22b8cf;
79
+ --info-light: #1b2b2e;
80
+ --shadow: 0 1px 3px rgba(0,0,0,0.3), 0 1px 2px rgba(0,0,0,0.2);
81
+ --shadow-md: 0 4px 6px rgba(0,0,0,0.25), 0 2px 4px rgba(0,0,0,0.2);
82
+ }
83
+ }
84
+
85
+ body {
86
+ font-family: var(--font);
87
+ background: var(--bg);
88
+ color: var(--text);
89
+ line-height: 1.5;
90
+ -webkit-font-smoothing: antialiased;
91
+ }
92
+
93
+ .dq-layout {
94
+ display: flex;
95
+ min-height: 100vh;
96
+ }
97
+
98
+ .dq-sidebar {
99
+ width: 220px;
100
+ background: var(--bg-secondary);
101
+ border-right: 1px solid var(--border);
102
+ padding: 20px 0;
103
+ flex-shrink: 0;
104
+ }
105
+
106
+ .dq-sidebar-title {
107
+ font-size: 14px;
108
+ font-weight: 700;
109
+ padding: 0 16px 16px;
110
+ color: var(--text);
111
+ letter-spacing: -0.02em;
112
+ }
113
+
114
+ .dq-sidebar-title span {
115
+ color: var(--primary);
116
+ }
117
+
118
+ .dq-nav-item {
119
+ display: block;
120
+ padding: 8px 16px;
121
+ font-size: 13px;
122
+ color: var(--text-secondary);
123
+ text-decoration: none;
124
+ cursor: pointer;
125
+ border: none;
126
+ background: none;
127
+ width: 100%;
128
+ text-align: left;
129
+ transition: all var(--transition);
130
+ }
131
+
132
+ .dq-nav-item:hover {
133
+ background: var(--bg-hover);
134
+ color: var(--text);
135
+ }
136
+
137
+ .dq-nav-item.active {
138
+ background: var(--primary-light);
139
+ color: var(--primary);
140
+ font-weight: 500;
141
+ }
142
+
143
+ .dq-main {
144
+ flex: 1;
145
+ padding: 24px 32px;
146
+ min-width: 0;
147
+ overflow-x: auto;
148
+ }
149
+
150
+ .dq-header {
151
+ display: flex;
152
+ align-items: center;
153
+ justify-content: space-between;
154
+ margin-bottom: 24px;
155
+ }
156
+
157
+ .dq-header h1 {
158
+ font-size: 22px;
159
+ font-weight: 700;
160
+ letter-spacing: -0.02em;
161
+ }
162
+
163
+ .dq-header-actions {
164
+ display: flex;
165
+ gap: 8px;
166
+ align-items: center;
167
+ }
168
+
169
+ .dq-btn {
170
+ display: inline-flex;
171
+ align-items: center;
172
+ gap: 6px;
173
+ padding: 7px 14px;
174
+ font-size: 13px;
175
+ font-weight: 500;
176
+ font-family: var(--font);
177
+ border-radius: var(--radius);
178
+ border: 1px solid var(--border);
179
+ background: var(--bg);
180
+ color: var(--text);
181
+ cursor: pointer;
182
+ transition: all var(--transition);
183
+ white-space: nowrap;
184
+ }
185
+
186
+ .dq-btn:hover { background: var(--bg-hover); }
187
+ .dq-btn:disabled { opacity: 0.5; cursor: not-allowed; }
188
+
189
+ .dq-btn-primary {
190
+ background: var(--primary);
191
+ color: #fff;
192
+ border-color: var(--primary);
193
+ }
194
+ .dq-btn-primary:hover { background: var(--primary-hover); }
195
+
196
+ .dq-btn-danger {
197
+ color: var(--danger);
198
+ border-color: var(--danger);
199
+ }
200
+ .dq-btn-danger:hover { background: var(--danger-light); }
201
+
202
+ .dq-btn-sm {
203
+ padding: 4px 10px;
204
+ font-size: 12px;
205
+ }
206
+
207
+ .dq-tabs {
208
+ display: flex;
209
+ gap: 2px;
210
+ margin-bottom: 16px;
211
+ border-bottom: 1px solid var(--border);
212
+ overflow-x: auto;
213
+ }
214
+
215
+ .dq-tab {
216
+ padding: 8px 14px;
217
+ font-size: 13px;
218
+ font-weight: 500;
219
+ color: var(--text-secondary);
220
+ background: none;
221
+ border: none;
222
+ border-bottom: 2px solid transparent;
223
+ cursor: pointer;
224
+ transition: all var(--transition);
225
+ font-family: var(--font);
226
+ white-space: nowrap;
227
+ }
228
+
229
+ .dq-tab:hover { color: var(--text); }
230
+
231
+ .dq-tab.active {
232
+ color: var(--primary);
233
+ border-bottom-color: var(--primary);
234
+ }
235
+
236
+ .dq-tab .dq-count {
237
+ margin-left: 4px;
238
+ font-size: 11px;
239
+ font-weight: 600;
240
+ padding: 1px 6px;
241
+ border-radius: 10px;
242
+ background: var(--bg-hover);
243
+ color: var(--text-secondary);
244
+ }
245
+
246
+ .dq-tab.active .dq-count {
247
+ background: var(--primary-light);
248
+ color: var(--primary);
249
+ }
250
+
251
+ .dq-card {
252
+ background: var(--bg);
253
+ border: 1px solid var(--border);
254
+ border-radius: var(--radius-lg);
255
+ box-shadow: var(--shadow);
256
+ overflow: hidden;
257
+ }
258
+
259
+ .dq-card-header {
260
+ padding: 14px 16px;
261
+ font-size: 14px;
262
+ font-weight: 600;
263
+ border-bottom: 1px solid var(--border);
264
+ }
265
+
266
+ .dq-card-body { padding: 16px; }
267
+
268
+ .dq-table-wrap { overflow-x: auto; }
269
+
270
+ table.dq-table {
271
+ width: 100%;
272
+ border-collapse: collapse;
273
+ font-size: 13px;
274
+ }
275
+
276
+ .dq-table th {
277
+ text-align: left;
278
+ padding: 10px 12px;
279
+ font-weight: 600;
280
+ font-size: 12px;
281
+ text-transform: uppercase;
282
+ letter-spacing: 0.04em;
283
+ color: var(--text-secondary);
284
+ background: var(--bg-secondary);
285
+ border-bottom: 1px solid var(--border);
286
+ white-space: nowrap;
287
+ }
288
+
289
+ .dq-table td {
290
+ padding: 10px 12px;
291
+ border-bottom: 1px solid var(--border);
292
+ vertical-align: middle;
293
+ }
294
+
295
+ .dq-table tr:last-child td { border-bottom: none; }
296
+
297
+ .dq-table tr:hover td { background: var(--bg-hover); }
298
+
299
+ .dq-table .dq-id {
300
+ font-family: var(--font-mono);
301
+ font-size: 12px;
302
+ color: var(--primary);
303
+ cursor: pointer;
304
+ font-weight: 500;
305
+ }
306
+
307
+ .dq-table .dq-id:hover { text-decoration: underline; }
308
+
309
+ .dq-table .dq-type {
310
+ font-family: var(--font-mono);
311
+ font-size: 12px;
312
+ background: var(--bg-secondary);
313
+ padding: 2px 8px;
314
+ border-radius: 4px;
315
+ }
316
+
317
+ .dq-badge {
318
+ display: inline-flex;
319
+ align-items: center;
320
+ padding: 2px 8px;
321
+ font-size: 11px;
322
+ font-weight: 600;
323
+ border-radius: 10px;
324
+ text-transform: capitalize;
325
+ white-space: nowrap;
326
+ }
327
+
328
+ .dq-badge-pending { background: var(--warning-light); color: var(--warning); }
329
+ .dq-badge-processing { background: var(--primary-light); color: var(--primary); }
330
+ .dq-badge-completed { background: var(--success-light); color: var(--success); }
331
+ .dq-badge-failed { background: var(--danger-light); color: var(--danger); }
332
+ .dq-badge-cancelled { background: var(--bg-hover); color: var(--text-muted); }
333
+ .dq-badge-waiting { background: var(--info-light); color: var(--info); }
334
+
335
+ .dq-pagination {
336
+ display: flex;
337
+ align-items: center;
338
+ justify-content: space-between;
339
+ padding: 12px 0;
340
+ font-size: 13px;
341
+ color: var(--text-secondary);
342
+ }
343
+
344
+ .dq-pagination-btns {
345
+ display: flex;
346
+ gap: 8px;
347
+ }
348
+
349
+ .dq-detail-grid {
350
+ display: grid;
351
+ grid-template-columns: 1fr 1fr;
352
+ gap: 16px;
353
+ margin-bottom: 16px;
354
+ }
355
+
356
+ @media (max-width: 900px) {
357
+ .dq-detail-grid { grid-template-columns: 1fr; }
358
+ .dq-sidebar { width: 180px; }
359
+ }
360
+
361
+ @media (max-width: 700px) {
362
+ .dq-layout { flex-direction: column; }
363
+ .dq-sidebar { width: 100%; border-right: none; border-bottom: 1px solid var(--border); padding: 12px 0; }
364
+ .dq-main { padding: 16px; }
365
+ }
366
+
367
+ .dq-prop-table { width: 100%; font-size: 13px; }
368
+
369
+ .dq-prop-table td {
370
+ padding: 6px 0;
371
+ vertical-align: top;
372
+ }
373
+
374
+ .dq-prop-table td:first-child {
375
+ color: var(--text-secondary);
376
+ width: 160px;
377
+ font-weight: 500;
378
+ padding-right: 12px;
379
+ }
380
+
381
+ .dq-code-block {
382
+ background: var(--bg-secondary);
383
+ border: 1px solid var(--border);
384
+ border-radius: var(--radius);
385
+ padding: 12px;
386
+ font-family: var(--font-mono);
387
+ font-size: 12px;
388
+ line-height: 1.6;
389
+ overflow-x: auto;
390
+ white-space: pre-wrap;
391
+ word-break: break-all;
392
+ }
393
+
394
+ .dq-timeline { position: relative; padding-left: 24px; }
395
+
396
+ .dq-timeline::before {
397
+ content: '';
398
+ position: absolute;
399
+ left: 7px;
400
+ top: 4px;
401
+ bottom: 4px;
402
+ width: 2px;
403
+ background: var(--border);
404
+ }
405
+
406
+ .dq-timeline-item {
407
+ position: relative;
408
+ padding-bottom: 16px;
409
+ }
410
+
411
+ .dq-timeline-item:last-child { padding-bottom: 0; }
412
+
413
+ .dq-timeline-dot {
414
+ position: absolute;
415
+ left: -20px;
416
+ top: 4px;
417
+ width: 10px;
418
+ height: 10px;
419
+ border-radius: 50%;
420
+ background: var(--primary);
421
+ border: 2px solid var(--bg);
422
+ box-shadow: 0 0 0 2px var(--border);
423
+ }
424
+
425
+ .dq-timeline-dot.completed { background: var(--success); }
426
+ .dq-timeline-dot.failed { background: var(--danger); }
427
+ .dq-timeline-dot.cancelled { background: var(--text-muted); }
428
+
429
+ .dq-timeline-time {
430
+ font-size: 11px;
431
+ color: var(--text-muted);
432
+ font-family: var(--font-mono);
433
+ }
434
+
435
+ .dq-timeline-type {
436
+ font-size: 13px;
437
+ font-weight: 500;
438
+ text-transform: capitalize;
439
+ }
440
+
441
+ .dq-timeline-meta {
442
+ font-size: 12px;
443
+ color: var(--text-secondary);
444
+ margin-top: 2px;
445
+ }
446
+
447
+ .dq-back-link {
448
+ display: inline-flex;
449
+ align-items: center;
450
+ gap: 4px;
451
+ font-size: 13px;
452
+ color: var(--text-secondary);
453
+ cursor: pointer;
454
+ margin-bottom: 16px;
455
+ background: none;
456
+ border: none;
457
+ font-family: var(--font);
458
+ padding: 0;
459
+ }
460
+ .dq-back-link:hover { color: var(--primary); }
461
+
462
+ .dq-empty {
463
+ text-align: center;
464
+ padding: 48px 16px;
465
+ color: var(--text-muted);
466
+ font-size: 14px;
467
+ }
468
+
469
+ .dq-spinner {
470
+ display: inline-block;
471
+ width: 16px;
472
+ height: 16px;
473
+ border: 2px solid var(--border);
474
+ border-top-color: var(--primary);
475
+ border-radius: 50%;
476
+ animation: dq-spin 0.6s linear infinite;
477
+ }
478
+
479
+ @keyframes dq-spin { to { transform: rotate(360deg); } }
480
+
481
+ .dq-loading-overlay {
482
+ display: flex;
483
+ align-items: center;
484
+ justify-content: center;
485
+ padding: 64px 0;
486
+ }
487
+
488
+ .dq-tag {
489
+ display: inline-block;
490
+ padding: 1px 6px;
491
+ font-size: 11px;
492
+ background: var(--bg-secondary);
493
+ border: 1px solid var(--border);
494
+ border-radius: 4px;
495
+ margin-right: 4px;
496
+ font-family: var(--font-mono);
497
+ }
498
+
499
+ .dq-progress-bar {
500
+ width: 100%;
501
+ height: 6px;
502
+ background: var(--bg-hover);
503
+ border-radius: 3px;
504
+ overflow: hidden;
505
+ }
506
+
507
+ .dq-progress-fill {
508
+ height: 100%;
509
+ background: var(--primary);
510
+ border-radius: 3px;
511
+ transition: width 300ms ease;
512
+ }
513
+
514
+ .dq-error-item {
515
+ background: var(--danger-light);
516
+ border: 1px solid var(--danger);
517
+ border-radius: var(--radius);
518
+ padding: 10px 12px;
519
+ margin-bottom: 8px;
520
+ font-size: 12px;
521
+ }
522
+ .dq-error-item:last-child { margin-bottom: 0; }
523
+
524
+ .dq-error-item .dq-error-time {
525
+ font-size: 11px;
526
+ color: var(--text-muted);
527
+ font-family: var(--font-mono);
528
+ margin-bottom: 4px;
529
+ }
530
+
531
+ .dq-error-item .dq-error-msg {
532
+ font-family: var(--font-mono);
533
+ color: var(--danger);
534
+ word-break: break-all;
535
+ }
536
+
537
+ .dq-select {
538
+ padding: 7px 10px;
539
+ font-size: 13px;
540
+ font-family: var(--font);
541
+ border: 1px solid var(--border);
542
+ border-radius: var(--radius);
543
+ background: var(--bg);
544
+ color: var(--text);
545
+ cursor: pointer;
546
+ }
547
+
548
+ .dq-auto-refresh {
549
+ display: flex;
550
+ align-items: center;
551
+ gap: 6px;
552
+ font-size: 12px;
553
+ color: var(--text-secondary);
554
+ }
555
+
556
+ .dq-auto-refresh input[type="checkbox"] {
557
+ accent-color: var(--primary);
558
+ }
559
+
560
+ .dq-toast {
561
+ position: fixed;
562
+ bottom: 20px;
563
+ right: 20px;
564
+ padding: 10px 16px;
565
+ border-radius: var(--radius);
566
+ font-size: 13px;
567
+ font-weight: 500;
568
+ color: #fff;
569
+ background: var(--text);
570
+ box-shadow: var(--shadow-md);
571
+ z-index: 1000;
572
+ animation: dq-fade-in 200ms ease;
573
+ }
574
+
575
+ .dq-toast.success { background: var(--success); }
576
+ .dq-toast.error { background: var(--danger); }
577
+
578
+ @keyframes dq-fade-in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
579
+ `
580
+ );
581
+ var CLIENT_JS = (
582
+ /* js */
583
+ `
584
+ import React from 'react';
585
+ import { createRoot } from 'react-dom/client';
586
+
587
+ const h = React.createElement;
588
+ const F = React.Fragment;
589
+ const { useState, useEffect, useCallback, useRef } = React;
590
+
591
+ const BASE = window.__DQ_BASE_PATH__;
592
+
593
+ // --- API Client ---
594
+ async function api(path, opts = {}) {
595
+ const res = await fetch(BASE + '/api' + path, {
596
+ headers: { 'Content-Type': 'application/json' },
597
+ ...opts,
598
+ });
599
+ if (!res.ok) {
600
+ const body = await res.json().catch(() => ({}));
601
+ throw new Error(body.error || 'Request failed: ' + res.status);
602
+ }
603
+ return res.json();
604
+ }
605
+
606
+ // --- Utils ---
607
+ function timeAgo(iso) {
608
+ if (!iso) return '\u2014';
609
+ const d = new Date(iso);
610
+ const now = Date.now();
611
+ const diff = now - d.getTime();
612
+ if (diff < 60000) return Math.floor(diff / 1000) + 's ago';
613
+ if (diff < 3600000) return Math.floor(diff / 60000) + 'm ago';
614
+ if (diff < 86400000) return Math.floor(diff / 3600000) + 'h ago';
615
+ return Math.floor(diff / 86400000) + 'd ago';
616
+ }
617
+
618
+ function formatDate(iso) {
619
+ if (!iso) return '\u2014';
620
+ return new Date(iso).toLocaleString();
621
+ }
622
+
623
+ function prettyJSON(val) {
624
+ try { return JSON.stringify(val, null, 2); }
625
+ catch { return String(val); }
626
+ }
627
+
628
+ // --- Toast ---
629
+ let toastTimer;
630
+ function Toast({ toast, setToast }) {
631
+ useEffect(() => {
632
+ if (toast) {
633
+ clearTimeout(toastTimer);
634
+ toastTimer = setTimeout(() => setToast(null), 3000);
635
+ }
636
+ }, [toast]);
637
+ if (!toast) return null;
638
+ return h('div', { className: 'dq-toast ' + (toast.type || '') }, toast.msg);
639
+ }
640
+
641
+ // --- Status Badge ---
642
+ function StatusBadge({ status }) {
643
+ return h('span', { className: 'dq-badge dq-badge-' + status }, status);
644
+ }
645
+
646
+ // --- Pagination ---
647
+ function Pagination({ offset, limit, hasMore, onPrev, onNext }) {
648
+ const page = Math.floor(offset / limit) + 1;
649
+ return h('div', { className: 'dq-pagination' },
650
+ h('span', null, 'Page ' + page),
651
+ h('div', { className: 'dq-pagination-btns' },
652
+ h('button', { className: 'dq-btn dq-btn-sm', disabled: offset === 0, onClick: onPrev }, '\u2190 Previous'),
653
+ h('button', { className: 'dq-btn dq-btn-sm', disabled: !hasMore, onClick: onNext }, 'Next \u2192'),
654
+ ),
655
+ );
656
+ }
657
+
658
+ // --- Event Timeline ---
659
+ function EventTimeline({ events }) {
660
+ if (!events.length) return h('div', { className: 'dq-empty' }, 'No events recorded');
661
+ const sorted = [...events].sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
662
+ return h('div', { className: 'dq-timeline' },
663
+ sorted.map(ev =>
664
+ h('div', { key: ev.id, className: 'dq-timeline-item' },
665
+ h('div', { className: 'dq-timeline-dot ' + ev.eventType }),
666
+ h('div', { className: 'dq-timeline-time' }, formatDate(ev.createdAt)),
667
+ h('div', { className: 'dq-timeline-type' }, ev.eventType),
668
+ ev.metadata ? h('div', { className: 'dq-timeline-meta' }, prettyJSON(ev.metadata)) : null,
669
+ )
670
+ ),
671
+ );
672
+ }
673
+
674
+ // --- Job Detail Page ---
675
+ function JobDetailPage({ jobId, onBack, showToast }) {
676
+ const [job, setJob] = useState(null);
677
+ const [events, setEvents] = useState([]);
678
+ const [loading, setLoading] = useState(true);
679
+ const [acting, setActing] = useState(false);
680
+
681
+ const load = useCallback(async () => {
682
+ try {
683
+ const [jRes, eRes] = await Promise.all([
684
+ api('/jobs/' + jobId),
685
+ api('/jobs/' + jobId + '/events'),
686
+ ]);
687
+ setJob(jRes.job);
688
+ setEvents(eRes.events);
689
+ } catch (e) {
690
+ showToast(e.message, 'error');
691
+ } finally {
692
+ setLoading(false);
693
+ }
694
+ }, [jobId]);
695
+
696
+ useEffect(() => { load(); }, [load]);
697
+
698
+ async function handleAction(action) {
699
+ setActing(true);
700
+ try {
701
+ await api('/jobs/' + jobId + '/' + action, { method: 'POST' });
702
+ showToast(action === 'cancel' ? 'Job cancelled' : 'Job retried', 'success');
703
+ await load();
704
+ } catch (e) {
705
+ showToast(e.message, 'error');
706
+ } finally {
707
+ setActing(false);
708
+ }
709
+ }
710
+
711
+ if (loading) return h('div', { className: 'dq-loading-overlay' }, h('div', { className: 'dq-spinner' }));
712
+ if (!job) return h('div', { className: 'dq-empty' }, 'Job not found');
713
+
714
+ const canCancel = job.status === 'pending' || job.status === 'waiting';
715
+ const canRetry = job.status === 'failed' || job.status === 'cancelled';
716
+
717
+ return h(F, null,
718
+ h('button', { className: 'dq-back-link', onClick: onBack }, '\u2190 Back to Jobs'),
719
+ h('div', { className: 'dq-header' },
720
+ h('h1', null, 'Job #' + job.id, ' ', h(StatusBadge, { status: job.status })),
721
+ h('div', { className: 'dq-header-actions' },
722
+ canCancel ? h('button', { className: 'dq-btn dq-btn-danger dq-btn-sm', onClick: () => handleAction('cancel'), disabled: acting }, 'Cancel') : null,
723
+ canRetry ? h('button', { className: 'dq-btn dq-btn-primary dq-btn-sm', onClick: () => handleAction('retry'), disabled: acting }, 'Retry') : null,
724
+ ),
725
+ ),
726
+
727
+ h('div', { className: 'dq-detail-grid' },
728
+ h('div', { className: 'dq-card' },
729
+ h('div', { className: 'dq-card-header' }, 'Properties'),
730
+ h('div', { className: 'dq-card-body' },
731
+ h('table', { className: 'dq-prop-table' },
732
+ h('tbody', null,
733
+ propRow('Type', h('span', { className: 'dq-type' }, job.jobType)),
734
+ propRow('Status', h(StatusBadge, { status: job.status })),
735
+ propRow('Priority', job.priority),
736
+ propRow('Attempts', job.attempts + ' / ' + job.maxAttempts),
737
+ job.progress != null ? propRow('Progress', h(F, null, h('div', { style: { display: 'flex', alignItems: 'center', gap: '8px' } }, h('div', { className: 'dq-progress-bar', style: { width: '100px' } }, h('div', { className: 'dq-progress-fill', style: { width: job.progress + '%' } })), job.progress + '%'))) : null,
738
+ propRow('Created', formatDate(job.createdAt)),
739
+ propRow('Updated', formatDate(job.updatedAt)),
740
+ job.startedAt ? propRow('Started', formatDate(job.startedAt)) : null,
741
+ job.completedAt ? propRow('Completed', formatDate(job.completedAt)) : null,
742
+ job.runAt ? propRow('Run At', formatDate(job.runAt)) : null,
743
+ job.timeoutMs ? propRow('Timeout', job.timeoutMs + 'ms') : null,
744
+ job.tags && job.tags.length ? propRow('Tags', h(F, null, job.tags.map(t => h('span', { key: t, className: 'dq-tag' }, t)))) : null,
745
+ job.idempotencyKey ? propRow('Idempotency Key', h('code', null, job.idempotencyKey)) : null,
746
+ job.failureReason ? propRow('Failure Reason', job.failureReason) : null,
747
+ job.pendingReason ? propRow('Pending Reason', job.pendingReason) : null,
748
+ job.waitTokenId ? propRow('Wait Token', h('code', null, job.waitTokenId)) : null,
749
+ ),
750
+ ),
751
+ ),
752
+ ),
753
+
754
+ h('div', { className: 'dq-card' },
755
+ h('div', { className: 'dq-card-header' }, 'Payload'),
756
+ h('div', { className: 'dq-card-body' },
757
+ h('pre', { className: 'dq-code-block' }, prettyJSON(job.payload)),
758
+ ),
759
+ ),
760
+ ),
761
+
762
+ job.errorHistory && job.errorHistory.length
763
+ ? h('div', { className: 'dq-card', style: { marginBottom: '16px' } },
764
+ h('div', { className: 'dq-card-header' }, 'Error History'),
765
+ h('div', { className: 'dq-card-body' },
766
+ job.errorHistory.map((err, i) =>
767
+ h('div', { key: i, className: 'dq-error-item' },
768
+ h('div', { className: 'dq-error-time' }, err.timestamp),
769
+ h('div', { className: 'dq-error-msg' }, err.message),
770
+ )
771
+ ),
772
+ ),
773
+ )
774
+ : null,
775
+
776
+ job.stepData && Object.keys(job.stepData).length
777
+ ? h('div', { className: 'dq-card', style: { marginBottom: '16px' } },
778
+ h('div', { className: 'dq-card-header' }, 'Step Data'),
779
+ h('div', { className: 'dq-card-body' },
780
+ h('pre', { className: 'dq-code-block' }, prettyJSON(job.stepData)),
781
+ ),
782
+ )
783
+ : null,
784
+
785
+ h('div', { className: 'dq-card' },
786
+ h('div', { className: 'dq-card-header' }, 'Events (' + events.length + ')'),
787
+ h('div', { className: 'dq-card-body' }, h(EventTimeline, { events })),
788
+ ),
789
+ );
790
+ }
791
+
792
+ function propRow(label, value) {
793
+ return h('tr', null,
794
+ h('td', null, label),
795
+ h('td', null, value),
796
+ );
797
+ }
798
+
799
+ // --- Jobs List Page ---
800
+ function JobsPage({ onSelectJob, showToast }) {
801
+ const [jobs, setJobs] = useState([]);
802
+ const [loading, setLoading] = useState(true);
803
+ const [hasMore, setHasMore] = useState(false);
804
+ const [status, setStatus] = useState('');
805
+ const [offset, setOffset] = useState(0);
806
+ const [processing, setProcessing] = useState(false);
807
+ const [autoRefresh, setAutoRefresh] = useState(false);
808
+ const limit = 25;
809
+ const intervalRef = useRef(null);
810
+
811
+ const load = useCallback(async () => {
812
+ try {
813
+ const params = new URLSearchParams({ limit: String(limit), offset: String(offset) });
814
+ if (status) params.set('status', status);
815
+ const data = await api('/jobs?' + params.toString());
816
+ setJobs(data.jobs);
817
+ setHasMore(data.hasMore);
818
+ } catch (e) {
819
+ showToast(e.message, 'error');
820
+ } finally {
821
+ setLoading(false);
822
+ }
823
+ }, [status, offset]);
824
+
825
+ useEffect(() => { setLoading(true); load(); }, [load]);
826
+
827
+ useEffect(() => {
828
+ if (autoRefresh) {
829
+ intervalRef.current = setInterval(load, 3000);
830
+ } else {
831
+ clearInterval(intervalRef.current);
832
+ }
833
+ return () => clearInterval(intervalRef.current);
834
+ }, [autoRefresh, load]);
835
+
836
+ async function handleProcess() {
837
+ setProcessing(true);
838
+ try {
839
+ const res = await api('/process', { method: 'POST' });
840
+ showToast('Processed ' + res.processed + ' job(s)', 'success');
841
+ await load();
842
+ } catch (e) {
843
+ showToast(e.message, 'error');
844
+ } finally {
845
+ setProcessing(false);
846
+ }
847
+ }
848
+
849
+ async function handleAction(jobId, action) {
850
+ try {
851
+ await api('/jobs/' + jobId + '/' + action, { method: 'POST' });
852
+ showToast(action === 'cancel' ? 'Job cancelled' : 'Job retried', 'success');
853
+ await load();
854
+ } catch (e) {
855
+ showToast(e.message, 'error');
856
+ }
857
+ }
858
+
859
+ const statuses = ['', 'pending', 'processing', 'completed', 'failed', 'cancelled', 'waiting'];
860
+
861
+ return h(F, null,
862
+ h('div', { className: 'dq-header' },
863
+ h('h1', null, 'Jobs'),
864
+ h('div', { className: 'dq-header-actions' },
865
+ h('label', { className: 'dq-auto-refresh' },
866
+ h('input', { type: 'checkbox', checked: autoRefresh, onChange: e => setAutoRefresh(e.target.checked) }),
867
+ 'Auto-refresh',
868
+ ),
869
+ h('button', { className: 'dq-btn', onClick: () => { setLoading(true); load(); } }, 'Refresh'),
870
+ h('button', {
871
+ className: 'dq-btn dq-btn-primary',
872
+ onClick: handleProcess,
873
+ disabled: processing,
874
+ }, processing ? h('span', { className: 'dq-spinner' }) : null, processing ? 'Processing...' : 'Process Jobs'),
875
+ ),
876
+ ),
877
+
878
+ h('div', { className: 'dq-tabs' },
879
+ statuses.map(s =>
880
+ h('button', {
881
+ key: s || 'all',
882
+ className: 'dq-tab' + (status === s ? ' active' : ''),
883
+ onClick: () => { setStatus(s); setOffset(0); },
884
+ }, s || 'All')
885
+ ),
886
+ ),
887
+
888
+ h('div', { className: 'dq-card' },
889
+ h('div', { className: 'dq-table-wrap' },
890
+ loading
891
+ ? h('div', { className: 'dq-loading-overlay' }, h('div', { className: 'dq-spinner' }))
892
+ : jobs.length === 0
893
+ ? h('div', { className: 'dq-empty' }, 'No jobs found')
894
+ : h('table', { className: 'dq-table' },
895
+ h('thead', null,
896
+ h('tr', null,
897
+ h('th', null, 'ID'),
898
+ h('th', null, 'Type'),
899
+ h('th', null, 'Status'),
900
+ h('th', null, 'Priority'),
901
+ h('th', null, 'Attempts'),
902
+ h('th', null, 'Created'),
903
+ h('th', null, 'Actions'),
904
+ ),
905
+ ),
906
+ h('tbody', null,
907
+ jobs.map(j =>
908
+ h('tr', { key: j.id },
909
+ h('td', null, h('span', { className: 'dq-id', onClick: () => onSelectJob(j.id) }, '#' + j.id)),
910
+ h('td', null, h('span', { className: 'dq-type' }, j.jobType)),
911
+ h('td', null, h(StatusBadge, { status: j.status })),
912
+ h('td', null, j.priority),
913
+ h('td', null, j.attempts + '/' + j.maxAttempts),
914
+ h('td', { title: formatDate(j.createdAt) }, timeAgo(j.createdAt)),
915
+ h('td', null,
916
+ h('div', { style: { display: 'flex', gap: '4px' } },
917
+ (j.status === 'pending' || j.status === 'waiting')
918
+ ? h('button', { className: 'dq-btn dq-btn-danger dq-btn-sm', onClick: () => handleAction(j.id, 'cancel') }, 'Cancel')
919
+ : null,
920
+ (j.status === 'failed' || j.status === 'cancelled')
921
+ ? h('button', { className: 'dq-btn dq-btn-sm', onClick: () => handleAction(j.id, 'retry') }, 'Retry')
922
+ : null,
923
+ ),
924
+ ),
925
+ )
926
+ ),
927
+ ),
928
+ ),
929
+ ),
930
+ !loading && jobs.length > 0
931
+ ? h(Pagination, {
932
+ offset,
933
+ limit,
934
+ hasMore,
935
+ onPrev: () => setOffset(Math.max(0, offset - limit)),
936
+ onNext: () => setOffset(offset + limit),
937
+ })
938
+ : null,
939
+ ),
940
+ );
941
+ }
942
+
943
+ // --- App ---
944
+ function App() {
945
+ const [route, setRoute] = useState(parseHash());
946
+ const [toast, setToast] = useState(null);
947
+
948
+ useEffect(() => {
949
+ function onHash() { setRoute(parseHash()); }
950
+ window.addEventListener('hashchange', onHash);
951
+ return () => window.removeEventListener('hashchange', onHash);
952
+ }, []);
953
+
954
+ function navigate(hash) { window.location.hash = hash; }
955
+ function showToast(msg, type) { setToast({ msg, type }); }
956
+
957
+ return h('div', { className: 'dq-layout' },
958
+ h('nav', { className: 'dq-sidebar' },
959
+ h('div', { className: 'dq-sidebar-title' }, h('span', null, 'dataqueue'), ' Dashboard'),
960
+ h('button', {
961
+ className: 'dq-nav-item' + (route.page === 'jobs' && !route.jobId ? ' active' : ''),
962
+ onClick: () => navigate('#/'),
963
+ }, 'Jobs'),
964
+ ),
965
+ h('main', { className: 'dq-main' },
966
+ route.page === 'detail' && route.jobId
967
+ ? h(JobDetailPage, { jobId: route.jobId, onBack: () => navigate('#/'), showToast })
968
+ : h(JobsPage, { onSelectJob: id => navigate('#/jobs/' + id), showToast }),
969
+ ),
970
+ h(Toast, { toast, setToast }),
971
+ );
972
+ }
973
+
974
+ function parseHash() {
975
+ const hash = window.location.hash.replace(/^#\\/?/, '');
976
+ const parts = hash.split('/').filter(Boolean);
977
+ if (parts[0] === 'jobs' && parts[1]) {
978
+ return { page: 'detail', jobId: parseInt(parts[1], 10) };
979
+ }
980
+ return { page: 'jobs', jobId: null };
981
+ }
982
+
983
+ // --- Mount ---
984
+ createRoot(document.getElementById('root')).render(h(App, null));
985
+ `
986
+ );
987
+
988
+ // src/core/api-handlers.ts
989
+ var DEFAULT_PAGE_SIZE = 25;
990
+ function serializeJob(job) {
991
+ return {
992
+ id: job.id,
993
+ jobType: job.jobType,
994
+ payload: job.payload,
995
+ status: job.status,
996
+ createdAt: toISO(job.createdAt),
997
+ updatedAt: toISO(job.updatedAt),
998
+ lockedAt: job.lockedAt ? toISO(job.lockedAt) : null,
999
+ lockedBy: job.lockedBy,
1000
+ attempts: job.attempts,
1001
+ maxAttempts: job.maxAttempts,
1002
+ nextAttemptAt: job.nextAttemptAt ? toISO(job.nextAttemptAt) : null,
1003
+ priority: job.priority,
1004
+ runAt: toISO(job.runAt),
1005
+ pendingReason: job.pendingReason,
1006
+ errorHistory: job.errorHistory,
1007
+ timeoutMs: job.timeoutMs,
1008
+ forceKillOnTimeout: job.forceKillOnTimeout,
1009
+ failureReason: job.failureReason,
1010
+ completedAt: job.completedAt ? toISO(job.completedAt) : null,
1011
+ startedAt: job.startedAt ? toISO(job.startedAt) : null,
1012
+ lastRetriedAt: job.lastRetriedAt ? toISO(job.lastRetriedAt) : null,
1013
+ lastFailedAt: job.lastFailedAt ? toISO(job.lastFailedAt) : null,
1014
+ lastCancelledAt: job.lastCancelledAt ? toISO(job.lastCancelledAt) : null,
1015
+ tags: job.tags,
1016
+ idempotencyKey: job.idempotencyKey,
1017
+ waitUntil: job.waitUntil ? toISO(job.waitUntil) : null,
1018
+ waitTokenId: job.waitTokenId,
1019
+ stepData: job.stepData,
1020
+ progress: job.progress
1021
+ };
1022
+ }
1023
+ function serializeEvent(event) {
1024
+ return {
1025
+ id: event.id,
1026
+ jobId: event.jobId,
1027
+ eventType: event.eventType,
1028
+ createdAt: toISO(event.createdAt),
1029
+ metadata: event.metadata
1030
+ };
1031
+ }
1032
+ function toISO(date) {
1033
+ if (date instanceof Date) return date.toISOString();
1034
+ return date;
1035
+ }
1036
+ function json(data, status = 200) {
1037
+ return new Response(JSON.stringify(data), {
1038
+ status,
1039
+ headers: { "Content-Type": "application/json" }
1040
+ });
1041
+ }
1042
+ function html(content, status = 200) {
1043
+ return new Response(content, {
1044
+ status,
1045
+ headers: { "Content-Type": "text/html; charset=utf-8" }
1046
+ });
1047
+ }
1048
+ function parsePathSegments(url, basePath) {
1049
+ const u = new URL(url);
1050
+ const normalized = basePath.endsWith("/") ? basePath : basePath + "/";
1051
+ const rest = u.pathname.startsWith(normalized) ? u.pathname.slice(normalized.length) : u.pathname.slice(basePath.length);
1052
+ return rest.split("/").map((s) => s.trim()).filter(Boolean);
1053
+ }
1054
+ async function handleRequest(request, config) {
1055
+ const { jobQueue, basePath } = config;
1056
+ const method = request.method.toUpperCase();
1057
+ const segments = parsePathSegments(request.url, basePath);
1058
+ try {
1059
+ if (method === "GET") {
1060
+ if (segments[0] === "api" && segments[1] === "jobs" && !segments[2]) {
1061
+ const url = new URL(request.url);
1062
+ const status = url.searchParams.get("status");
1063
+ const jobType = url.searchParams.get("jobType");
1064
+ const limit = parseInt(
1065
+ url.searchParams.get("limit") || String(DEFAULT_PAGE_SIZE),
1066
+ 10
1067
+ );
1068
+ const offset = parseInt(url.searchParams.get("offset") || "0", 10);
1069
+ let jobs;
1070
+ if (status) {
1071
+ jobs = await jobQueue.getJobsByStatus(status, limit + 1, offset);
1072
+ } else if (jobType) {
1073
+ jobs = await jobQueue.getJobs({ jobType }, limit + 1, offset);
1074
+ } else {
1075
+ jobs = await jobQueue.getAllJobs(limit + 1, offset);
1076
+ }
1077
+ const hasMore = jobs.length > limit;
1078
+ if (hasMore) jobs = jobs.slice(0, limit);
1079
+ return json({
1080
+ jobs: jobs.map(serializeJob),
1081
+ hasMore
1082
+ });
1083
+ }
1084
+ if (segments[0] === "api" && segments[1] === "jobs" && segments[2] && segments[3] === "events") {
1085
+ const id = parseInt(segments[2], 10);
1086
+ if (isNaN(id)) return json({ error: "Invalid job ID" }, 400);
1087
+ const events = await jobQueue.getJobEvents(id);
1088
+ return json({ events: events.map(serializeEvent) });
1089
+ }
1090
+ if (segments[0] === "api" && segments[1] === "jobs" && segments[2]) {
1091
+ const id = parseInt(segments[2], 10);
1092
+ if (isNaN(id)) return json({ error: "Invalid job ID" }, 400);
1093
+ const job = await jobQueue.getJob(id);
1094
+ if (!job) return json({ error: "Job not found" }, 404);
1095
+ return json({ job: serializeJob(job) });
1096
+ }
1097
+ if (segments[0] !== "api") {
1098
+ return html(generateDashboardHTML(basePath));
1099
+ }
1100
+ }
1101
+ if (method === "POST") {
1102
+ if (segments[0] === "api" && segments[1] === "jobs" && segments[2] && segments[3] === "cancel") {
1103
+ const id = parseInt(segments[2], 10);
1104
+ if (isNaN(id)) return json({ error: "Invalid job ID" }, 400);
1105
+ try {
1106
+ await jobQueue.cancelJob(id);
1107
+ return json({ ok: true });
1108
+ } catch (err) {
1109
+ return json({ ok: false, error: err.message }, 400);
1110
+ }
1111
+ }
1112
+ if (segments[0] === "api" && segments[1] === "jobs" && segments[2] && segments[3] === "retry") {
1113
+ const id = parseInt(segments[2], 10);
1114
+ if (isNaN(id)) return json({ error: "Invalid job ID" }, 400);
1115
+ try {
1116
+ await jobQueue.retryJob(id);
1117
+ return json({ ok: true });
1118
+ } catch (err) {
1119
+ return json({ ok: false, error: err.message }, 400);
1120
+ }
1121
+ }
1122
+ if (segments[0] === "api" && segments[1] === "process") {
1123
+ const { jobHandlers, processorOptions } = config;
1124
+ const processor = jobQueue.createProcessor(jobHandlers, {
1125
+ workerId: `dashboard-${Date.now()}`,
1126
+ batchSize: 10,
1127
+ ...processorOptions
1128
+ });
1129
+ const processed = await processor.start();
1130
+ return json({ processed });
1131
+ }
1132
+ }
1133
+ return json({ error: "Not found" }, 404);
1134
+ } catch (err) {
1135
+ console.error("[dataqueue-dashboard] Error:", err);
1136
+ return json({ error: err.message || "Internal server error" }, 500);
1137
+ }
1138
+ }
1139
+
1140
+ // src/next.ts
1141
+ function createDataqueueDashboard(config) {
1142
+ const handler = async (request) => {
1143
+ return handleRequest(request, config);
1144
+ };
1145
+ return {
1146
+ GET: handler,
1147
+ POST: handler
1148
+ };
1149
+ }
1150
+
1151
+ export { createDataqueueDashboard };
1152
+ //# sourceMappingURL=next.js.map
29
1153
  //# sourceMappingURL=next.js.map