fairchild 0.0.1__py3-none-any.whl → 0.0.3__py3-none-any.whl

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,1650 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Fairchild</title>
7
+ <style>
8
+ :root {
9
+ --bg-primary: #0f172a;
10
+ --bg-secondary: #1e293b;
11
+ --bg-hover: #334155;
12
+ --border-color: #334155;
13
+ --text-primary: #f8fafc;
14
+ --text-secondary: #e2e8f0;
15
+ --text-muted: #94a3b8;
16
+ --text-dim: #64748b;
17
+ --chart-grid: #334155;
18
+ --chart-edge: #475569;
19
+ }
20
+
21
+ [data-theme="light"] {
22
+ --bg-primary: #f8fafc;
23
+ --bg-secondary: #ffffff;
24
+ --bg-hover: #f1f5f9;
25
+ --border-color: #e2e8f0;
26
+ --text-primary: #0f172a;
27
+ --text-secondary: #1e293b;
28
+ --text-muted: #64748b;
29
+ --text-dim: #94a3b8;
30
+ --chart-grid: #e2e8f0;
31
+ --chart-edge: #94a3b8;
32
+ }
33
+
34
+ * {
35
+ box-sizing: border-box;
36
+ margin: 0;
37
+ padding: 0;
38
+ }
39
+ body {
40
+ font-family:
41
+ -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
42
+ background: var(--bg-primary);
43
+ color: var(--text-secondary);
44
+ line-height: 1.5;
45
+ transition:
46
+ background 0.2s,
47
+ color 0.2s;
48
+ }
49
+ .container {
50
+ max-width: 1400px;
51
+ margin: 0 auto;
52
+ padding: 2rem;
53
+ }
54
+
55
+ .header {
56
+ display: flex;
57
+ justify-content: space-between;
58
+ align-items: center;
59
+ margin-bottom: 2rem;
60
+ }
61
+ h1 {
62
+ font-size: 1.5rem;
63
+ font-weight: 600;
64
+ color: var(--text-primary);
65
+ }
66
+ h2 {
67
+ font-size: 1rem;
68
+ font-weight: 500;
69
+ margin-bottom: 1rem;
70
+ color: var(--text-muted);
71
+ }
72
+
73
+ .theme-toggle {
74
+ background: var(--bg-secondary);
75
+ border: 1px solid var(--border-color);
76
+ border-radius: 6px;
77
+ padding: 0.5rem;
78
+ cursor: pointer;
79
+ color: var(--text-muted);
80
+ display: flex;
81
+ align-items: center;
82
+ gap: 0.5rem;
83
+ font-size: 0.875rem;
84
+ }
85
+ .theme-toggle:hover {
86
+ background: var(--bg-hover);
87
+ color: var(--text-primary);
88
+ }
89
+ .theme-toggle svg {
90
+ width: 18px;
91
+ height: 18px;
92
+ }
93
+
94
+ .stats {
95
+ display: grid;
96
+ grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
97
+ gap: 1rem;
98
+ margin-bottom: 2rem;
99
+ }
100
+ .stat {
101
+ background: var(--bg-secondary);
102
+ border-radius: 8px;
103
+ padding: 1.25rem;
104
+ border: 1px solid var(--border-color);
105
+ }
106
+ .stat-value {
107
+ font-size: 2rem;
108
+ font-weight: 700;
109
+ color: var(--text-primary);
110
+ }
111
+ .stat-label {
112
+ font-size: 0.75rem;
113
+ text-transform: uppercase;
114
+ color: var(--text-dim);
115
+ margin-top: 0.25rem;
116
+ }
117
+ .stat.available .stat-value {
118
+ color: #22c55e;
119
+ }
120
+ .stat.running .stat-value {
121
+ color: #3b82f6;
122
+ }
123
+ .stat.completed .stat-value {
124
+ color: #10b981;
125
+ }
126
+ .stat.failed .stat-value {
127
+ color: #f59e0b;
128
+ }
129
+ .stat.discarded .stat-value {
130
+ color: #ef4444;
131
+ }
132
+
133
+ .section {
134
+ margin-bottom: 2rem;
135
+ }
136
+ .section-header {
137
+ display: flex;
138
+ justify-content: space-between;
139
+ align-items: center;
140
+ margin-bottom: 1rem;
141
+ }
142
+ .section-header h2 {
143
+ margin-bottom: 0;
144
+ }
145
+
146
+ .tabs {
147
+ display: flex;
148
+ gap: 0.5rem;
149
+ margin-bottom: 1rem;
150
+ }
151
+ .tab {
152
+ padding: 0.5rem 1rem;
153
+ background: var(--bg-secondary);
154
+ border: 1px solid var(--border-color);
155
+ border-radius: 6px;
156
+ cursor: pointer;
157
+ color: var(--text-muted);
158
+ font-size: 0.875rem;
159
+ }
160
+ .tab:hover {
161
+ background: var(--bg-hover);
162
+ }
163
+ .tab.active {
164
+ background: #3b82f6;
165
+ border-color: #3b82f6;
166
+ color: white;
167
+ }
168
+ .tabs-small {
169
+ margin-bottom: 0;
170
+ }
171
+ .tabs-small .tab {
172
+ padding: 0.25rem 0.625rem;
173
+ font-size: 0.75rem;
174
+ }
175
+ .tab-count {
176
+ display: inline-block;
177
+ background: rgba(203, 213, 225, 0.4);
178
+ padding: 0.125rem 0.375rem;
179
+ border-radius: 4px;
180
+ font-size: 0.7rem;
181
+ margin-left: 0.25rem;
182
+ }
183
+ .tab.active .tab-count {
184
+ background: rgba(255, 255, 255, 0.4);
185
+ }
186
+
187
+ table {
188
+ width: 100%;
189
+ border-collapse: collapse;
190
+ }
191
+ th,
192
+ td {
193
+ text-align: left;
194
+ padding: 0.75rem 1rem;
195
+ border-bottom: 1px solid var(--border-color);
196
+ font-size: 0.875rem;
197
+ }
198
+ th {
199
+ background: var(--bg-secondary);
200
+ color: var(--text-muted);
201
+ font-weight: 500;
202
+ text-transform: uppercase;
203
+ font-size: 0.75rem;
204
+ }
205
+ tr:hover {
206
+ background: var(--bg-secondary);
207
+ }
208
+
209
+ .badge {
210
+ display: inline-block;
211
+ padding: 0.25rem 0.5rem;
212
+ border-radius: 4px;
213
+ font-size: 0.75rem;
214
+ font-weight: 500;
215
+ }
216
+ .badge.available {
217
+ background: #166534;
218
+ color: #bbf7d0;
219
+ }
220
+ .badge.running {
221
+ background: #1d4ed8;
222
+ color: #bfdbfe;
223
+ }
224
+ .badge.scheduled {
225
+ background: #6b21a8;
226
+ color: #e9d5ff;
227
+ }
228
+ .badge.completed {
229
+ background: #115e59;
230
+ color: #99f6e4;
231
+ }
232
+ .badge.failed {
233
+ background: #92400e;
234
+ color: #fde68a;
235
+ }
236
+ .badge.discarded {
237
+ background: #991b1b;
238
+ color: #fecaca;
239
+ }
240
+
241
+ .mono {
242
+ font-family: "SF Mono", Monaco, monospace;
243
+ font-size: 0.8rem;
244
+ }
245
+ .truncate {
246
+ max-width: 200px;
247
+ overflow: hidden;
248
+ text-overflow: ellipsis;
249
+ white-space: nowrap;
250
+ }
251
+ .job-link {
252
+ color: #3b82f6;
253
+ text-decoration: none;
254
+ }
255
+ .job-link:hover {
256
+ text-decoration: underline;
257
+ }
258
+
259
+ .queues {
260
+ display: grid;
261
+ grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
262
+ gap: 1rem;
263
+ }
264
+ .queue-card {
265
+ background: var(--bg-secondary);
266
+ border-radius: 8px;
267
+ padding: 1rem;
268
+ border: 1px solid var(--border-color);
269
+ }
270
+ .queue-name {
271
+ font-weight: 600;
272
+ margin-bottom: 0.5rem;
273
+ color: var(--text-primary);
274
+ }
275
+ .queue-stats {
276
+ display: flex;
277
+ gap: 1rem;
278
+ flex-wrap: wrap;
279
+ }
280
+ .queue-stat {
281
+ font-size: 0.875rem;
282
+ }
283
+ .queue-stat span {
284
+ color: var(--text-dim);
285
+ }
286
+
287
+ .chart-container {
288
+ background: var(--bg-secondary);
289
+ border-radius: 8px;
290
+ padding: 1.5rem;
291
+ border: 1px solid var(--border-color);
292
+ margin-bottom: 2rem;
293
+ }
294
+ .chart-header {
295
+ display: flex;
296
+ justify-content: space-between;
297
+ align-items: center;
298
+ margin-bottom: 1rem;
299
+ }
300
+ .chart-legend {
301
+ display: flex;
302
+ gap: 1.5rem;
303
+ font-size: 0.75rem;
304
+ }
305
+ .chart-legend-item {
306
+ display: flex;
307
+ align-items: center;
308
+ gap: 0.5rem;
309
+ }
310
+ .chart-legend-color {
311
+ width: 12px;
312
+ height: 12px;
313
+ border-radius: 2px;
314
+ }
315
+ #timeseries-chart {
316
+ width: 100%;
317
+ height: 200px;
318
+ }
319
+
320
+ .chart-tooltip {
321
+ position: absolute;
322
+ background: var(--bg-secondary);
323
+ border: 1px solid var(--border-color);
324
+ border-radius: 6px;
325
+ padding: 0.5rem 0.75rem;
326
+ font-size: 0.75rem;
327
+ pointer-events: none;
328
+ z-index: 100;
329
+ opacity: 0;
330
+ transition: opacity 0.15s;
331
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
332
+ }
333
+ .chart-tooltip.visible {
334
+ opacity: 1;
335
+ }
336
+ .chart-tooltip-time {
337
+ font-weight: 600;
338
+ color: var(--text-primary);
339
+ margin-bottom: 0.25rem;
340
+ }
341
+ .chart-tooltip-row {
342
+ display: flex;
343
+ align-items: center;
344
+ gap: 0.5rem;
345
+ color: var(--text-secondary);
346
+ }
347
+ .chart-tooltip-dot {
348
+ width: 8px;
349
+ height: 8px;
350
+ border-radius: 2px;
351
+ }
352
+ .chart-tooltip-value {
353
+ margin-left: auto;
354
+ font-weight: 500;
355
+ }
356
+
357
+ #workflows-table tbody tr {
358
+ cursor: pointer;
359
+ }
360
+ #workflows-table tbody tr:hover {
361
+ background: var(--bg-hover);
362
+ }
363
+
364
+ /* Enqueue Modal */
365
+ .modal-overlay {
366
+ display: none;
367
+ position: fixed;
368
+ top: 0;
369
+ left: 0;
370
+ right: 0;
371
+ bottom: 0;
372
+ background: rgba(0, 0, 0, 0.5);
373
+ z-index: 1000;
374
+ align-items: center;
375
+ justify-content: center;
376
+ }
377
+ .modal-overlay.open {
378
+ display: flex;
379
+ }
380
+ .modal {
381
+ background: var(--bg-secondary);
382
+ border: 1px solid var(--border-color);
383
+ border-radius: 12px;
384
+ padding: 1.5rem;
385
+ width: 100%;
386
+ max-width: 500px;
387
+ max-height: 90vh;
388
+ overflow-y: auto;
389
+ }
390
+ .modal-header {
391
+ display: flex;
392
+ justify-content: space-between;
393
+ align-items: center;
394
+ margin-bottom: 1.5rem;
395
+ }
396
+ .modal-title {
397
+ font-size: 1.25rem;
398
+ font-weight: 600;
399
+ color: var(--text-primary);
400
+ }
401
+ .modal-close {
402
+ background: none;
403
+ border: none;
404
+ color: var(--text-muted);
405
+ cursor: pointer;
406
+ padding: 0.25rem;
407
+ font-size: 1.5rem;
408
+ line-height: 1;
409
+ }
410
+ .modal-close:hover {
411
+ color: var(--text-primary);
412
+ }
413
+ .form-group {
414
+ margin-bottom: 1rem;
415
+ }
416
+ .form-label {
417
+ display: block;
418
+ font-size: 0.875rem;
419
+ font-weight: 500;
420
+ color: var(--text-secondary);
421
+ margin-bottom: 0.5rem;
422
+ }
423
+ .form-input,
424
+ .form-select,
425
+ .form-textarea {
426
+ width: 100%;
427
+ padding: 0.625rem 0.75rem;
428
+ background: var(--bg-primary);
429
+ border: 1px solid var(--border-color);
430
+ border-radius: 6px;
431
+ color: var(--text-primary);
432
+ font-size: 0.875rem;
433
+ font-family: inherit;
434
+ }
435
+ .form-input:focus,
436
+ .form-select:focus,
437
+ .form-textarea:focus {
438
+ outline: none;
439
+ border-color: #3b82f6;
440
+ }
441
+ .form-textarea {
442
+ min-height: 100px;
443
+ font-family: "SF Mono", Monaco, monospace;
444
+ font-size: 0.8rem;
445
+ resize: vertical;
446
+ }
447
+ .form-hint {
448
+ font-size: 0.75rem;
449
+ color: var(--text-dim);
450
+ margin-top: 0.25rem;
451
+ }
452
+ .form-row {
453
+ display: grid;
454
+ grid-template-columns: 1fr 1fr;
455
+ gap: 1rem;
456
+ }
457
+ .form-actions {
458
+ display: flex;
459
+ justify-content: flex-end;
460
+ gap: 0.75rem;
461
+ margin-top: 1.5rem;
462
+ }
463
+ .btn {
464
+ padding: 0.625rem 1rem;
465
+ border-radius: 6px;
466
+ font-size: 0.875rem;
467
+ font-weight: 500;
468
+ cursor: pointer;
469
+ border: 1px solid transparent;
470
+ }
471
+ .btn-secondary {
472
+ background: var(--bg-primary);
473
+ border-color: var(--border-color);
474
+ color: var(--text-secondary);
475
+ }
476
+ .btn-secondary:hover {
477
+ background: var(--bg-hover);
478
+ }
479
+ .btn-primary {
480
+ background: #3b82f6;
481
+ color: white;
482
+ }
483
+ .btn-primary:hover {
484
+ background: #2563eb;
485
+ }
486
+ .btn-primary:disabled {
487
+ opacity: 0.5;
488
+ cursor: not-allowed;
489
+ }
490
+ .form-error {
491
+ color: #ef4444;
492
+ font-size: 0.875rem;
493
+ margin-top: 0.5rem;
494
+ }
495
+ .form-success {
496
+ color: #22c55e;
497
+ font-size: 0.875rem;
498
+ margin-top: 0.5rem;
499
+ }
500
+ .enqueue-btn {
501
+ background: #3b82f6;
502
+ border: none;
503
+ color: white;
504
+ padding: 0.5rem 1rem;
505
+ border-radius: 6px;
506
+ cursor: pointer;
507
+ font-size: 0.875rem;
508
+ font-weight: 500;
509
+ display: flex;
510
+ align-items: center;
511
+ gap: 0.5rem;
512
+ }
513
+ .enqueue-btn:hover {
514
+ background: #2563eb;
515
+ }
516
+ .header-actions {
517
+ display: flex;
518
+ align-items: center;
519
+ gap: 0.75rem;
520
+ }
521
+
522
+ /* Workers section */
523
+ .workers-table {
524
+ width: 100%;
525
+ border-collapse: collapse;
526
+ background: var(--bg-secondary);
527
+ border-radius: 8px;
528
+ overflow: hidden;
529
+ border: 1px solid var(--border-color);
530
+ }
531
+ .workers-table th,
532
+ .workers-table td {
533
+ text-align: left;
534
+ padding: 0.75rem 1rem;
535
+ border-bottom: 1px solid var(--border-color);
536
+ font-size: 0.875rem;
537
+ }
538
+ .workers-table th {
539
+ background: var(--bg-secondary);
540
+ color: var(--text-muted);
541
+ font-weight: 500;
542
+ text-transform: uppercase;
543
+ font-size: 0.75rem;
544
+ }
545
+ .workers-table tr:last-child td {
546
+ border-bottom: none;
547
+ }
548
+ .workers-table tr:hover td {
549
+ background: var(--bg-hover);
550
+ }
551
+ .worker-queues {
552
+ display: flex;
553
+ flex-wrap: wrap;
554
+ gap: 0.375rem;
555
+ }
556
+ .worker-queue-tag {
557
+ display: inline-flex;
558
+ align-items: center;
559
+ gap: 0.25rem;
560
+ padding: 0.125rem 0.5rem;
561
+ background: var(--bg-primary);
562
+ border: 1px solid var(--border-color);
563
+ border-radius: 4px;
564
+ font-size: 0.75rem;
565
+ color: var(--text-secondary);
566
+ }
567
+ .worker-queue-tag .slots {
568
+ color: var(--text-dim);
569
+ }
570
+ .btn-pause {
571
+ padding: 0.375rem 0.75rem;
572
+ border-radius: 4px;
573
+ font-size: 0.75rem;
574
+ font-weight: 500;
575
+ cursor: pointer;
576
+ border: 1px solid transparent;
577
+ background: #92400e;
578
+ color: #fde68a;
579
+ }
580
+ .btn-pause:hover {
581
+ background: #78350f;
582
+ }
583
+ .btn-resume {
584
+ padding: 0.375rem 0.75rem;
585
+ border-radius: 4px;
586
+ font-size: 0.75rem;
587
+ font-weight: 500;
588
+ cursor: pointer;
589
+ border: 1px solid transparent;
590
+ background: #166534;
591
+ color: #bbf7d0;
592
+ }
593
+ .btn-resume:hover {
594
+ background: #14532d;
595
+ }
596
+ .btn-pause:disabled,
597
+ .btn-resume:disabled {
598
+ opacity: 0.5;
599
+ cursor: not-allowed;
600
+ }
601
+ .worker-state {
602
+ display: inline-block;
603
+ padding: 0.25rem 0.5rem;
604
+ border-radius: 4px;
605
+ font-size: 0.75rem;
606
+ font-weight: 500;
607
+ }
608
+ .worker-state.running {
609
+ background: #166534;
610
+ color: #bbf7d0;
611
+ }
612
+ .worker-state.idle {
613
+ background: #374151;
614
+ color: #9ca3af;
615
+ }
616
+ .worker-state.paused {
617
+ background: #92400e;
618
+ color: #fde68a;
619
+ }
620
+ .worker-state.stale {
621
+ background: #991b1b;
622
+ color: #fecaca;
623
+ }
624
+ .no-workers {
625
+ text-align: center;
626
+ padding: 2rem;
627
+ color: var(--text-dim);
628
+ background: var(--bg-secondary);
629
+ border-radius: 8px;
630
+ border: 1px solid var(--border-color);
631
+ }
632
+ </style>
633
+ </head>
634
+ <body>
635
+ <div class="container">
636
+ <div class="header">
637
+ <h1>Fairchild</h1>
638
+ <div class="header-actions">
639
+ <button class="enqueue-btn" onclick="openEnqueueModal()">
640
+ <svg
641
+ xmlns="http://www.w3.org/2000/svg"
642
+ width="16"
643
+ height="16"
644
+ viewBox="0 0 24 24"
645
+ fill="none"
646
+ stroke="currentColor"
647
+ stroke-width="2"
648
+ stroke-linecap="round"
649
+ stroke-linejoin="round"
650
+ >
651
+ <line x1="12" y1="5" x2="12" y2="19"></line>
652
+ <line x1="5" y1="12" x2="19" y2="12"></line>
653
+ </svg>
654
+ Enqueue Job
655
+ </button>
656
+ <button
657
+ class="theme-toggle"
658
+ onclick="toggleTheme()"
659
+ title="Toggle theme"
660
+ >
661
+ <svg
662
+ id="theme-icon-dark"
663
+ xmlns="http://www.w3.org/2000/svg"
664
+ fill="none"
665
+ viewBox="0 0 24 24"
666
+ stroke="currentColor"
667
+ >
668
+ <path
669
+ stroke-linecap="round"
670
+ stroke-linejoin="round"
671
+ stroke-width="2"
672
+ d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"
673
+ />
674
+ </svg>
675
+ <svg
676
+ id="theme-icon-light"
677
+ xmlns="http://www.w3.org/2000/svg"
678
+ fill="none"
679
+ viewBox="0 0 24 24"
680
+ stroke="currentColor"
681
+ style="display: none"
682
+ >
683
+ <path
684
+ stroke-linecap="round"
685
+ stroke-linejoin="round"
686
+ stroke-width="2"
687
+ d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"
688
+ />
689
+ </svg>
690
+ </button>
691
+ </div>
692
+ </div>
693
+
694
+ <div class="stats" id="stats"></div>
695
+
696
+ <div class="section">
697
+ <div class="chart-container">
698
+ <div class="chart-header">
699
+ <h2 style="margin-bottom: 0">Jobs per Minute (Last 60 min)</h2>
700
+ <div class="chart-legend">
701
+ <div class="chart-legend-item">
702
+ <div
703
+ class="chart-legend-color"
704
+ style="background: #3b82f6"
705
+ ></div>
706
+ <span>Queued</span>
707
+ </div>
708
+ <div class="chart-legend-item">
709
+ <div
710
+ class="chart-legend-color"
711
+ style="background: #10b981"
712
+ ></div>
713
+ <span>Completed</span>
714
+ </div>
715
+ <div class="chart-legend-item">
716
+ <div
717
+ class="chart-legend-color"
718
+ style="background: #ef4444"
719
+ ></div>
720
+ <span>Failed</span>
721
+ </div>
722
+ </div>
723
+ </div>
724
+ <div style="position: relative">
725
+ <canvas id="timeseries-chart"></canvas>
726
+ <div class="chart-tooltip" id="chart-tooltip">
727
+ <div class="chart-tooltip-time" id="tooltip-time"></div>
728
+ <div class="chart-tooltip-row">
729
+ <div
730
+ class="chart-tooltip-dot"
731
+ style="background: #3b82f6"
732
+ ></div>
733
+ <span>Queued</span>
734
+ <span class="chart-tooltip-value" id="tooltip-queued">0</span>
735
+ </div>
736
+ <div class="chart-tooltip-row">
737
+ <div
738
+ class="chart-tooltip-dot"
739
+ style="background: #10b981"
740
+ ></div>
741
+ <span>Completed</span>
742
+ <span class="chart-tooltip-value" id="tooltip-completed"
743
+ >0</span
744
+ >
745
+ </div>
746
+ <div class="chart-tooltip-row">
747
+ <div
748
+ class="chart-tooltip-dot"
749
+ style="background: #ef4444"
750
+ ></div>
751
+ <span>Failed</span>
752
+ <span class="chart-tooltip-value" id="tooltip-failed">0</span>
753
+ </div>
754
+ </div>
755
+ </div>
756
+ </div>
757
+ </div>
758
+
759
+ <div class="section">
760
+ <h2>Queues</h2>
761
+ <div class="queues" id="queues"></div>
762
+ </div>
763
+
764
+ <div class="section">
765
+ <div class="section-header">
766
+ <h2>Recent Jobs</h2>
767
+ <div class="tabs tabs-small" id="jobs-tabs">
768
+ <div class="tab active" data-state="">
769
+ All <span class="tab-count" id="jobs-count-all">0</span>
770
+ </div>
771
+ <div class="tab" data-state="available">
772
+ Available
773
+ <span class="tab-count" id="jobs-count-available">0</span>
774
+ </div>
775
+ <div class="tab" data-state="running">
776
+ Running <span class="tab-count" id="jobs-count-running">0</span>
777
+ </div>
778
+ <div class="tab" data-state="completed">
779
+ Completed
780
+ <span class="tab-count" id="jobs-count-completed">0</span>
781
+ </div>
782
+ <div class="tab" data-state="failed">
783
+ Failed <span class="tab-count" id="jobs-count-failed">0</span>
784
+ </div>
785
+ <div class="tab" data-state="discarded">
786
+ Discarded
787
+ <span class="tab-count" id="jobs-count-discarded">0</span>
788
+ </div>
789
+ </div>
790
+ </div>
791
+ <table id="jobs-table">
792
+ <thead>
793
+ <tr>
794
+ <th>ID</th>
795
+ <th>Task</th>
796
+ <th>Queue</th>
797
+ <th>State</th>
798
+ <th>Attempts</th>
799
+ <th>Created</th>
800
+ </tr>
801
+ </thead>
802
+ <tbody></tbody>
803
+ </table>
804
+ </div>
805
+
806
+ <div class="section">
807
+ <div class="section-header">
808
+ <h2>Workers</h2>
809
+ <div class="tabs tabs-small" id="worker-tabs">
810
+ <div class="tab active" data-worker-state="">
811
+ All <span class="tab-count" id="worker-count-all">0</span>
812
+ </div>
813
+ <div class="tab" data-worker-state="running">
814
+ Running <span class="tab-count" id="worker-count-running">0</span>
815
+ </div>
816
+ <div class="tab" data-worker-state="idle">
817
+ Idle <span class="tab-count" id="worker-count-idle">0</span>
818
+ </div>
819
+ <div class="tab" data-worker-state="paused">
820
+ Paused <span class="tab-count" id="worker-count-paused">0</span>
821
+ </div>
822
+ <div class="tab" data-worker-state="stale">
823
+ Stale <span class="tab-count" id="worker-count-stale">0</span>
824
+ </div>
825
+ </div>
826
+ </div>
827
+ <div id="workers"></div>
828
+ </div>
829
+ </div>
830
+
831
+ <!-- Enqueue Job Modal -->
832
+ <div class="modal-overlay" id="enqueue-modal">
833
+ <div class="modal">
834
+ <div class="modal-header">
835
+ <h2 class="modal-title">Enqueue Job</h2>
836
+ <button class="modal-close" onclick="closeEnqueueModal()">
837
+ &times;
838
+ </button>
839
+ </div>
840
+ <form id="enqueue-form" onsubmit="submitEnqueueForm(event)">
841
+ <div class="form-group">
842
+ <label class="form-label" for="enqueue-task">Task</label>
843
+ <select
844
+ class="form-select"
845
+ id="enqueue-task"
846
+ required
847
+ onchange="onTaskSelected()"
848
+ >
849
+ <option value="">Select a task...</option>
850
+ </select>
851
+ <div class="form-hint" id="enqueue-task-params"></div>
852
+ <div
853
+ class="form-hint"
854
+ id="enqueue-task-docstring"
855
+ style="
856
+ margin-top: 0.5rem;
857
+ white-space: pre-wrap;
858
+ color: var(--text-secondary);
859
+ "
860
+ ></div>
861
+ </div>
862
+ <div class="form-group">
863
+ <label class="form-label" for="enqueue-args"
864
+ >Arguments (JSON)</label
865
+ >
866
+ <textarea
867
+ class="form-textarea"
868
+ id="enqueue-args"
869
+ placeholder='{&#10; "key": "value"&#10;}'
870
+ >
871
+ {}</textarea
872
+ >
873
+ <div class="form-hint">JSON object with task arguments</div>
874
+ </div>
875
+ <div class="form-row">
876
+ <div class="form-group">
877
+ <label class="form-label" for="enqueue-priority">Priority</label>
878
+ <input
879
+ class="form-input"
880
+ type="number"
881
+ id="enqueue-priority"
882
+ min="0"
883
+ max="9"
884
+ placeholder="0-9 (lower = higher)"
885
+ />
886
+ <div class="form-hint">Optional, uses task default</div>
887
+ </div>
888
+ <div class="form-group">
889
+ <label class="form-label" for="enqueue-scheduled"
890
+ >Schedule For</label
891
+ >
892
+ <input
893
+ class="form-input"
894
+ type="datetime-local"
895
+ id="enqueue-scheduled"
896
+ />
897
+ <div class="form-hint">Optional, runs immediately if empty</div>
898
+ </div>
899
+ </div>
900
+ <div id="enqueue-message"></div>
901
+ <div class="form-actions">
902
+ <button
903
+ type="button"
904
+ class="btn btn-secondary"
905
+ onclick="closeEnqueueModal()"
906
+ >
907
+ Cancel
908
+ </button>
909
+ <button type="submit" class="btn btn-primary" id="enqueue-submit">
910
+ Enqueue
911
+ </button>
912
+ </div>
913
+ </form>
914
+ </div>
915
+ </div>
916
+
917
+ <script>
918
+ // Theme management
919
+ function getSystemTheme() {
920
+ return window.matchMedia("(prefers-color-scheme: light)").matches
921
+ ? "light"
922
+ : "dark";
923
+ }
924
+
925
+ function getStoredTheme() {
926
+ return localStorage.getItem("fairchild-theme");
927
+ }
928
+
929
+ function setTheme(theme) {
930
+ document.documentElement.setAttribute("data-theme", theme);
931
+ localStorage.setItem("fairchild-theme", theme);
932
+ updateThemeIcon(theme);
933
+ }
934
+
935
+ function updateThemeIcon(theme) {
936
+ const darkIcon = document.getElementById("theme-icon-dark");
937
+ const lightIcon = document.getElementById("theme-icon-light");
938
+ if (theme === "light") {
939
+ darkIcon.style.display = "none";
940
+ lightIcon.style.display = "block";
941
+ } else {
942
+ darkIcon.style.display = "block";
943
+ lightIcon.style.display = "none";
944
+ }
945
+ }
946
+
947
+ function toggleTheme() {
948
+ const current =
949
+ document.documentElement.getAttribute("data-theme") ||
950
+ getSystemTheme();
951
+ const next = current === "light" ? "dark" : "light";
952
+ setTheme(next);
953
+ // Re-render charts with new colors
954
+ fetchTimeseries();
955
+ }
956
+
957
+ // Initialize theme
958
+ const storedTheme = getStoredTheme();
959
+ if (storedTheme) {
960
+ setTheme(storedTheme);
961
+ } else {
962
+ const systemTheme = getSystemTheme();
963
+ document.documentElement.setAttribute("data-theme", systemTheme);
964
+ updateThemeIcon(systemTheme);
965
+ }
966
+
967
+ // Listen for system theme changes
968
+ window
969
+ .matchMedia("(prefers-color-scheme: light)")
970
+ .addEventListener("change", (e) => {
971
+ if (!getStoredTheme()) {
972
+ const newTheme = e.matches ? "light" : "dark";
973
+ document.documentElement.setAttribute("data-theme", newTheme);
974
+ updateThemeIcon(newTheme);
975
+ fetchTimeseries();
976
+ }
977
+ });
978
+
979
+ function getChartColors() {
980
+ const style = getComputedStyle(document.documentElement);
981
+ return {
982
+ grid: style.getPropertyValue("--chart-grid").trim() || "#334155",
983
+ edge: style.getPropertyValue("--chart-edge").trim() || "#475569",
984
+ text: style.getPropertyValue("--text-dim").trim() || "#64748b",
985
+ };
986
+ }
987
+
988
+ let currentState = "";
989
+ let currentWorkerState = "";
990
+
991
+ async function fetchStats() {
992
+ const res = await fetch("/api/stats");
993
+ const stats = await res.json();
994
+
995
+ const order = [
996
+ "available",
997
+ "running",
998
+ "scheduled",
999
+ "completed",
1000
+ "failed",
1001
+ "discarded",
1002
+ ];
1003
+ const container = document.getElementById("stats");
1004
+ container.innerHTML = order
1005
+ .map(
1006
+ (state) => `
1007
+ <div class="stat ${state}">
1008
+ <div class="stat-value">${stats[state] || 0}</div>
1009
+ <div class="stat-label">${state}</div>
1010
+ </div>
1011
+ `,
1012
+ )
1013
+ .join("");
1014
+
1015
+ // Update job tab counts
1016
+ const total = order.reduce(
1017
+ (sum, state) => sum + (stats[state] || 0),
1018
+ 0,
1019
+ );
1020
+ document.getElementById("jobs-count-all").textContent = total;
1021
+ document.getElementById("jobs-count-available").textContent =
1022
+ stats.available || 0;
1023
+ document.getElementById("jobs-count-running").textContent =
1024
+ stats.running || 0;
1025
+ document.getElementById("jobs-count-completed").textContent =
1026
+ stats.completed || 0;
1027
+ document.getElementById("jobs-count-failed").textContent =
1028
+ stats.failed || 0;
1029
+ document.getElementById("jobs-count-discarded").textContent =
1030
+ stats.discarded || 0;
1031
+ }
1032
+
1033
+ async function fetchQueues() {
1034
+ const res = await fetch("/api/queues");
1035
+ const queues = await res.json();
1036
+
1037
+ const container = document.getElementById("queues");
1038
+ container.innerHTML = Object.entries(queues)
1039
+ .map(
1040
+ ([name, stats]) => `
1041
+ <div class="queue-card">
1042
+ <div class="queue-name">${name}</div>
1043
+ <div class="queue-stats">
1044
+ ${Object.entries(stats)
1045
+ .map(
1046
+ ([state, count]) => `
1047
+ <div class="queue-stat"><span>${state}:</span> ${count}</div>
1048
+ `,
1049
+ )
1050
+ .join("")}
1051
+ </div>
1052
+ </div>
1053
+ `,
1054
+ )
1055
+ .join("");
1056
+ }
1057
+
1058
+ function formatTimeAgo(iso) {
1059
+ if (!iso) return "-";
1060
+ const d = new Date(iso);
1061
+ const now = new Date();
1062
+ const diffMs = now - d;
1063
+ const diffSec = Math.floor(diffMs / 1000);
1064
+
1065
+ if (diffSec < 60) {
1066
+ return `${diffSec}s ago`;
1067
+ } else if (diffSec < 3600) {
1068
+ const mins = Math.floor(diffSec / 60);
1069
+ return `${mins}m ago`;
1070
+ } else {
1071
+ return formatTime(iso);
1072
+ }
1073
+ }
1074
+
1075
+ async function fetchWorkers(filterState = "") {
1076
+ const res = await fetch("/api/workers");
1077
+ const workers = await res.json();
1078
+
1079
+ const container = document.getElementById("workers");
1080
+
1081
+ // Calculate display state for each worker and filter
1082
+ const workersWithState = workers.map((worker) => {
1083
+ const displayState =
1084
+ worker.state === "running" && worker.active_jobs === 0
1085
+ ? "idle"
1086
+ : worker.state;
1087
+ return { ...worker, displayState };
1088
+ });
1089
+
1090
+ // Update counts in tabs
1091
+ const counts = {
1092
+ all: workersWithState.length,
1093
+ running: 0,
1094
+ idle: 0,
1095
+ paused: 0,
1096
+ stale: 0,
1097
+ };
1098
+ workersWithState.forEach((w) => {
1099
+ if (counts[w.displayState] !== undefined) {
1100
+ counts[w.displayState]++;
1101
+ }
1102
+ });
1103
+ document.getElementById("worker-count-all").textContent = counts.all;
1104
+ document.getElementById("worker-count-running").textContent =
1105
+ counts.running;
1106
+ document.getElementById("worker-count-idle").textContent = counts.idle;
1107
+ document.getElementById("worker-count-paused").textContent =
1108
+ counts.paused;
1109
+ document.getElementById("worker-count-stale").textContent =
1110
+ counts.stale;
1111
+
1112
+ const filteredWorkers = filterState
1113
+ ? workersWithState.filter((w) => w.displayState === filterState)
1114
+ : workersWithState;
1115
+
1116
+ if (filteredWorkers.length === 0) {
1117
+ const message = filterState
1118
+ ? `No ${filterState} workers`
1119
+ : "No workers running";
1120
+ container.innerHTML = `<div class="no-workers">${message}</div>`;
1121
+ return;
1122
+ }
1123
+
1124
+ const rows = filteredWorkers
1125
+ .map((worker) => {
1126
+ // Parse queues - handle both object and string (double-encoded) cases
1127
+ let queues = worker.queues || {};
1128
+ if (typeof queues === "string") {
1129
+ try {
1130
+ queues = JSON.parse(queues);
1131
+ } catch (e) {
1132
+ queues = {};
1133
+ }
1134
+ }
1135
+
1136
+ const queueTags = Object.entries(queues)
1137
+ .map(
1138
+ ([name, slots]) =>
1139
+ `<span class="worker-queue-tag">${name} <span class="slots">×${slots}</span></span>`,
1140
+ )
1141
+ .join("");
1142
+
1143
+ const isPaused = worker.state === "paused";
1144
+ const actionBtn = isPaused
1145
+ ? `<button class="btn-resume" onclick="resumeWorker('${worker.id}')">Resume</button>`
1146
+ : `<button class="btn-pause" onclick="pauseWorker('${worker.id}')">Pause</button>`;
1147
+
1148
+ const displayState = worker.displayState;
1149
+
1150
+ const timeAgo = formatTimeAgo(worker.last_heartbeat_at);
1151
+ const fullTime = worker.last_heartbeat_at
1152
+ ? new Date(worker.last_heartbeat_at).toLocaleString()
1153
+ : "";
1154
+
1155
+ return `
1156
+ <tr>
1157
+ <td class="mono">${worker.hostname}</td>
1158
+ <td><span class="worker-state ${displayState}">${displayState}</span></td>
1159
+ <td><div class="worker-queues">${queueTags || "-"}</div></td>
1160
+ <td>${worker.active_jobs}</td>
1161
+ <td class="mono" title="${fullTime}">${timeAgo}</td>
1162
+ <td>${actionBtn}</td>
1163
+ </tr>
1164
+ `;
1165
+ })
1166
+ .join("");
1167
+
1168
+ container.innerHTML = `
1169
+ <table class="workers-table">
1170
+ <thead>
1171
+ <tr>
1172
+ <th>Hostname</th>
1173
+ <th>State</th>
1174
+ <th>Queues</th>
1175
+ <th>Active Jobs</th>
1176
+ <th>Last Heartbeat</th>
1177
+ <th>Actions</th>
1178
+ </tr>
1179
+ </thead>
1180
+ <tbody>${rows}</tbody>
1181
+ </table>
1182
+ `;
1183
+ }
1184
+
1185
+ async function pauseWorker(workerId) {
1186
+ try {
1187
+ const res = await fetch(`/api/workers/${workerId}/pause`, {
1188
+ method: "POST",
1189
+ });
1190
+ if (res.ok) {
1191
+ fetchWorkers();
1192
+ } else {
1193
+ const data = await res.json();
1194
+ alert(data.error || "Failed to pause worker");
1195
+ }
1196
+ } catch (err) {
1197
+ alert("Error pausing worker: " + err.message);
1198
+ }
1199
+ }
1200
+
1201
+ async function resumeWorker(workerId) {
1202
+ try {
1203
+ const res = await fetch(`/api/workers/${workerId}/resume`, {
1204
+ method: "POST",
1205
+ });
1206
+ if (res.ok) {
1207
+ fetchWorkers();
1208
+ } else {
1209
+ const data = await res.json();
1210
+ alert(data.error || "Failed to resume worker");
1211
+ }
1212
+ } catch (err) {
1213
+ alert("Error resuming worker: " + err.message);
1214
+ }
1215
+ }
1216
+
1217
+ async function fetchWorkflows() {
1218
+ const res = await fetch("/api/workflows");
1219
+ const workflows = await res.json();
1220
+
1221
+ const tbody = document.querySelector("#workflows-table tbody");
1222
+ tbody.innerHTML = workflows
1223
+ .map(
1224
+ (wf) => `
1225
+ <tr onclick="window.location='/workflows/${wf.workflow_id}'">
1226
+ <td>${wf.workflow_name || wf.workflow_id.slice(0, 8)}</td>
1227
+ <td>${wf.completed}/${wf.total_jobs} completed</td>
1228
+ <td class="mono">${formatTime(wf.started_at)}</td>
1229
+ <td class="mono">${wf.finished_at ? formatTime(wf.finished_at) : "-"}</td>
1230
+ </tr>
1231
+ `,
1232
+ )
1233
+ .join("");
1234
+ }
1235
+
1236
+ async function fetchJobs(state = "") {
1237
+ const url = state ? `/api/jobs?state=${state}` : "/api/jobs";
1238
+ const res = await fetch(url);
1239
+ const jobs = await res.json();
1240
+
1241
+ const tbody = document.querySelector("#jobs-table tbody");
1242
+ tbody.innerHTML = jobs
1243
+ .map(
1244
+ (job) => `
1245
+ <tr>
1246
+ <td class="mono"><a href="/jobs/${job.id}" class="job-link">${job.id}</a></td>
1247
+ <td class="truncate">${job.task_name}</td>
1248
+ <td>${job.queue}</td>
1249
+ <td><span class="badge ${job.state}">${job.state}</span></td>
1250
+ <td>${job.attempt}/${job.max_attempts}</td>
1251
+ <td class="mono">${formatTime(job.inserted_at)}</td>
1252
+ </tr>
1253
+ `,
1254
+ )
1255
+ .join("");
1256
+ }
1257
+
1258
+ function formatTime(iso) {
1259
+ if (!iso) return "-";
1260
+ const d = new Date(iso);
1261
+ return d.toLocaleTimeString();
1262
+ }
1263
+
1264
+ // Tab handling for jobs
1265
+ document.querySelectorAll(".tab[data-state]").forEach((tab) => {
1266
+ tab.addEventListener("click", () => {
1267
+ document
1268
+ .querySelectorAll(".tab[data-state]")
1269
+ .forEach((t) => t.classList.remove("active"));
1270
+ tab.classList.add("active");
1271
+ currentState = tab.dataset.state;
1272
+ fetchJobs(currentState);
1273
+ });
1274
+ });
1275
+
1276
+ // Tab handling for workers
1277
+ document.querySelectorAll(".tab[data-worker-state]").forEach((tab) => {
1278
+ tab.addEventListener("click", () => {
1279
+ document
1280
+ .querySelectorAll(".tab[data-worker-state]")
1281
+ .forEach((t) => t.classList.remove("active"));
1282
+ tab.classList.add("active");
1283
+ currentWorkerState = tab.dataset.workerState;
1284
+ fetchWorkers(currentWorkerState);
1285
+ });
1286
+ });
1287
+
1288
+ // Chart rendering
1289
+ async function fetchTimeseries() {
1290
+ const res = await fetch("/api/timeseries");
1291
+ const data = await res.json();
1292
+ renderChart(data);
1293
+ }
1294
+
1295
+ // Store chart data for tooltip
1296
+ let chartData = { inserted: [], completed: [], failed: [], times: [] };
1297
+ let chartLayout = { padding: {}, barWidth: 0, width: 0 };
1298
+
1299
+ function renderChart(data) {
1300
+ const canvas = document.getElementById("timeseries-chart");
1301
+ const ctx = canvas.getContext("2d");
1302
+ const dpr = window.devicePixelRatio || 1;
1303
+
1304
+ // Set canvas size
1305
+ const rect = canvas.getBoundingClientRect();
1306
+ canvas.width = rect.width * dpr;
1307
+ canvas.height = rect.height * dpr;
1308
+ ctx.scale(dpr, dpr);
1309
+
1310
+ const width = rect.width;
1311
+ const height = rect.height;
1312
+ const padding = { top: 20, right: 20, bottom: 30, left: 50 };
1313
+ const chartWidth = width - padding.left - padding.right;
1314
+ const chartHeight = height - padding.top - padding.bottom;
1315
+
1316
+ // Generate last 60 minutes
1317
+ const now = new Date();
1318
+ now.setSeconds(0, 0);
1319
+ const minutes = [];
1320
+ const times = [];
1321
+ for (let i = 59; i >= 0; i--) {
1322
+ const d = new Date(now.getTime() - i * 60000);
1323
+ minutes.push(d.toISOString().slice(0, 16) + ":00+00:00");
1324
+ times.push(d);
1325
+ }
1326
+
1327
+ // Extract values
1328
+ const inserted = minutes.map((m) => data.inserted[m] || 0);
1329
+ const completed = minutes.map((m) => data.completed[m] || 0);
1330
+ const failed = minutes.map((m) => data.failed[m] || 0);
1331
+
1332
+ // Store for tooltip
1333
+ chartData = { inserted, completed, failed, times };
1334
+ const barWidth = Math.max(2, chartWidth / 60 - 2);
1335
+ chartLayout = { padding, barWidth, width };
1336
+
1337
+ // Calculate max for scaling
1338
+ const maxVal = Math.max(1, ...inserted, ...completed, ...failed);
1339
+
1340
+ // Clear canvas
1341
+ ctx.clearRect(0, 0, width, height);
1342
+
1343
+ // Get theme-aware colors
1344
+ const colors = getChartColors();
1345
+
1346
+ // Draw grid lines
1347
+ ctx.strokeStyle = colors.grid;
1348
+ ctx.lineWidth = 1;
1349
+ const gridLines = 4;
1350
+ for (let i = 0; i <= gridLines; i++) {
1351
+ const y = padding.top + (chartHeight / gridLines) * i;
1352
+ ctx.beginPath();
1353
+ ctx.moveTo(padding.left, y);
1354
+ ctx.lineTo(width - padding.right, y);
1355
+ ctx.stroke();
1356
+
1357
+ // Y-axis labels
1358
+ const val = Math.round(maxVal * (1 - i / gridLines));
1359
+ ctx.fillStyle = colors.text;
1360
+ ctx.font = "11px -apple-system, sans-serif";
1361
+ ctx.textAlign = "right";
1362
+ ctx.fillText(val.toString(), padding.left - 8, y + 4);
1363
+ }
1364
+
1365
+ // Draw x-axis labels (every 15 min)
1366
+ ctx.textAlign = "center";
1367
+ for (let i = 0; i < 60; i += 15) {
1368
+ const x = padding.left + (chartWidth / 59) * i;
1369
+ const d = new Date(now.getTime() - (59 - i) * 60000);
1370
+ const label = d.toLocaleTimeString([], {
1371
+ hour: "2-digit",
1372
+ minute: "2-digit",
1373
+ });
1374
+ ctx.fillText(label, x, height - 8);
1375
+ }
1376
+
1377
+ // Draw bars (stacked)
1378
+ for (let i = 0; i < 60; i++) {
1379
+ const x = padding.left + (chartWidth / 60) * i + 1;
1380
+
1381
+ let y = padding.top + chartHeight;
1382
+
1383
+ // Failed (bottom, red)
1384
+ if (failed[i] > 0) {
1385
+ const h = (failed[i] / maxVal) * chartHeight;
1386
+ ctx.fillStyle = "#ef4444";
1387
+ ctx.fillRect(x, y - h, barWidth, h);
1388
+ y -= h;
1389
+ }
1390
+
1391
+ // Completed (middle, green)
1392
+ if (completed[i] > 0) {
1393
+ const h = (completed[i] / maxVal) * chartHeight;
1394
+ ctx.fillStyle = "#10b981";
1395
+ ctx.fillRect(x, y - h, barWidth, h);
1396
+ y -= h;
1397
+ }
1398
+
1399
+ // Inserted (top, blue)
1400
+ if (inserted[i] > 0) {
1401
+ const h = (inserted[i] / maxVal) * chartHeight;
1402
+ ctx.fillStyle = "#3b82f6";
1403
+ ctx.fillRect(x, y - h, barWidth, h);
1404
+ }
1405
+ }
1406
+ }
1407
+
1408
+ // Chart tooltip handling
1409
+ const chartCanvas = document.getElementById("timeseries-chart");
1410
+ const tooltip = document.getElementById("chart-tooltip");
1411
+
1412
+ chartCanvas.addEventListener("mousemove", (e) => {
1413
+ const rect = chartCanvas.getBoundingClientRect();
1414
+ const x = e.clientX - rect.left;
1415
+
1416
+ // Calculate which bar we're over
1417
+ const { padding, barWidth, width } = chartLayout;
1418
+ const chartWidth = width - padding.left - padding.right;
1419
+ const barIndex = Math.floor((x - padding.left) / (chartWidth / 60));
1420
+
1421
+ if (barIndex >= 0 && barIndex < 60 && chartData.times.length > 0) {
1422
+ const time = chartData.times[barIndex];
1423
+ const queued = chartData.inserted[barIndex] || 0;
1424
+ const completed = chartData.completed[barIndex] || 0;
1425
+ const failed = chartData.failed[barIndex] || 0;
1426
+
1427
+ // Update tooltip content
1428
+ document.getElementById("tooltip-time").textContent =
1429
+ time.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
1430
+ document.getElementById("tooltip-queued").textContent = queued;
1431
+ document.getElementById("tooltip-completed").textContent = completed;
1432
+ document.getElementById("tooltip-failed").textContent = failed;
1433
+
1434
+ // Position tooltip
1435
+ let tooltipX = e.clientX - rect.left + 10;
1436
+ let tooltipY = e.clientY - rect.top - 10;
1437
+
1438
+ // Keep tooltip in bounds
1439
+ const tooltipRect = tooltip.getBoundingClientRect();
1440
+ if (tooltipX + tooltipRect.width > rect.width) {
1441
+ tooltipX = e.clientX - rect.left - tooltipRect.width - 10;
1442
+ }
1443
+ if (tooltipY < 0) {
1444
+ tooltipY = e.clientY - rect.top + 20;
1445
+ }
1446
+
1447
+ tooltip.style.left = tooltipX + "px";
1448
+ tooltip.style.top = tooltipY + "px";
1449
+ tooltip.classList.add("visible");
1450
+ } else {
1451
+ tooltip.classList.remove("visible");
1452
+ }
1453
+ });
1454
+
1455
+ chartCanvas.addEventListener("mouseleave", () => {
1456
+ tooltip.classList.remove("visible");
1457
+ });
1458
+
1459
+ // Initial load and refresh
1460
+ async function refresh() {
1461
+ await Promise.all([
1462
+ fetchStats(),
1463
+ fetchQueues(),
1464
+ fetchWorkers(currentWorkerState),
1465
+ fetchWorkflows(),
1466
+ fetchJobs(currentState),
1467
+ fetchTimeseries(),
1468
+ ]);
1469
+ }
1470
+
1471
+ refresh();
1472
+ setInterval(refresh, 30000);
1473
+
1474
+ // Update worker heartbeat times every second
1475
+ setInterval(() => {
1476
+ const cells = document.querySelectorAll(".workers-table td[title]");
1477
+ cells.forEach((cell) => {
1478
+ const fullTime = cell.getAttribute("title");
1479
+ if (fullTime) {
1480
+ const iso = new Date(fullTime).toISOString();
1481
+ cell.textContent = formatTimeAgo(iso);
1482
+ }
1483
+ });
1484
+ }, 1000);
1485
+
1486
+ // Enqueue modal
1487
+ let tasksLoaded = false;
1488
+ let taskRegistry = {};
1489
+
1490
+ async function loadTasks() {
1491
+ if (tasksLoaded) return;
1492
+ const res = await fetch("/api/tasks");
1493
+ const tasks = await res.json();
1494
+ const select = document.getElementById("enqueue-task");
1495
+ tasks.forEach((task) => {
1496
+ taskRegistry[task.name] = task;
1497
+ const option = document.createElement("option");
1498
+ option.value = task.name;
1499
+ option.textContent = `${task.name} (queue: ${task.queue})`;
1500
+ select.appendChild(option);
1501
+ });
1502
+ tasksLoaded = true;
1503
+ }
1504
+
1505
+ function onTaskSelected() {
1506
+ const taskName = document.getElementById("enqueue-task").value;
1507
+ const paramsEl = document.getElementById("enqueue-task-params");
1508
+ const docstringEl = document.getElementById("enqueue-task-docstring");
1509
+ const argsEl = document.getElementById("enqueue-args");
1510
+
1511
+ if (!taskName || !taskRegistry[taskName]) {
1512
+ paramsEl.innerHTML = "";
1513
+ docstringEl.textContent = "";
1514
+ return;
1515
+ }
1516
+
1517
+ const task = taskRegistry[taskName];
1518
+ const params = task.params || [];
1519
+
1520
+ // Show docstring if available
1521
+ docstringEl.textContent = task.docstring || "";
1522
+
1523
+ if (params.length === 0) {
1524
+ paramsEl.innerHTML = "No arguments required";
1525
+ argsEl.value = "{}";
1526
+ return;
1527
+ }
1528
+
1529
+ // Build parameter description
1530
+ const paramStrs = params.map((p) => {
1531
+ let str = `<strong>${p.name}</strong>`;
1532
+ if (p.type) str += `: ${p.type}`;
1533
+ if (!p.required)
1534
+ str += ` (optional, default: ${JSON.stringify(p.default)})`;
1535
+ return str;
1536
+ });
1537
+ paramsEl.innerHTML = "Parameters: " + paramStrs.join(", ");
1538
+
1539
+ // Generate template JSON with required params
1540
+ const template = {};
1541
+ params.forEach((p) => {
1542
+ if (p.required) {
1543
+ if (p.type === "str") template[p.name] = "";
1544
+ else if (p.type === "int" || p.type === "float")
1545
+ template[p.name] = 0;
1546
+ else if (p.type === "bool") template[p.name] = false;
1547
+ else if (p.type === "list") template[p.name] = [];
1548
+ else if (p.type === "dict") template[p.name] = {};
1549
+ else template[p.name] = null;
1550
+ }
1551
+ });
1552
+ argsEl.value = JSON.stringify(template, null, 2);
1553
+ }
1554
+
1555
+ function openEnqueueModal() {
1556
+ loadTasks();
1557
+ document.getElementById("enqueue-modal").classList.add("open");
1558
+ document.getElementById("enqueue-message").innerHTML = "";
1559
+ document.getElementById("enqueue-task-params").innerHTML = "";
1560
+ }
1561
+
1562
+ function closeEnqueueModal() {
1563
+ document.getElementById("enqueue-modal").classList.remove("open");
1564
+ document.getElementById("enqueue-form").reset();
1565
+ document.getElementById("enqueue-args").value = "{}";
1566
+ document.getElementById("enqueue-message").innerHTML = "";
1567
+ document.getElementById("enqueue-task-params").innerHTML = "";
1568
+ document.getElementById("enqueue-task-docstring").textContent = "";
1569
+ }
1570
+
1571
+ // Close modal on overlay click
1572
+ document
1573
+ .getElementById("enqueue-modal")
1574
+ .addEventListener("click", (e) => {
1575
+ if (e.target.classList.contains("modal-overlay")) {
1576
+ closeEnqueueModal();
1577
+ }
1578
+ });
1579
+
1580
+ // Close modal on Escape key
1581
+ document.addEventListener("keydown", (e) => {
1582
+ if (
1583
+ e.key === "Escape" &&
1584
+ document.getElementById("enqueue-modal").classList.contains("open")
1585
+ ) {
1586
+ closeEnqueueModal();
1587
+ }
1588
+ });
1589
+
1590
+ async function submitEnqueueForm(e) {
1591
+ e.preventDefault();
1592
+ const messageEl = document.getElementById("enqueue-message");
1593
+ const submitBtn = document.getElementById("enqueue-submit");
1594
+
1595
+ const task = document.getElementById("enqueue-task").value;
1596
+ const argsText = document.getElementById("enqueue-args").value;
1597
+ const priority = document.getElementById("enqueue-priority").value;
1598
+ const scheduled = document.getElementById("enqueue-scheduled").value;
1599
+
1600
+ // Validate JSON
1601
+ let args;
1602
+ try {
1603
+ args = JSON.parse(argsText);
1604
+ if (typeof args !== "object" || Array.isArray(args)) {
1605
+ throw new Error("Arguments must be a JSON object");
1606
+ }
1607
+ } catch (err) {
1608
+ messageEl.innerHTML = `<div class="form-error">Invalid JSON: ${err.message}</div>`;
1609
+ return;
1610
+ }
1611
+
1612
+ // Build request body
1613
+ const body = { task, args };
1614
+ if (priority !== "") {
1615
+ body.priority = parseInt(priority, 10);
1616
+ }
1617
+ if (scheduled) {
1618
+ body.scheduled_at = new Date(scheduled).toISOString();
1619
+ }
1620
+
1621
+ // Submit
1622
+ submitBtn.disabled = true;
1623
+ submitBtn.textContent = "Enqueueing...";
1624
+
1625
+ try {
1626
+ const res = await fetch("/api/jobs", {
1627
+ method: "POST",
1628
+ headers: { "Content-Type": "application/json" },
1629
+ body: JSON.stringify(body),
1630
+ });
1631
+
1632
+ const data = await res.json();
1633
+
1634
+ if (!res.ok) {
1635
+ messageEl.innerHTML = `<div class="form-error">${data.error || "Failed to enqueue job"}</div>`;
1636
+ return;
1637
+ }
1638
+
1639
+ // Redirect to the job page
1640
+ window.location.href = `/jobs/${data.id}`;
1641
+ } catch (err) {
1642
+ messageEl.innerHTML = `<div class="form-error">Error: ${err.message}</div>`;
1643
+ } finally {
1644
+ submitBtn.disabled = false;
1645
+ submitBtn.textContent = "Enqueue";
1646
+ }
1647
+ }
1648
+ </script>
1649
+ </body>
1650
+ </html>