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