fairchild 0.0.2__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.
- fairchild/templates/dashboard.html +1650 -0
- fairchild/templates/job.html +1245 -0
- {fairchild-0.0.2.dist-info → fairchild-0.0.3.dist-info}/METADATA +1 -1
- {fairchild-0.0.2.dist-info → fairchild-0.0.3.dist-info}/RECORD +8 -6
- {fairchild-0.0.2.dist-info → fairchild-0.0.3.dist-info}/WHEEL +0 -0
- {fairchild-0.0.2.dist-info → fairchild-0.0.3.dist-info}/entry_points.txt +0 -0
- {fairchild-0.0.2.dist-info → fairchild-0.0.3.dist-info}/licenses/LICENSE +0 -0
- {fairchild-0.0.2.dist-info → fairchild-0.0.3.dist-info}/top_level.txt +0 -0
|
@@ -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
|
+
×
|
|
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='{ "key": "value" }'
|
|
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>
|