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.
- fairchild/__init__.py +11 -0
- fairchild/cli.py +386 -0
- fairchild/context.py +54 -0
- fairchild/db/__init__.py +0 -0
- fairchild/db/migrations.py +69 -0
- fairchild/fairchild.py +166 -0
- fairchild/future.py +78 -0
- fairchild/job.py +123 -0
- fairchild/record.py +22 -0
- fairchild/task.py +225 -0
- fairchild/templates/dashboard.html +1650 -0
- fairchild/templates/job.html +1245 -0
- fairchild/ui.py +560 -0
- fairchild/worker.py +495 -0
- fairchild-0.0.3.dist-info/METADATA +483 -0
- fairchild-0.0.3.dist-info/RECORD +20 -0
- fairchild-0.0.3.dist-info/entry_points.txt +2 -0
- fairchild-0.0.3.dist-info/licenses/LICENSE +21 -0
- fairchild-0.0.3.dist-info/top_level.txt +1 -0
- fairchild-0.0.1.dist-info/METADATA +0 -6
- fairchild-0.0.1.dist-info/RECORD +0 -5
- fairchild-0.0.1.dist-info/top_level.txt +0 -1
- main.py +0 -6
- {fairchild-0.0.1.dist-info → fairchild-0.0.3.dist-info}/WHEEL +0 -0
|
@@ -0,0 +1,1245 @@
|
|
|
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>Job - Fairchild</title>
|
|
7
|
+
<!-- React and ReactDOM -->
|
|
8
|
+
<script
|
|
9
|
+
crossorigin
|
|
10
|
+
src="https://unpkg.com/react@18/umd/react.production.min.js"
|
|
11
|
+
></script>
|
|
12
|
+
<script
|
|
13
|
+
crossorigin
|
|
14
|
+
src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"
|
|
15
|
+
></script>
|
|
16
|
+
<!-- React Flow v11 (has UMD support) -->
|
|
17
|
+
<script src="https://cdn.jsdelivr.net/npm/reactflow@11.11.4/dist/umd/index.js"></script>
|
|
18
|
+
<link
|
|
19
|
+
href="https://cdn.jsdelivr.net/npm/reactflow@11.11.4/dist/style.css"
|
|
20
|
+
rel="stylesheet"
|
|
21
|
+
/>
|
|
22
|
+
<!-- Dagre for layout -->
|
|
23
|
+
<script src="https://cdn.jsdelivr.net/npm/@dagrejs/dagre@1.1.4/dist/dagre.min.js"></script>
|
|
24
|
+
<style>
|
|
25
|
+
:root {
|
|
26
|
+
--bg-primary: #0f172a;
|
|
27
|
+
--bg-secondary: #1e293b;
|
|
28
|
+
--bg-hover: #334155;
|
|
29
|
+
--border-color: #334155;
|
|
30
|
+
--text-primary: #f8fafc;
|
|
31
|
+
--text-secondary: #e2e8f0;
|
|
32
|
+
--text-muted: #94a3b8;
|
|
33
|
+
--text-dim: #64748b;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
[data-theme="light"] {
|
|
37
|
+
--bg-primary: #f8fafc;
|
|
38
|
+
--bg-secondary: #ffffff;
|
|
39
|
+
--bg-hover: #f1f5f9;
|
|
40
|
+
--border-color: #e2e8f0;
|
|
41
|
+
--text-primary: #0f172a;
|
|
42
|
+
--text-secondary: #1e293b;
|
|
43
|
+
--text-muted: #64748b;
|
|
44
|
+
--text-dim: #94a3b8;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
* {
|
|
48
|
+
box-sizing: border-box;
|
|
49
|
+
margin: 0;
|
|
50
|
+
padding: 0;
|
|
51
|
+
}
|
|
52
|
+
body {
|
|
53
|
+
font-family:
|
|
54
|
+
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
55
|
+
background: var(--bg-primary);
|
|
56
|
+
color: var(--text-secondary);
|
|
57
|
+
min-height: 100vh;
|
|
58
|
+
}
|
|
59
|
+
.container {
|
|
60
|
+
max-width: 1000px;
|
|
61
|
+
margin: 0 auto;
|
|
62
|
+
padding: 24px;
|
|
63
|
+
}
|
|
64
|
+
header {
|
|
65
|
+
display: flex;
|
|
66
|
+
justify-content: space-between;
|
|
67
|
+
align-items: center;
|
|
68
|
+
margin-bottom: 24px;
|
|
69
|
+
padding-bottom: 16px;
|
|
70
|
+
border-bottom: 1px solid var(--border-color);
|
|
71
|
+
}
|
|
72
|
+
.header-left {
|
|
73
|
+
display: flex;
|
|
74
|
+
align-items: center;
|
|
75
|
+
gap: 16px;
|
|
76
|
+
}
|
|
77
|
+
.back-link {
|
|
78
|
+
color: var(--text-muted);
|
|
79
|
+
text-decoration: none;
|
|
80
|
+
font-size: 0.9rem;
|
|
81
|
+
}
|
|
82
|
+
.back-link:hover {
|
|
83
|
+
color: var(--text-primary);
|
|
84
|
+
}
|
|
85
|
+
h1 {
|
|
86
|
+
color: var(--text-primary);
|
|
87
|
+
font-size: 1.5rem;
|
|
88
|
+
font-weight: 600;
|
|
89
|
+
}
|
|
90
|
+
.header-right {
|
|
91
|
+
display: flex;
|
|
92
|
+
align-items: center;
|
|
93
|
+
gap: 12px;
|
|
94
|
+
}
|
|
95
|
+
.theme-toggle {
|
|
96
|
+
background: var(--bg-secondary);
|
|
97
|
+
border: 1px solid var(--border-color);
|
|
98
|
+
color: var(--text-muted);
|
|
99
|
+
padding: 8px;
|
|
100
|
+
border-radius: 6px;
|
|
101
|
+
cursor: pointer;
|
|
102
|
+
display: flex;
|
|
103
|
+
align-items: center;
|
|
104
|
+
justify-content: center;
|
|
105
|
+
}
|
|
106
|
+
.theme-toggle:hover {
|
|
107
|
+
color: var(--text-primary);
|
|
108
|
+
background: var(--bg-hover);
|
|
109
|
+
}
|
|
110
|
+
.theme-toggle svg {
|
|
111
|
+
width: 18px;
|
|
112
|
+
height: 18px;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
.badge {
|
|
116
|
+
padding: 4px 10px;
|
|
117
|
+
border-radius: 4px;
|
|
118
|
+
font-size: 0.8rem;
|
|
119
|
+
font-weight: 500;
|
|
120
|
+
display: inline-block;
|
|
121
|
+
}
|
|
122
|
+
.badge.completed {
|
|
123
|
+
background: rgba(34, 197, 94, 0.2);
|
|
124
|
+
color: #22c55e;
|
|
125
|
+
}
|
|
126
|
+
.badge.running {
|
|
127
|
+
background: rgba(59, 130, 246, 0.2);
|
|
128
|
+
color: #3b82f6;
|
|
129
|
+
}
|
|
130
|
+
.badge.scheduled {
|
|
131
|
+
background: rgba(168, 85, 247, 0.2);
|
|
132
|
+
color: #a855f7;
|
|
133
|
+
}
|
|
134
|
+
.badge.available {
|
|
135
|
+
background: rgba(100, 116, 139, 0.2);
|
|
136
|
+
color: #94a3b8;
|
|
137
|
+
}
|
|
138
|
+
.badge.failed,
|
|
139
|
+
.badge.discarded {
|
|
140
|
+
background: rgba(239, 68, 68, 0.2);
|
|
141
|
+
color: #ef4444;
|
|
142
|
+
}
|
|
143
|
+
.badge.cancelled {
|
|
144
|
+
background: rgba(245, 158, 11, 0.2);
|
|
145
|
+
color: #f59e0b;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
.card {
|
|
149
|
+
background: var(--bg-secondary);
|
|
150
|
+
border: 1px solid var(--border-color);
|
|
151
|
+
border-radius: 8px;
|
|
152
|
+
padding: 20px;
|
|
153
|
+
margin-bottom: 20px;
|
|
154
|
+
}
|
|
155
|
+
.card-title {
|
|
156
|
+
color: var(--text-primary);
|
|
157
|
+
font-size: 1rem;
|
|
158
|
+
font-weight: 600;
|
|
159
|
+
margin-bottom: 16px;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
.info-grid {
|
|
163
|
+
display: grid;
|
|
164
|
+
grid-template-columns: repeat(2, 1fr);
|
|
165
|
+
gap: 16px;
|
|
166
|
+
}
|
|
167
|
+
@media (max-width: 600px) {
|
|
168
|
+
.info-grid {
|
|
169
|
+
grid-template-columns: 1fr;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
.info-item {
|
|
173
|
+
}
|
|
174
|
+
.info-label {
|
|
175
|
+
font-size: 0.75rem;
|
|
176
|
+
color: var(--text-dim);
|
|
177
|
+
text-transform: uppercase;
|
|
178
|
+
letter-spacing: 0.05em;
|
|
179
|
+
margin-bottom: 4px;
|
|
180
|
+
}
|
|
181
|
+
.info-value {
|
|
182
|
+
color: var(--text-primary);
|
|
183
|
+
font-size: 0.95rem;
|
|
184
|
+
}
|
|
185
|
+
.info-value.mono {
|
|
186
|
+
font-family: "SF Mono", Monaco, monospace;
|
|
187
|
+
font-size: 0.85rem;
|
|
188
|
+
}
|
|
189
|
+
.info-value a {
|
|
190
|
+
color: #3b82f6;
|
|
191
|
+
text-decoration: none;
|
|
192
|
+
}
|
|
193
|
+
.info-value a:hover {
|
|
194
|
+
text-decoration: underline;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
.code-block {
|
|
198
|
+
background: var(--bg-primary);
|
|
199
|
+
border: 1px solid var(--border-color);
|
|
200
|
+
border-radius: 6px;
|
|
201
|
+
padding: 12px;
|
|
202
|
+
font-family: "SF Mono", Monaco, monospace;
|
|
203
|
+
font-size: 0.8rem;
|
|
204
|
+
overflow-x: auto;
|
|
205
|
+
white-space: pre-wrap;
|
|
206
|
+
word-break: break-all;
|
|
207
|
+
color: var(--text-secondary);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
.timeline {
|
|
211
|
+
position: relative;
|
|
212
|
+
padding-left: 24px;
|
|
213
|
+
}
|
|
214
|
+
.timeline::before {
|
|
215
|
+
content: "";
|
|
216
|
+
position: absolute;
|
|
217
|
+
left: 6px;
|
|
218
|
+
top: 4px;
|
|
219
|
+
bottom: 4px;
|
|
220
|
+
width: 2px;
|
|
221
|
+
background: var(--border-color);
|
|
222
|
+
}
|
|
223
|
+
.timeline-item {
|
|
224
|
+
position: relative;
|
|
225
|
+
padding-bottom: 16px;
|
|
226
|
+
}
|
|
227
|
+
.timeline-item:last-child {
|
|
228
|
+
padding-bottom: 0;
|
|
229
|
+
}
|
|
230
|
+
.timeline-item::before {
|
|
231
|
+
content: "";
|
|
232
|
+
position: absolute;
|
|
233
|
+
left: -20px;
|
|
234
|
+
top: 4px;
|
|
235
|
+
width: 10px;
|
|
236
|
+
height: 10px;
|
|
237
|
+
border-radius: 50%;
|
|
238
|
+
background: var(--border-color);
|
|
239
|
+
}
|
|
240
|
+
.timeline-item.active::before {
|
|
241
|
+
background: #3b82f6;
|
|
242
|
+
}
|
|
243
|
+
.timeline-item.success::before {
|
|
244
|
+
background: #22c55e;
|
|
245
|
+
}
|
|
246
|
+
.timeline-item.error::before {
|
|
247
|
+
background: #ef4444;
|
|
248
|
+
}
|
|
249
|
+
.timeline-label {
|
|
250
|
+
font-size: 0.8rem;
|
|
251
|
+
color: var(--text-dim);
|
|
252
|
+
}
|
|
253
|
+
.timeline-time {
|
|
254
|
+
font-size: 0.9rem;
|
|
255
|
+
color: var(--text-primary);
|
|
256
|
+
font-family: "SF Mono", Monaco, monospace;
|
|
257
|
+
}
|
|
258
|
+
.timeline-duration {
|
|
259
|
+
font-size: 0.75rem;
|
|
260
|
+
color: var(--text-muted);
|
|
261
|
+
margin-left: 8px;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
.error-box {
|
|
265
|
+
background: rgba(239, 68, 68, 0.1);
|
|
266
|
+
border: 1px solid rgba(239, 68, 68, 0.3);
|
|
267
|
+
border-radius: 6px;
|
|
268
|
+
padding: 12px;
|
|
269
|
+
margin-top: 8px;
|
|
270
|
+
}
|
|
271
|
+
.error-box pre {
|
|
272
|
+
font-family: "SF Mono", Monaco, monospace;
|
|
273
|
+
font-size: 0.8rem;
|
|
274
|
+
color: #ef4444;
|
|
275
|
+
white-space: pre-wrap;
|
|
276
|
+
word-break: break-all;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
.deps-list {
|
|
280
|
+
display: flex;
|
|
281
|
+
flex-wrap: wrap;
|
|
282
|
+
gap: 8px;
|
|
283
|
+
}
|
|
284
|
+
.dep-tag {
|
|
285
|
+
background: var(--bg-primary);
|
|
286
|
+
border: 1px solid var(--border-color);
|
|
287
|
+
padding: 4px 10px;
|
|
288
|
+
border-radius: 4px;
|
|
289
|
+
font-size: 0.8rem;
|
|
290
|
+
font-family: "SF Mono", Monaco, monospace;
|
|
291
|
+
color: var(--text-muted);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
.dag-container {
|
|
295
|
+
position: relative;
|
|
296
|
+
}
|
|
297
|
+
.dag-header {
|
|
298
|
+
display: flex;
|
|
299
|
+
justify-content: space-between;
|
|
300
|
+
align-items: center;
|
|
301
|
+
margin-bottom: 12px;
|
|
302
|
+
}
|
|
303
|
+
.dag-legend {
|
|
304
|
+
display: flex;
|
|
305
|
+
gap: 12px;
|
|
306
|
+
font-size: 0.7rem;
|
|
307
|
+
}
|
|
308
|
+
.dag-legend-item {
|
|
309
|
+
display: flex;
|
|
310
|
+
align-items: center;
|
|
311
|
+
gap: 4px;
|
|
312
|
+
}
|
|
313
|
+
.dag-legend-color {
|
|
314
|
+
width: 10px;
|
|
315
|
+
height: 10px;
|
|
316
|
+
border-radius: 2px;
|
|
317
|
+
}
|
|
318
|
+
#dag-flow {
|
|
319
|
+
width: 100%;
|
|
320
|
+
height: 350px;
|
|
321
|
+
border-radius: 6px;
|
|
322
|
+
background: var(--bg-primary);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/* React Flow custom styles */
|
|
326
|
+
.react-flow__node {
|
|
327
|
+
font-family:
|
|
328
|
+
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
329
|
+
}
|
|
330
|
+
.react-flow__attribution {
|
|
331
|
+
display: none;
|
|
332
|
+
}
|
|
333
|
+
.job-node {
|
|
334
|
+
padding: 8px 16px;
|
|
335
|
+
border-radius: 6px;
|
|
336
|
+
font-size: 12px;
|
|
337
|
+
font-weight: 600;
|
|
338
|
+
color: white;
|
|
339
|
+
min-width: 80px;
|
|
340
|
+
text-align: center;
|
|
341
|
+
cursor: pointer;
|
|
342
|
+
border: 2px solid transparent;
|
|
343
|
+
transition: all 0.15s ease;
|
|
344
|
+
}
|
|
345
|
+
.job-node:hover {
|
|
346
|
+
filter: brightness(1.1);
|
|
347
|
+
transform: scale(1.02);
|
|
348
|
+
}
|
|
349
|
+
.job-node.current {
|
|
350
|
+
border: 2px solid #fbbf24 !important;
|
|
351
|
+
box-shadow: 0 0 0 2px rgba(251, 191, 36, 0.3);
|
|
352
|
+
}
|
|
353
|
+
.job-node.group {
|
|
354
|
+
border-style: dashed;
|
|
355
|
+
border-color: rgba(255, 255, 255, 0.5);
|
|
356
|
+
}
|
|
357
|
+
.job-node .count {
|
|
358
|
+
font-size: 10px;
|
|
359
|
+
opacity: 0.8;
|
|
360
|
+
margin-left: 4px;
|
|
361
|
+
}
|
|
362
|
+
.job-node.completed {
|
|
363
|
+
background: #22c55e;
|
|
364
|
+
}
|
|
365
|
+
.job-node.running {
|
|
366
|
+
background: #3b82f6;
|
|
367
|
+
}
|
|
368
|
+
.job-node.scheduled {
|
|
369
|
+
background: #a855f7;
|
|
370
|
+
}
|
|
371
|
+
.job-node.available {
|
|
372
|
+
background: #64748b;
|
|
373
|
+
}
|
|
374
|
+
.job-node.failed,
|
|
375
|
+
.job-node.discarded {
|
|
376
|
+
background: #ef4444;
|
|
377
|
+
}
|
|
378
|
+
.job-node.cancelled {
|
|
379
|
+
background: #f59e0b;
|
|
380
|
+
}
|
|
381
|
+
.job-node.ellipsis {
|
|
382
|
+
background: transparent;
|
|
383
|
+
border: none;
|
|
384
|
+
min-width: auto;
|
|
385
|
+
color: #94a3b8;
|
|
386
|
+
font-size: 11px;
|
|
387
|
+
padding: 4px 8px;
|
|
388
|
+
}
|
|
389
|
+
.job-node.ellipsis:hover {
|
|
390
|
+
color: #e2e8f0;
|
|
391
|
+
background: rgba(148, 163, 184, 0.1);
|
|
392
|
+
border-radius: 4px;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
#family-table {
|
|
396
|
+
width: 100%;
|
|
397
|
+
border-collapse: collapse;
|
|
398
|
+
}
|
|
399
|
+
#family-table th,
|
|
400
|
+
#family-table td {
|
|
401
|
+
text-align: left;
|
|
402
|
+
padding: 8px 12px;
|
|
403
|
+
border-bottom: 1px solid var(--border-color);
|
|
404
|
+
font-size: 0.85rem;
|
|
405
|
+
}
|
|
406
|
+
#family-table th {
|
|
407
|
+
color: var(--text-dim);
|
|
408
|
+
font-weight: 500;
|
|
409
|
+
text-transform: uppercase;
|
|
410
|
+
font-size: 0.7rem;
|
|
411
|
+
}
|
|
412
|
+
#family-table tr:last-child td {
|
|
413
|
+
border-bottom: none;
|
|
414
|
+
}
|
|
415
|
+
#family-table .current-job {
|
|
416
|
+
background: var(--bg-hover);
|
|
417
|
+
}
|
|
418
|
+
#family-table a {
|
|
419
|
+
color: #3b82f6;
|
|
420
|
+
text-decoration: none;
|
|
421
|
+
}
|
|
422
|
+
#family-table a:hover {
|
|
423
|
+
text-decoration: underline;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
.expand-btn {
|
|
427
|
+
font-size: 0.7rem;
|
|
428
|
+
padding: 2px 8px;
|
|
429
|
+
background: var(--bg-primary);
|
|
430
|
+
border: 1px solid var(--border-color);
|
|
431
|
+
color: var(--text-muted);
|
|
432
|
+
border-radius: 4px;
|
|
433
|
+
cursor: pointer;
|
|
434
|
+
margin-left: 12px;
|
|
435
|
+
}
|
|
436
|
+
.expand-btn:hover {
|
|
437
|
+
background: var(--bg-hover);
|
|
438
|
+
color: var(--text-primary);
|
|
439
|
+
}
|
|
440
|
+
</style>
|
|
441
|
+
</head>
|
|
442
|
+
<body>
|
|
443
|
+
<div class="container">
|
|
444
|
+
<header>
|
|
445
|
+
<div class="header-left">
|
|
446
|
+
<a href="/" class="back-link">← Dashboard</a>
|
|
447
|
+
<h1 id="job-title">Job</h1>
|
|
448
|
+
</div>
|
|
449
|
+
<div class="header-right">
|
|
450
|
+
<span id="job-state"></span>
|
|
451
|
+
<button
|
|
452
|
+
class="theme-toggle"
|
|
453
|
+
onclick="toggleTheme()"
|
|
454
|
+
title="Toggle theme"
|
|
455
|
+
>
|
|
456
|
+
<svg
|
|
457
|
+
id="theme-icon-sun"
|
|
458
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
459
|
+
fill="none"
|
|
460
|
+
viewBox="0 0 24 24"
|
|
461
|
+
stroke="currentColor"
|
|
462
|
+
style="display: none"
|
|
463
|
+
>
|
|
464
|
+
<path
|
|
465
|
+
stroke-linecap="round"
|
|
466
|
+
stroke-linejoin="round"
|
|
467
|
+
stroke-width="2"
|
|
468
|
+
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"
|
|
469
|
+
/>
|
|
470
|
+
</svg>
|
|
471
|
+
<svg
|
|
472
|
+
id="theme-icon-moon"
|
|
473
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
474
|
+
fill="none"
|
|
475
|
+
viewBox="0 0 24 24"
|
|
476
|
+
stroke="currentColor"
|
|
477
|
+
>
|
|
478
|
+
<path
|
|
479
|
+
stroke-linecap="round"
|
|
480
|
+
stroke-linejoin="round"
|
|
481
|
+
stroke-width="2"
|
|
482
|
+
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"
|
|
483
|
+
/>
|
|
484
|
+
</svg>
|
|
485
|
+
</button>
|
|
486
|
+
</div>
|
|
487
|
+
</header>
|
|
488
|
+
|
|
489
|
+
<div class="card">
|
|
490
|
+
<div class="card-title">Overview</div>
|
|
491
|
+
<div class="info-grid" id="overview-grid">
|
|
492
|
+
<!-- Populated by JS -->
|
|
493
|
+
</div>
|
|
494
|
+
</div>
|
|
495
|
+
|
|
496
|
+
<div class="card" id="family-card" style="display: none">
|
|
497
|
+
<div class="dag-container">
|
|
498
|
+
<div class="dag-header">
|
|
499
|
+
<div class="card-title" style="margin-bottom: 0">Job Tree</div>
|
|
500
|
+
<div class="dag-legend">
|
|
501
|
+
<div class="dag-legend-item">
|
|
502
|
+
<div class="dag-legend-color" style="background: #22c55e"></div>
|
|
503
|
+
<span>Completed</span>
|
|
504
|
+
</div>
|
|
505
|
+
<div class="dag-legend-item">
|
|
506
|
+
<div class="dag-legend-color" style="background: #3b82f6"></div>
|
|
507
|
+
<span>Running</span>
|
|
508
|
+
</div>
|
|
509
|
+
<div class="dag-legend-item">
|
|
510
|
+
<div class="dag-legend-color" style="background: #a855f7"></div>
|
|
511
|
+
<span>Scheduled</span>
|
|
512
|
+
</div>
|
|
513
|
+
<div class="dag-legend-item">
|
|
514
|
+
<div class="dag-legend-color" style="background: #64748b"></div>
|
|
515
|
+
<span>Available</span>
|
|
516
|
+
</div>
|
|
517
|
+
<div class="dag-legend-item">
|
|
518
|
+
<div class="dag-legend-color" style="background: #ef4444"></div>
|
|
519
|
+
<span>Failed</span>
|
|
520
|
+
</div>
|
|
521
|
+
</div>
|
|
522
|
+
</div>
|
|
523
|
+
<div id="dag-flow"></div>
|
|
524
|
+
</div>
|
|
525
|
+
<table id="family-table" style="margin-top: 16px">
|
|
526
|
+
<thead>
|
|
527
|
+
<tr>
|
|
528
|
+
<th>Task</th>
|
|
529
|
+
<th>State</th>
|
|
530
|
+
<th>Duration</th>
|
|
531
|
+
<th>Result</th>
|
|
532
|
+
</tr>
|
|
533
|
+
</thead>
|
|
534
|
+
<tbody></tbody>
|
|
535
|
+
</table>
|
|
536
|
+
</div>
|
|
537
|
+
|
|
538
|
+
<div class="card">
|
|
539
|
+
<div class="card-title">Timeline</div>
|
|
540
|
+
<div class="timeline" id="timeline">
|
|
541
|
+
<!-- Populated by JS -->
|
|
542
|
+
</div>
|
|
543
|
+
</div>
|
|
544
|
+
|
|
545
|
+
<div class="card">
|
|
546
|
+
<div class="card-title">Arguments</div>
|
|
547
|
+
<div class="code-block" id="args-block">Loading...</div>
|
|
548
|
+
</div>
|
|
549
|
+
|
|
550
|
+
<div class="card" id="result-card" style="display: none">
|
|
551
|
+
<div class="card-title">Result</div>
|
|
552
|
+
<div class="code-block" id="result-block"></div>
|
|
553
|
+
</div>
|
|
554
|
+
|
|
555
|
+
<div class="card" id="errors-card" style="display: none">
|
|
556
|
+
<div class="card-title">Errors</div>
|
|
557
|
+
<div id="errors-list"></div>
|
|
558
|
+
</div>
|
|
559
|
+
|
|
560
|
+
<div class="card" id="deps-card" style="display: none">
|
|
561
|
+
<div class="card-title">Dependencies</div>
|
|
562
|
+
<div class="deps-list" id="deps-list"></div>
|
|
563
|
+
</div>
|
|
564
|
+
</div>
|
|
565
|
+
|
|
566
|
+
<script>
|
|
567
|
+
// Theme management
|
|
568
|
+
function getSystemTheme() {
|
|
569
|
+
return window.matchMedia("(prefers-color-scheme: light)").matches
|
|
570
|
+
? "light"
|
|
571
|
+
: "dark";
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function getStoredTheme() {
|
|
575
|
+
return localStorage.getItem("fairchild-theme");
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function setTheme(theme) {
|
|
579
|
+
document.documentElement.setAttribute("data-theme", theme);
|
|
580
|
+
localStorage.setItem("fairchild-theme", theme);
|
|
581
|
+
updateThemeIcon(theme);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function updateThemeIcon(theme) {
|
|
585
|
+
document.getElementById("theme-icon-sun").style.display =
|
|
586
|
+
theme === "light" ? "block" : "none";
|
|
587
|
+
document.getElementById("theme-icon-moon").style.display =
|
|
588
|
+
theme === "dark" ? "block" : "none";
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function toggleTheme() {
|
|
592
|
+
const current =
|
|
593
|
+
document.documentElement.getAttribute("data-theme") ||
|
|
594
|
+
getSystemTheme();
|
|
595
|
+
const next = current === "light" ? "dark" : "light";
|
|
596
|
+
setTheme(next);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// Initialize theme
|
|
600
|
+
const storedTheme = getStoredTheme();
|
|
601
|
+
if (storedTheme) {
|
|
602
|
+
setTheme(storedTheme);
|
|
603
|
+
} else {
|
|
604
|
+
const systemTheme = getSystemTheme();
|
|
605
|
+
document.documentElement.setAttribute("data-theme", systemTheme);
|
|
606
|
+
updateThemeIcon(systemTheme);
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// Listen for system theme changes
|
|
610
|
+
window
|
|
611
|
+
.matchMedia("(prefers-color-scheme: light)")
|
|
612
|
+
.addEventListener("change", (e) => {
|
|
613
|
+
if (!getStoredTheme()) {
|
|
614
|
+
const newTheme = e.matches ? "light" : "dark";
|
|
615
|
+
document.documentElement.setAttribute("data-theme", newTheme);
|
|
616
|
+
updateThemeIcon(newTheme);
|
|
617
|
+
}
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
const jobId = "{{JOB_ID}}";
|
|
621
|
+
|
|
622
|
+
function formatTime(isoString) {
|
|
623
|
+
if (!isoString) return "-";
|
|
624
|
+
const d = new Date(isoString);
|
|
625
|
+
return d.toLocaleString();
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function formatDuration(ms) {
|
|
629
|
+
if (ms < 1000) return ms + "ms";
|
|
630
|
+
if (ms < 60000) return (ms / 1000).toFixed(1) + "s";
|
|
631
|
+
return (ms / 60000).toFixed(1) + "m";
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
function formatJson(obj) {
|
|
635
|
+
if (obj === null || obj === undefined) return "null";
|
|
636
|
+
if (typeof obj === "string") {
|
|
637
|
+
try {
|
|
638
|
+
obj = JSON.parse(obj);
|
|
639
|
+
} catch (e) {
|
|
640
|
+
return obj;
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
return JSON.stringify(obj, null, 2);
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
async function fetchJob() {
|
|
647
|
+
const res = await fetch(`/api/jobs/${jobId}`);
|
|
648
|
+
if (!res.ok) {
|
|
649
|
+
document.getElementById("job-title").textContent = "Job Not Found";
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
const job = await res.json();
|
|
653
|
+
renderJob(job);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
function renderJob(job) {
|
|
657
|
+
// Title
|
|
658
|
+
const taskShortName = job.task_name.split(".").pop();
|
|
659
|
+
document.getElementById("job-title").textContent =
|
|
660
|
+
job.job_key || taskShortName;
|
|
661
|
+
document.title = `${job.job_key || taskShortName} - Fairchild`;
|
|
662
|
+
|
|
663
|
+
// State badge
|
|
664
|
+
document.getElementById("job-state").innerHTML =
|
|
665
|
+
`<span class="badge ${job.state}">${job.state}</span>`;
|
|
666
|
+
|
|
667
|
+
// Overview grid
|
|
668
|
+
const parentLink = job.parent_id
|
|
669
|
+
? `<a href="/jobs/${job.parent_id}">${job.parent_id.slice(0, 8)}...</a>`
|
|
670
|
+
: "-";
|
|
671
|
+
|
|
672
|
+
document.getElementById("overview-grid").innerHTML = `
|
|
673
|
+
<div class="info-item">
|
|
674
|
+
<div class="info-label">Task</div>
|
|
675
|
+
<div class="info-value mono">${job.task_name}</div>
|
|
676
|
+
</div>
|
|
677
|
+
<div class="info-item">
|
|
678
|
+
<div class="info-label">Queue</div>
|
|
679
|
+
<div class="info-value">${job.queue}</div>
|
|
680
|
+
</div>
|
|
681
|
+
<div class="info-item">
|
|
682
|
+
<div class="info-label">Parent Job</div>
|
|
683
|
+
<div class="info-value mono">${parentLink}</div>
|
|
684
|
+
</div>
|
|
685
|
+
<div class="info-item">
|
|
686
|
+
<div class="info-label">Priority</div>
|
|
687
|
+
<div class="info-value">${job.priority}</div>
|
|
688
|
+
</div>
|
|
689
|
+
<div class="info-item">
|
|
690
|
+
<div class="info-label">Attempts</div>
|
|
691
|
+
<div class="info-value">${job.attempt} / ${job.max_attempts}</div>
|
|
692
|
+
</div>
|
|
693
|
+
<div class="info-item">
|
|
694
|
+
<div class="info-label">Job ID</div>
|
|
695
|
+
<div class="info-value mono">${job.id}</div>
|
|
696
|
+
</div>
|
|
697
|
+
<div class="info-item">
|
|
698
|
+
<div class="info-label">Tags</div>
|
|
699
|
+
<div class="info-value">${job.tags && job.tags.length > 0 ? job.tags.join(", ") : "-"}</div>
|
|
700
|
+
</div>
|
|
701
|
+
`;
|
|
702
|
+
|
|
703
|
+
// Timeline
|
|
704
|
+
let timelineHtml = "";
|
|
705
|
+
|
|
706
|
+
timelineHtml += `
|
|
707
|
+
<div class="timeline-item active">
|
|
708
|
+
<div class="timeline-label">Inserted</div>
|
|
709
|
+
<div class="timeline-time">${formatTime(job.inserted_at)}</div>
|
|
710
|
+
</div>
|
|
711
|
+
`;
|
|
712
|
+
|
|
713
|
+
if (
|
|
714
|
+
job.scheduled_at &&
|
|
715
|
+
new Date(job.scheduled_at) > new Date(job.inserted_at)
|
|
716
|
+
) {
|
|
717
|
+
const waitTime =
|
|
718
|
+
new Date(job.scheduled_at) - new Date(job.inserted_at);
|
|
719
|
+
timelineHtml += `
|
|
720
|
+
<div class="timeline-item">
|
|
721
|
+
<div class="timeline-label">Scheduled</div>
|
|
722
|
+
<div class="timeline-time">${formatTime(job.scheduled_at)}<span class="timeline-duration">+${formatDuration(waitTime)}</span></div>
|
|
723
|
+
</div>
|
|
724
|
+
`;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
if (job.attempted_at) {
|
|
728
|
+
const queueTime =
|
|
729
|
+
new Date(job.attempted_at) - new Date(job.inserted_at);
|
|
730
|
+
timelineHtml += `
|
|
731
|
+
<div class="timeline-item active">
|
|
732
|
+
<div class="timeline-label">Started</div>
|
|
733
|
+
<div class="timeline-time">${formatTime(job.attempted_at)}<span class="timeline-duration">waited ${formatDuration(queueTime)}</span></div>
|
|
734
|
+
</div>
|
|
735
|
+
`;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
if (job.completed_at) {
|
|
739
|
+
const execTime =
|
|
740
|
+
new Date(job.completed_at) - new Date(job.attempted_at);
|
|
741
|
+
const itemClass = job.state === "completed" ? "success" : "error";
|
|
742
|
+
timelineHtml += `
|
|
743
|
+
<div class="timeline-item ${itemClass}">
|
|
744
|
+
<div class="timeline-label">${job.state === "completed" ? "Completed" : "Failed"}</div>
|
|
745
|
+
<div class="timeline-time">${formatTime(job.completed_at)}<span class="timeline-duration">ran ${formatDuration(execTime)}</span></div>
|
|
746
|
+
</div>
|
|
747
|
+
`;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
document.getElementById("timeline").innerHTML = timelineHtml;
|
|
751
|
+
|
|
752
|
+
// Arguments
|
|
753
|
+
document.getElementById("args-block").textContent = formatJson(
|
|
754
|
+
job.args,
|
|
755
|
+
);
|
|
756
|
+
|
|
757
|
+
// Result
|
|
758
|
+
if (job.recorded !== null && job.recorded !== undefined) {
|
|
759
|
+
document.getElementById("result-card").style.display = "block";
|
|
760
|
+
document.getElementById("result-block").textContent = formatJson(
|
|
761
|
+
job.recorded,
|
|
762
|
+
);
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// Errors
|
|
766
|
+
let errors = job.errors;
|
|
767
|
+
if (typeof errors === "string") {
|
|
768
|
+
try {
|
|
769
|
+
errors = JSON.parse(errors);
|
|
770
|
+
} catch (e) {
|
|
771
|
+
errors = [];
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
if (errors && Array.isArray(errors) && errors.length > 0) {
|
|
775
|
+
document.getElementById("errors-card").style.display = "block";
|
|
776
|
+
let errorsHtml = "";
|
|
777
|
+
errors.forEach((err, idx) => {
|
|
778
|
+
errorsHtml += `
|
|
779
|
+
<div class="error-box">
|
|
780
|
+
<div style="font-size: 0.75rem; color: var(--text-dim); margin-bottom: 4px;">Attempt ${idx + 1}</div>
|
|
781
|
+
<pre>${typeof err === "string" ? err : JSON.stringify(err, null, 2)}</pre>
|
|
782
|
+
</div>
|
|
783
|
+
`;
|
|
784
|
+
});
|
|
785
|
+
document.getElementById("errors-list").innerHTML = errorsHtml;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
// Dependencies
|
|
789
|
+
if (job.deps && job.deps.length > 0) {
|
|
790
|
+
document.getElementById("deps-card").style.display = "block";
|
|
791
|
+
const depsHtml = job.deps
|
|
792
|
+
.map(
|
|
793
|
+
(dep) =>
|
|
794
|
+
`<a href="/jobs/${dep}" class="dep-tag">${dep.slice(0, 8)}...</a>`,
|
|
795
|
+
)
|
|
796
|
+
.join("");
|
|
797
|
+
document.getElementById("deps-list").innerHTML = depsHtml;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
// Fetch and render job family tree if this job has parent or children
|
|
801
|
+
fetchJobFamily();
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
let familyData = null;
|
|
805
|
+
let expandedGroups = {}; // Track which groups are expanded by key
|
|
806
|
+
let flowRoot = null;
|
|
807
|
+
|
|
808
|
+
async function fetchJobFamily() {
|
|
809
|
+
try {
|
|
810
|
+
const res = await fetch(`/api/jobs/${jobId}/family`);
|
|
811
|
+
if (!res.ok) {
|
|
812
|
+
console.error("Family API error:", res.status);
|
|
813
|
+
return;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
familyData = await res.json();
|
|
817
|
+
|
|
818
|
+
// Only show DAG if there's more than one job in the family
|
|
819
|
+
if (familyData.jobs && familyData.jobs.length > 1) {
|
|
820
|
+
document.getElementById("family-card").style.display = "block";
|
|
821
|
+
renderDAG(familyData.jobs);
|
|
822
|
+
renderFamilyTable(familyData.jobs);
|
|
823
|
+
}
|
|
824
|
+
} catch (err) {
|
|
825
|
+
console.error("fetchJobFamily error:", err);
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
function toggleGroupExpand(groupKey) {
|
|
830
|
+
expandedGroups[groupKey] = !expandedGroups[groupKey];
|
|
831
|
+
if (familyData && familyData.jobs) {
|
|
832
|
+
renderDAG(familyData.jobs);
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
// Make toggleGroupExpand available globally for node clicks
|
|
837
|
+
window.toggleGroupExpand = toggleGroupExpand;
|
|
838
|
+
|
|
839
|
+
function renderDAG(jobs) {
|
|
840
|
+
// reactflow v11 UMD exposes window.ReactFlow
|
|
841
|
+
const {
|
|
842
|
+
ReactFlowProvider,
|
|
843
|
+
default: ReactFlow,
|
|
844
|
+
Background,
|
|
845
|
+
Controls,
|
|
846
|
+
Handle,
|
|
847
|
+
Position,
|
|
848
|
+
useNodesState,
|
|
849
|
+
useEdgesState,
|
|
850
|
+
useReactFlow,
|
|
851
|
+
MarkerType,
|
|
852
|
+
} = window.ReactFlow;
|
|
853
|
+
const { useState, useEffect, useCallback } = React;
|
|
854
|
+
|
|
855
|
+
// Build job map
|
|
856
|
+
const jobMap = {};
|
|
857
|
+
jobs.forEach((j) => (jobMap[j.id] = j));
|
|
858
|
+
|
|
859
|
+
// Build display nodes and edges
|
|
860
|
+
let displayNodes = [];
|
|
861
|
+
let edges = [];
|
|
862
|
+
|
|
863
|
+
// Group siblings by parent_id + task_name
|
|
864
|
+
const siblingGroups = {};
|
|
865
|
+
const jobsWithDeps = [];
|
|
866
|
+
const rootJobs = [];
|
|
867
|
+
|
|
868
|
+
jobs.forEach((job) => {
|
|
869
|
+
if (!job.parent_id) {
|
|
870
|
+
rootJobs.push(job);
|
|
871
|
+
} else if (job.deps && job.deps.length > 0) {
|
|
872
|
+
jobsWithDeps.push(job);
|
|
873
|
+
} else {
|
|
874
|
+
const key = `${job.parent_id}:${job.task_name}`;
|
|
875
|
+
if (!siblingGroups[key]) siblingGroups[key] = [];
|
|
876
|
+
siblingGroups[key].push(job);
|
|
877
|
+
}
|
|
878
|
+
});
|
|
879
|
+
|
|
880
|
+
// Root jobs
|
|
881
|
+
rootJobs.forEach((job) => {
|
|
882
|
+
displayNodes.push({
|
|
883
|
+
id: job.id,
|
|
884
|
+
label: job.task_name.split(".").pop(),
|
|
885
|
+
state: job.state,
|
|
886
|
+
isGroup: false,
|
|
887
|
+
count: 1,
|
|
888
|
+
jobIds: [job.id],
|
|
889
|
+
});
|
|
890
|
+
});
|
|
891
|
+
|
|
892
|
+
// Sibling groups - show first 2, "...", and last 1 when collapsed
|
|
893
|
+
Object.entries(siblingGroups).forEach(([key, groupJobs]) => {
|
|
894
|
+
let isExpanded = expandedGroups[key];
|
|
895
|
+
|
|
896
|
+
// Auto-expand if the current job is hidden in this group
|
|
897
|
+
const hiddenJobs = groupJobs.slice(2, -1);
|
|
898
|
+
if (hiddenJobs.some((j) => j.id === jobId)) {
|
|
899
|
+
isExpanded = true;
|
|
900
|
+
expandedGroups[key] = true;
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
if (groupJobs.length >= 4 && !isExpanded) {
|
|
904
|
+
// Show first 2, ellipsis node, and last 1
|
|
905
|
+
const parentId = groupJobs[0].parent_id;
|
|
906
|
+
const hiddenCount = groupJobs.length - 3;
|
|
907
|
+
|
|
908
|
+
// First 2 jobs
|
|
909
|
+
for (let i = 0; i < 2; i++) {
|
|
910
|
+
const job = groupJobs[i];
|
|
911
|
+
displayNodes.push({
|
|
912
|
+
id: job.id,
|
|
913
|
+
label: job.task_name.split(".").pop(),
|
|
914
|
+
state: job.state,
|
|
915
|
+
isGroup: false,
|
|
916
|
+
count: 1,
|
|
917
|
+
jobIds: [job.id],
|
|
918
|
+
});
|
|
919
|
+
edges.push({ from: parentId, to: job.id });
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
// Last job (before ellipsis so ellipsis appears at the end)
|
|
923
|
+
const lastJob = groupJobs[groupJobs.length - 1];
|
|
924
|
+
displayNodes.push({
|
|
925
|
+
id: lastJob.id,
|
|
926
|
+
label: lastJob.task_name.split(".").pop(),
|
|
927
|
+
state: lastJob.state,
|
|
928
|
+
isGroup: false,
|
|
929
|
+
count: 1,
|
|
930
|
+
jobIds: [lastJob.id],
|
|
931
|
+
});
|
|
932
|
+
edges.push({ from: parentId, to: lastJob.id });
|
|
933
|
+
|
|
934
|
+
// Ellipsis node at the end (clickable to expand)
|
|
935
|
+
const ellipsisId = `ellipsis:${key}`;
|
|
936
|
+
displayNodes.push({
|
|
937
|
+
id: ellipsisId,
|
|
938
|
+
label: `+${hiddenCount}`,
|
|
939
|
+
state: "ellipsis",
|
|
940
|
+
isGroup: false,
|
|
941
|
+
isEllipsis: true,
|
|
942
|
+
count: hiddenCount,
|
|
943
|
+
jobIds: groupJobs.slice(2, -1).map((j) => j.id),
|
|
944
|
+
groupKey: key,
|
|
945
|
+
});
|
|
946
|
+
edges.push({ from: parentId, to: ellipsisId });
|
|
947
|
+
} else {
|
|
948
|
+
// Show all (either < 4 jobs or expanded)
|
|
949
|
+
groupJobs.forEach((job) => {
|
|
950
|
+
displayNodes.push({
|
|
951
|
+
id: job.id,
|
|
952
|
+
label: job.task_name.split(".").pop(),
|
|
953
|
+
state: job.state,
|
|
954
|
+
isGroup: false,
|
|
955
|
+
count: 1,
|
|
956
|
+
jobIds: [job.id],
|
|
957
|
+
});
|
|
958
|
+
edges.push({ from: job.parent_id, to: job.id });
|
|
959
|
+
});
|
|
960
|
+
}
|
|
961
|
+
});
|
|
962
|
+
|
|
963
|
+
// Jobs with deps
|
|
964
|
+
jobsWithDeps.forEach((job) => {
|
|
965
|
+
displayNodes.push({
|
|
966
|
+
id: job.id,
|
|
967
|
+
label: job.task_name.split(".").pop(),
|
|
968
|
+
state: job.state,
|
|
969
|
+
isGroup: false,
|
|
970
|
+
count: 1,
|
|
971
|
+
jobIds: [job.id],
|
|
972
|
+
});
|
|
973
|
+
job.deps.forEach((depId) => {
|
|
974
|
+
let edgeFrom = depId;
|
|
975
|
+
// Check if this dep is hidden in an ellipsis node
|
|
976
|
+
displayNodes.forEach((node) => {
|
|
977
|
+
if (
|
|
978
|
+
(node.isGroup || node.isEllipsis) &&
|
|
979
|
+
node.jobIds.includes(depId)
|
|
980
|
+
) {
|
|
981
|
+
edgeFrom = node.id;
|
|
982
|
+
}
|
|
983
|
+
});
|
|
984
|
+
edges.push({ from: edgeFrom, to: job.id });
|
|
985
|
+
});
|
|
986
|
+
});
|
|
987
|
+
|
|
988
|
+
// Use dagre for layout
|
|
989
|
+
const g = new dagre.graphlib.Graph();
|
|
990
|
+
g.setGraph({ rankdir: "LR", nodesep: 50, ranksep: 100 });
|
|
991
|
+
g.setDefaultEdgeLabel(() => ({}));
|
|
992
|
+
|
|
993
|
+
const nodeWidth = 120;
|
|
994
|
+
const nodeHeight = 36;
|
|
995
|
+
|
|
996
|
+
const nodeMap = {};
|
|
997
|
+
displayNodes.forEach((node) => {
|
|
998
|
+
nodeMap[node.id] = node;
|
|
999
|
+
g.setNode(node.id, { width: nodeWidth, height: nodeHeight });
|
|
1000
|
+
});
|
|
1001
|
+
|
|
1002
|
+
const edgeSet = new Set();
|
|
1003
|
+
edges.forEach((e) => {
|
|
1004
|
+
const key = `${e.from}->${e.to}`;
|
|
1005
|
+
if (!edgeSet.has(key) && nodeMap[e.from] && nodeMap[e.to]) {
|
|
1006
|
+
edgeSet.add(key);
|
|
1007
|
+
g.setEdge(e.from, e.to);
|
|
1008
|
+
}
|
|
1009
|
+
});
|
|
1010
|
+
|
|
1011
|
+
dagre.layout(g);
|
|
1012
|
+
|
|
1013
|
+
// Convert to React Flow format
|
|
1014
|
+
const flowNodes = displayNodes.map((node) => {
|
|
1015
|
+
const pos = g.node(node.id);
|
|
1016
|
+
const isCurrent = node.jobIds.includes(jobId);
|
|
1017
|
+
return {
|
|
1018
|
+
id: node.id,
|
|
1019
|
+
position: { x: pos.x - nodeWidth / 2, y: pos.y - nodeHeight / 2 },
|
|
1020
|
+
data: {
|
|
1021
|
+
label: node.label,
|
|
1022
|
+
state: node.state,
|
|
1023
|
+
isGroup: node.isGroup,
|
|
1024
|
+
isEllipsis: node.isEllipsis,
|
|
1025
|
+
groupKey: node.groupKey,
|
|
1026
|
+
count: node.count,
|
|
1027
|
+
isCurrent: isCurrent,
|
|
1028
|
+
jobIds: node.jobIds,
|
|
1029
|
+
},
|
|
1030
|
+
type: "jobNode",
|
|
1031
|
+
};
|
|
1032
|
+
});
|
|
1033
|
+
|
|
1034
|
+
const flowEdges = [];
|
|
1035
|
+
edgeSet.forEach((key) => {
|
|
1036
|
+
const [from, to] = key.split("->");
|
|
1037
|
+
// Check if target node is still pending (not completed)
|
|
1038
|
+
const targetNode = nodeMap[to];
|
|
1039
|
+
const isFlowing =
|
|
1040
|
+
targetNode &&
|
|
1041
|
+
["running", "scheduled", "available"].includes(targetNode.state);
|
|
1042
|
+
|
|
1043
|
+
flowEdges.push({
|
|
1044
|
+
id: key,
|
|
1045
|
+
source: from,
|
|
1046
|
+
target: to,
|
|
1047
|
+
type: "bezier",
|
|
1048
|
+
style: { stroke: "#94a3b8", strokeWidth: 2 },
|
|
1049
|
+
markerEnd: { type: MarkerType.ArrowClosed, color: "#94a3b8" },
|
|
1050
|
+
animated: isFlowing,
|
|
1051
|
+
});
|
|
1052
|
+
});
|
|
1053
|
+
|
|
1054
|
+
// Custom node component
|
|
1055
|
+
const JobNode = ({ data }) => {
|
|
1056
|
+
const classes = ["job-node", data.state];
|
|
1057
|
+
if (data.isCurrent) classes.push("current");
|
|
1058
|
+
if (data.isGroup) classes.push("group");
|
|
1059
|
+
if (data.isEllipsis) classes.push("ellipsis");
|
|
1060
|
+
|
|
1061
|
+
const handleClick = (e) => {
|
|
1062
|
+
e.stopPropagation();
|
|
1063
|
+
// Ellipsis node - expand the group
|
|
1064
|
+
if (data.isEllipsis && data.groupKey) {
|
|
1065
|
+
window.toggleGroupExpand(data.groupKey);
|
|
1066
|
+
return;
|
|
1067
|
+
}
|
|
1068
|
+
// Regular job node - navigate to job
|
|
1069
|
+
if (
|
|
1070
|
+
!data.isGroup &&
|
|
1071
|
+
data.jobIds.length === 1 &&
|
|
1072
|
+
data.jobIds[0] !== jobId
|
|
1073
|
+
) {
|
|
1074
|
+
window.location.href = `/jobs/${data.jobIds[0]}`;
|
|
1075
|
+
}
|
|
1076
|
+
};
|
|
1077
|
+
|
|
1078
|
+
return React.createElement(
|
|
1079
|
+
"div",
|
|
1080
|
+
{
|
|
1081
|
+
className: classes.join(" "),
|
|
1082
|
+
onClick: handleClick,
|
|
1083
|
+
},
|
|
1084
|
+
[
|
|
1085
|
+
// Left handle (target for incoming edges)
|
|
1086
|
+
React.createElement(Handle, {
|
|
1087
|
+
key: "target",
|
|
1088
|
+
type: "target",
|
|
1089
|
+
position: Position.Left,
|
|
1090
|
+
style: { background: "transparent", border: "none" },
|
|
1091
|
+
}),
|
|
1092
|
+
// Label
|
|
1093
|
+
data.label,
|
|
1094
|
+
// Count for groups
|
|
1095
|
+
data.isGroup &&
|
|
1096
|
+
React.createElement(
|
|
1097
|
+
"span",
|
|
1098
|
+
{ key: "count", className: "count" },
|
|
1099
|
+
`×${data.count}`,
|
|
1100
|
+
),
|
|
1101
|
+
// Right handle (source for outgoing edges)
|
|
1102
|
+
React.createElement(Handle, {
|
|
1103
|
+
key: "source",
|
|
1104
|
+
type: "source",
|
|
1105
|
+
position: Position.Right,
|
|
1106
|
+
style: { background: "transparent", border: "none" },
|
|
1107
|
+
}),
|
|
1108
|
+
],
|
|
1109
|
+
);
|
|
1110
|
+
};
|
|
1111
|
+
|
|
1112
|
+
const nodeTypes = { jobNode: JobNode };
|
|
1113
|
+
|
|
1114
|
+
// Flow component
|
|
1115
|
+
const Flow = () => {
|
|
1116
|
+
const [nodes, setNodes, onNodesChange] = useNodesState(flowNodes);
|
|
1117
|
+
const [edgesState, setEdges, onEdgesChange] =
|
|
1118
|
+
useEdgesState(flowEdges);
|
|
1119
|
+
const { fitView } = useReactFlow();
|
|
1120
|
+
|
|
1121
|
+
// Fit view when nodes change
|
|
1122
|
+
useEffect(() => {
|
|
1123
|
+
setTimeout(() => fitView({ padding: 0.2 }), 50);
|
|
1124
|
+
}, [nodes, fitView]);
|
|
1125
|
+
|
|
1126
|
+
const onNodeClick = (event, node) => {
|
|
1127
|
+
const data = node.data;
|
|
1128
|
+
// Ellipsis node - expand the group
|
|
1129
|
+
if (data.isEllipsis && data.groupKey) {
|
|
1130
|
+
window.toggleGroupExpand(data.groupKey);
|
|
1131
|
+
return;
|
|
1132
|
+
}
|
|
1133
|
+
// Regular job node - navigate to job
|
|
1134
|
+
if (
|
|
1135
|
+
!data.isGroup &&
|
|
1136
|
+
data.jobIds &&
|
|
1137
|
+
data.jobIds.length === 1 &&
|
|
1138
|
+
data.jobIds[0] !== jobId
|
|
1139
|
+
) {
|
|
1140
|
+
window.location.href = `/jobs/${data.jobIds[0]}`;
|
|
1141
|
+
}
|
|
1142
|
+
};
|
|
1143
|
+
|
|
1144
|
+
return React.createElement(
|
|
1145
|
+
ReactFlow,
|
|
1146
|
+
{
|
|
1147
|
+
nodes: nodes,
|
|
1148
|
+
edges: edgesState,
|
|
1149
|
+
onNodesChange: onNodesChange,
|
|
1150
|
+
onEdgesChange: onEdgesChange,
|
|
1151
|
+
onNodeClick: onNodeClick,
|
|
1152
|
+
nodeTypes: nodeTypes,
|
|
1153
|
+
fitView: true,
|
|
1154
|
+
fitViewOptions: { padding: 0.2 },
|
|
1155
|
+
minZoom: 0.5,
|
|
1156
|
+
maxZoom: 2,
|
|
1157
|
+
nodesDraggable: false,
|
|
1158
|
+
nodesConnectable: false,
|
|
1159
|
+
elementsSelectable: true,
|
|
1160
|
+
panOnDrag: true,
|
|
1161
|
+
zoomOnScroll: true,
|
|
1162
|
+
preventScrolling: false,
|
|
1163
|
+
},
|
|
1164
|
+
[
|
|
1165
|
+
React.createElement(Background, {
|
|
1166
|
+
key: "bg",
|
|
1167
|
+
color: "#334155",
|
|
1168
|
+
gap: 20,
|
|
1169
|
+
size: 1,
|
|
1170
|
+
}),
|
|
1171
|
+
React.createElement(Controls, {
|
|
1172
|
+
key: "controls",
|
|
1173
|
+
showInteractive: false,
|
|
1174
|
+
}),
|
|
1175
|
+
],
|
|
1176
|
+
);
|
|
1177
|
+
};
|
|
1178
|
+
|
|
1179
|
+
// Render
|
|
1180
|
+
const container = document.getElementById("dag-flow");
|
|
1181
|
+
if (!flowRoot) {
|
|
1182
|
+
flowRoot = ReactDOM.createRoot(container);
|
|
1183
|
+
}
|
|
1184
|
+
flowRoot.render(
|
|
1185
|
+
React.createElement(
|
|
1186
|
+
ReactFlowProvider,
|
|
1187
|
+
null,
|
|
1188
|
+
React.createElement(Flow),
|
|
1189
|
+
),
|
|
1190
|
+
);
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
function renderFamilyTable(jobs) {
|
|
1194
|
+
const tbody = document.querySelector("#family-table tbody");
|
|
1195
|
+
|
|
1196
|
+
const sorted = jobs.slice().sort((a, b) => {
|
|
1197
|
+
if (!a.parent_id && b.parent_id) return -1;
|
|
1198
|
+
if (a.parent_id && !b.parent_id) return 1;
|
|
1199
|
+
return 0;
|
|
1200
|
+
});
|
|
1201
|
+
|
|
1202
|
+
tbody.innerHTML = sorted
|
|
1203
|
+
.map((job) => {
|
|
1204
|
+
const isCurrentJob = job.id === jobId;
|
|
1205
|
+
const taskName = job.task_name.split(".").pop();
|
|
1206
|
+
|
|
1207
|
+
let duration = "-";
|
|
1208
|
+
if (job.attempted_at && job.completed_at) {
|
|
1209
|
+
const ms =
|
|
1210
|
+
new Date(job.completed_at) - new Date(job.attempted_at);
|
|
1211
|
+
duration = formatDuration(ms);
|
|
1212
|
+
} else if (job.attempted_at && job.state === "running") {
|
|
1213
|
+
duration = "running...";
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
let result = "-";
|
|
1217
|
+
if (job.recorded !== null && job.recorded !== undefined) {
|
|
1218
|
+
const recorded =
|
|
1219
|
+
typeof job.recorded === "string"
|
|
1220
|
+
? job.recorded
|
|
1221
|
+
: JSON.stringify(job.recorded);
|
|
1222
|
+
result =
|
|
1223
|
+
recorded.length > 30 ? recorded.slice(0, 30) + "..." : recorded;
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
return `
|
|
1227
|
+
<tr class="${isCurrentJob ? "current-job" : ""}">
|
|
1228
|
+
<td class="mono">
|
|
1229
|
+
${isCurrentJob ? taskName : `<a href="/jobs/${job.id}">${taskName}</a>`}
|
|
1230
|
+
${!job.parent_id ? ' <span style="color: var(--text-dim)">(root)</span>' : ""}
|
|
1231
|
+
</td>
|
|
1232
|
+
<td><span class="badge ${job.state}">${job.state}</span></td>
|
|
1233
|
+
<td class="mono">${duration}</td>
|
|
1234
|
+
<td class="mono" style="color: var(--text-muted)">${result}</td>
|
|
1235
|
+
</tr>
|
|
1236
|
+
`;
|
|
1237
|
+
})
|
|
1238
|
+
.join("");
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
fetchJob();
|
|
1242
|
+
setInterval(fetchJob, 5000);
|
|
1243
|
+
</script>
|
|
1244
|
+
</body>
|
|
1245
|
+
</html>
|