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.
@@ -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>