odibi 2.5.0__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.
Files changed (124) hide show
  1. odibi/__init__.py +32 -0
  2. odibi/__main__.py +8 -0
  3. odibi/catalog.py +3011 -0
  4. odibi/cli/__init__.py +11 -0
  5. odibi/cli/__main__.py +6 -0
  6. odibi/cli/catalog.py +553 -0
  7. odibi/cli/deploy.py +69 -0
  8. odibi/cli/doctor.py +161 -0
  9. odibi/cli/export.py +66 -0
  10. odibi/cli/graph.py +150 -0
  11. odibi/cli/init_pipeline.py +242 -0
  12. odibi/cli/lineage.py +259 -0
  13. odibi/cli/main.py +215 -0
  14. odibi/cli/run.py +98 -0
  15. odibi/cli/schema.py +208 -0
  16. odibi/cli/secrets.py +232 -0
  17. odibi/cli/story.py +379 -0
  18. odibi/cli/system.py +132 -0
  19. odibi/cli/test.py +286 -0
  20. odibi/cli/ui.py +31 -0
  21. odibi/cli/validate.py +39 -0
  22. odibi/config.py +3541 -0
  23. odibi/connections/__init__.py +9 -0
  24. odibi/connections/azure_adls.py +499 -0
  25. odibi/connections/azure_sql.py +709 -0
  26. odibi/connections/base.py +28 -0
  27. odibi/connections/factory.py +322 -0
  28. odibi/connections/http.py +78 -0
  29. odibi/connections/local.py +119 -0
  30. odibi/connections/local_dbfs.py +61 -0
  31. odibi/constants.py +17 -0
  32. odibi/context.py +528 -0
  33. odibi/diagnostics/__init__.py +12 -0
  34. odibi/diagnostics/delta.py +520 -0
  35. odibi/diagnostics/diff.py +169 -0
  36. odibi/diagnostics/manager.py +171 -0
  37. odibi/engine/__init__.py +20 -0
  38. odibi/engine/base.py +334 -0
  39. odibi/engine/pandas_engine.py +2178 -0
  40. odibi/engine/polars_engine.py +1114 -0
  41. odibi/engine/registry.py +54 -0
  42. odibi/engine/spark_engine.py +2362 -0
  43. odibi/enums.py +7 -0
  44. odibi/exceptions.py +297 -0
  45. odibi/graph.py +426 -0
  46. odibi/introspect.py +1214 -0
  47. odibi/lineage.py +511 -0
  48. odibi/node.py +3341 -0
  49. odibi/orchestration/__init__.py +0 -0
  50. odibi/orchestration/airflow.py +90 -0
  51. odibi/orchestration/dagster.py +77 -0
  52. odibi/patterns/__init__.py +24 -0
  53. odibi/patterns/aggregation.py +599 -0
  54. odibi/patterns/base.py +94 -0
  55. odibi/patterns/date_dimension.py +423 -0
  56. odibi/patterns/dimension.py +696 -0
  57. odibi/patterns/fact.py +748 -0
  58. odibi/patterns/merge.py +128 -0
  59. odibi/patterns/scd2.py +148 -0
  60. odibi/pipeline.py +2382 -0
  61. odibi/plugins.py +80 -0
  62. odibi/project.py +581 -0
  63. odibi/references.py +151 -0
  64. odibi/registry.py +246 -0
  65. odibi/semantics/__init__.py +71 -0
  66. odibi/semantics/materialize.py +392 -0
  67. odibi/semantics/metrics.py +361 -0
  68. odibi/semantics/query.py +743 -0
  69. odibi/semantics/runner.py +430 -0
  70. odibi/semantics/story.py +507 -0
  71. odibi/semantics/views.py +432 -0
  72. odibi/state/__init__.py +1203 -0
  73. odibi/story/__init__.py +55 -0
  74. odibi/story/doc_story.py +554 -0
  75. odibi/story/generator.py +1431 -0
  76. odibi/story/lineage.py +1043 -0
  77. odibi/story/lineage_utils.py +324 -0
  78. odibi/story/metadata.py +608 -0
  79. odibi/story/renderers.py +453 -0
  80. odibi/story/templates/run_story.html +2520 -0
  81. odibi/story/themes.py +216 -0
  82. odibi/testing/__init__.py +13 -0
  83. odibi/testing/assertions.py +75 -0
  84. odibi/testing/fixtures.py +85 -0
  85. odibi/testing/source_pool.py +277 -0
  86. odibi/transformers/__init__.py +122 -0
  87. odibi/transformers/advanced.py +1472 -0
  88. odibi/transformers/delete_detection.py +610 -0
  89. odibi/transformers/manufacturing.py +1029 -0
  90. odibi/transformers/merge_transformer.py +778 -0
  91. odibi/transformers/relational.py +675 -0
  92. odibi/transformers/scd.py +579 -0
  93. odibi/transformers/sql_core.py +1356 -0
  94. odibi/transformers/validation.py +165 -0
  95. odibi/ui/__init__.py +0 -0
  96. odibi/ui/app.py +195 -0
  97. odibi/utils/__init__.py +66 -0
  98. odibi/utils/alerting.py +667 -0
  99. odibi/utils/config_loader.py +343 -0
  100. odibi/utils/console.py +231 -0
  101. odibi/utils/content_hash.py +202 -0
  102. odibi/utils/duration.py +43 -0
  103. odibi/utils/encoding.py +102 -0
  104. odibi/utils/extensions.py +28 -0
  105. odibi/utils/hashing.py +61 -0
  106. odibi/utils/logging.py +203 -0
  107. odibi/utils/logging_context.py +740 -0
  108. odibi/utils/progress.py +429 -0
  109. odibi/utils/setup_helpers.py +302 -0
  110. odibi/utils/telemetry.py +140 -0
  111. odibi/validation/__init__.py +62 -0
  112. odibi/validation/engine.py +765 -0
  113. odibi/validation/explanation_linter.py +155 -0
  114. odibi/validation/fk.py +547 -0
  115. odibi/validation/gate.py +252 -0
  116. odibi/validation/quarantine.py +605 -0
  117. odibi/writers/__init__.py +15 -0
  118. odibi/writers/sql_server_writer.py +2081 -0
  119. odibi-2.5.0.dist-info/METADATA +255 -0
  120. odibi-2.5.0.dist-info/RECORD +124 -0
  121. odibi-2.5.0.dist-info/WHEEL +5 -0
  122. odibi-2.5.0.dist-info/entry_points.txt +2 -0
  123. odibi-2.5.0.dist-info/licenses/LICENSE +190 -0
  124. odibi-2.5.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,2520 @@
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>Pipeline Story: {{ metadata.pipeline_name }}</title>
7
+ <!-- Graphviz via @hpcc-js/wasm for DAG visualization -->
8
+ <script src="https://unpkg.com/@hpcc-js/wasm@2.13.0/dist/graphviz.umd.js"></script>
9
+ <style>
10
+ :root {
11
+ --primary-color: #0066cc;
12
+ --bg-color: #f4f7f9;
13
+ --card-bg: #ffffff;
14
+ --text-color: #333333;
15
+ --border-color: #e1e4e8;
16
+ --success-color: #28a745;
17
+ --error-color: #dc3545;
18
+ --warning-color: #ffc107;
19
+ --skipped-color: #6c757d;
20
+ --font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
21
+ --mono-font: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace;
22
+ --code-bg: #2d2d2d;
23
+ --code-text: #f8f8f2;
24
+ }
25
+
26
+ /* Collapsible sections */
27
+ .collapsible-header {
28
+ display: flex;
29
+ justify-content: space-between;
30
+ align-items: center;
31
+ cursor: pointer;
32
+ padding: 12px 16px;
33
+ background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
34
+ border-radius: 8px 8px 0 0;
35
+ border: 1px solid var(--border-color);
36
+ border-bottom: none;
37
+ user-select: none;
38
+ transition: background 0.2s;
39
+ }
40
+ .collapsible-header:hover {
41
+ background: linear-gradient(135deg, #e9ecef 0%, #dee2e6 100%);
42
+ }
43
+ .collapsible-header h3 {
44
+ margin: 0;
45
+ color: var(--primary-color);
46
+ font-size: 1.1em;
47
+ }
48
+ .collapsible-header .toggle-icon {
49
+ font-size: 1.2em;
50
+ color: #666;
51
+ transition: transform 0.3s;
52
+ }
53
+ .collapsible-header.collapsed .toggle-icon {
54
+ transform: rotate(-90deg);
55
+ }
56
+ .collapsible-content {
57
+ overflow: hidden;
58
+ transition: max-height 0.3s ease-out;
59
+ }
60
+ .collapsible-content.collapsed {
61
+ max-height: 0 !important;
62
+ border: none !important;
63
+ }
64
+
65
+ /* Custom tooltip for DAG nodes */
66
+ .dag-tooltip {
67
+ position: fixed;
68
+ background: #fff;
69
+ border: 1px solid #ddd;
70
+ border-radius: 8px;
71
+ padding: 12px 16px;
72
+ box-shadow: 0 4px 12px rgba(0,0,0,0.15);
73
+ z-index: 10000;
74
+ max-width: 350px;
75
+ font-size: 13px;
76
+ pointer-events: none;
77
+ display: none;
78
+ }
79
+ .dag-tooltip.visible {
80
+ display: block;
81
+ }
82
+ .dag-tooltip h4 {
83
+ margin: 0 0 8px 0;
84
+ color: var(--primary-color);
85
+ font-size: 14px;
86
+ border-bottom: 1px solid #eee;
87
+ padding-bottom: 6px;
88
+ }
89
+ .dag-tooltip .tooltip-row {
90
+ display: flex;
91
+ margin: 4px 0;
92
+ }
93
+ .dag-tooltip .tooltip-label {
94
+ font-weight: 500;
95
+ color: #666;
96
+ min-width: 80px;
97
+ }
98
+ .dag-tooltip .tooltip-value {
99
+ color: #333;
100
+ }
101
+ .dag-tooltip .deps-list {
102
+ margin: 4px 0 0 0;
103
+ padding-left: 20px;
104
+ }
105
+ .dag-tooltip .deps-list li {
106
+ margin: 2px 0;
107
+ color: #555;
108
+ }
109
+ .dag-tooltip .external-badge {
110
+ display: inline-block;
111
+ background: #e7f5ff;
112
+ color: #1c7ed6;
113
+ padding: 2px 8px;
114
+ border-radius: 4px;
115
+ font-size: 11px;
116
+ margin-left: 8px;
117
+ }
118
+
119
+ body {
120
+ font-family: var(--font-family);
121
+ background-color: var(--bg-color);
122
+ color: var(--text-color);
123
+ margin: 0;
124
+ padding: 20px;
125
+ line-height: 1.5;
126
+ }
127
+
128
+ .container {
129
+ max-width: 1200px;
130
+ margin: 0 auto;
131
+ }
132
+
133
+ /* Header */
134
+ .header {
135
+ background: var(--card-bg);
136
+ padding: 20px;
137
+ border-radius: 8px;
138
+ box-shadow: 0 2px 4px rgba(0,0,0,0.05);
139
+ margin-bottom: 20px;
140
+ border-left: 5px solid var(--primary-color);
141
+ }
142
+
143
+ .header h1 { margin: 0 0 10px 0; color: var(--primary-color); }
144
+ .meta-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 15px; font-size: 0.9em; }
145
+ .meta-item label { display: block; color: #666; font-size: 0.8em; text-transform: uppercase; letter-spacing: 0.5px; }
146
+
147
+ /* Summary Stats */
148
+ .stats-bar {
149
+ display: flex;
150
+ gap: 20px;
151
+ margin-bottom: 30px;
152
+ }
153
+ .stat-card {
154
+ flex: 1;
155
+ background: var(--card-bg);
156
+ padding: 15px;
157
+ border-radius: 8px;
158
+ text-align: center;
159
+ box-shadow: 0 2px 4px rgba(0,0,0,0.05);
160
+ }
161
+ .stat-value { font-size: 24px; font-weight: bold; }
162
+ .stat-label { font-size: 12px; color: #666; text-transform: uppercase; }
163
+ .stat-success { color: var(--success-color); }
164
+ .stat-error { color: var(--error-color); }
165
+
166
+ /* Node Card */
167
+ .node-card {
168
+ background: var(--card-bg);
169
+ border-radius: 8px;
170
+ box-shadow: 0 2px 4px rgba(0,0,0,0.05);
171
+ margin-bottom: 20px;
172
+ overflow: hidden;
173
+ border: 1px solid var(--border-color);
174
+ }
175
+
176
+ .node-header {
177
+ padding: 15px 20px;
178
+ display: flex;
179
+ justify-content: space-between;
180
+ align-items: center;
181
+ cursor: pointer;
182
+ background: #fff;
183
+ transition: background 0.2s;
184
+ }
185
+ .node-header:hover { background: #f8f9fa; }
186
+
187
+ .node-title { display: flex; align-items: center; gap: 10px; font-weight: 600; font-size: 1.1em; }
188
+ .node-metrics { display: flex; gap: 20px; color: #666; font-size: 0.9em; }
189
+
190
+ .status-icon { width: 24px; height: 24px; display: flex; align-items: center; justify-content: center; border-radius: 50%; color: white; font-size: 14px; }
191
+ .status-success { background-color: var(--success-color); }
192
+ .status-failed { background-color: var(--error-color); }
193
+ .status-skipped { background-color: var(--skipped-color); }
194
+
195
+ /* Node Body (Collapsible) */
196
+ .node-body {
197
+ display: none;
198
+ border-top: 1px solid var(--border-color);
199
+ }
200
+ .node-body.open { display: block; }
201
+
202
+ /* Tabs */
203
+ .tabs { display: flex; background: #f8f9fa; border-bottom: 1px solid var(--border-color); }
204
+ .tab-btn {
205
+ padding: 10px 20px;
206
+ border: none;
207
+ background: none;
208
+ cursor: pointer;
209
+ font-weight: 500;
210
+ color: #666;
211
+ border-bottom: 2px solid transparent;
212
+ }
213
+ .tab-btn:hover { color: var(--primary-color); }
214
+ .tab-btn.active { color: var(--primary-color); border-bottom-color: var(--primary-color); background: #fff; }
215
+
216
+ .tab-content { padding: 20px; display: none; }
217
+ .tab-content.active { display: block; }
218
+
219
+ /* Data Tables */
220
+ table { width: 100%; border-collapse: collapse; font-size: 0.9em; margin-bottom: 15px; }
221
+ th, td { padding: 8px 12px; text-align: left; border-bottom: 1px solid var(--border-color); }
222
+ th { background: #f8f9fa; font-weight: 600; }
223
+ .diff-added { color: var(--success-color); background: #e6fffa; }
224
+ .diff-removed { color: var(--error-color); background: #fff5f5; }
225
+
226
+ /* Code Blocks */
227
+ pre {
228
+ background: #2d2d2d;
229
+ color: #f8f8f2;
230
+ padding: 15px;
231
+ border-radius: 6px;
232
+ overflow-x: auto;
233
+ font-family: var(--mono-font);
234
+ font-size: 0.9em;
235
+ margin: 0;
236
+ }
237
+
238
+ /* Badges */
239
+ .badge {
240
+ padding: 2px 8px;
241
+ border-radius: 12px;
242
+ font-size: 0.75em;
243
+ font-weight: 600;
244
+ text-transform: uppercase;
245
+ }
246
+ .badge-blue { background: #e7f5ff; color: #1971c2; }
247
+ .badge-green { background: #e6fffa; color: #0ca678; }
248
+ .badge-red { background: #fff5f5; color: #e03131; }
249
+ .badge-orange { background: #fff9db; color: #f08c00; }
250
+ .badge-gray { background: #f1f3f5; color: #868e96; }
251
+ .badge-purple { background: #f3e8ff; color: #7c3aed; }
252
+
253
+ /* Anomaly Badge */
254
+ .anomaly-badge {
255
+ display: inline-flex;
256
+ align-items: center;
257
+ gap: 4px;
258
+ padding: 2px 8px;
259
+ border-radius: 12px;
260
+ font-size: 0.75em;
261
+ font-weight: 600;
262
+ background: #fef3c7;
263
+ color: #92400e;
264
+ border: 1px solid #f59e0b;
265
+ }
266
+ .anomaly-badge.slow { background: #fee2e2; color: #991b1b; border-color: #f87171; }
267
+ .anomaly-badge.rows { background: #dbeafe; color: #1e40af; border-color: #60a5fa; }
268
+
269
+ /* Flash highlight animation for scrolled-to nodes */
270
+ @keyframes flash-highlight {
271
+ 0% { box-shadow: 0 0 0 4px rgba(0, 102, 204, 0.6); }
272
+ 50% { box-shadow: 0 0 0 8px rgba(0, 102, 204, 0.3); }
273
+ 100% { box-shadow: 0 0 0 0 rgba(0, 102, 204, 0); }
274
+ }
275
+ .node-card.flash {
276
+ animation: flash-highlight 1.5s ease-out;
277
+ }
278
+
279
+ /* Keyboard navigation focus indicator */
280
+ .node-card.keyboard-focus {
281
+ outline: 3px solid var(--primary-color);
282
+ outline-offset: 2px;
283
+ }
284
+
285
+ /* Print-friendly styles */
286
+ @media print {
287
+ body { background: white; padding: 10px; }
288
+ .node-card { break-inside: avoid; page-break-inside: avoid; }
289
+ .node-body { display: block !important; max-height: none !important; }
290
+ .node-body.open { max-height: none !important; }
291
+ #pipeline-graph-card { display: none; }
292
+ .filter-bar, .stats-bar button, #dark-mode-btn, #view-mode-btn { display: none !important; }
293
+ .header { border-left: 5px solid #333; }
294
+ pre { white-space: pre-wrap; word-wrap: break-word; }
295
+ .copy-btn { display: none !important; }
296
+ }
297
+
298
+ /* Execution Timeline styles - horizontal bar chart */
299
+ .timeline-container {
300
+ background: var(--card-bg);
301
+ border-radius: 8px;
302
+ padding: 15px;
303
+ margin-bottom: 20px;
304
+ }
305
+ .timeline-row {
306
+ display: flex;
307
+ align-items: center;
308
+ margin-bottom: 4px;
309
+ height: 24px;
310
+ }
311
+ .timeline-label {
312
+ width: 180px;
313
+ min-width: 180px;
314
+ font-size: 11px;
315
+ text-align: right;
316
+ padding-right: 10px;
317
+ overflow: hidden;
318
+ text-overflow: ellipsis;
319
+ white-space: nowrap;
320
+ color: var(--text-color);
321
+ }
322
+ .timeline-bar-container {
323
+ flex: 1;
324
+ height: 20px;
325
+ background: var(--bg-color);
326
+ border-radius: 3px;
327
+ overflow: hidden;
328
+ }
329
+ .timeline-bar {
330
+ height: 100%;
331
+ border-radius: 3px;
332
+ display: flex;
333
+ align-items: center;
334
+ padding-left: 6px;
335
+ font-size: 10px;
336
+ color: white;
337
+ cursor: pointer;
338
+ transition: opacity 0.2s;
339
+ min-width: 30px;
340
+ }
341
+ .timeline-bar:hover { opacity: 0.8; }
342
+ .timeline-bar.success { background: var(--success-color); }
343
+ .timeline-bar.failed { background: var(--error-color); }
344
+ .timeline-bar.skipped { background: var(--skipped-color); }
345
+ .timeline-bar.unknown { background: var(--skipped-color); }
346
+
347
+ /* Run Health Header */
348
+ .health-header {
349
+ background: var(--card-bg);
350
+ padding: 20px;
351
+ border-radius: 8px;
352
+ box-shadow: 0 2px 4px rgba(0,0,0,0.05);
353
+ margin-bottom: 20px;
354
+ border-left: 5px solid var(--error-color);
355
+ }
356
+ .health-header.success { border-left-color: var(--success-color); }
357
+ .health-header h2 { margin: 0 0 15px 0; display: flex; align-items: center; gap: 10px; }
358
+ .health-header .failed-list { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 15px; }
359
+ .health-header .failed-chip {
360
+ background: #fff5f5;
361
+ border: 1px solid #ffa8a8;
362
+ padding: 4px 12px;
363
+ border-radius: 16px;
364
+ font-size: 0.9em;
365
+ color: var(--error-color);
366
+ cursor: pointer;
367
+ transition: background 0.2s;
368
+ }
369
+ .health-header .failed-chip:hover { background: #ffe3e3; }
370
+ .health-header .first-error {
371
+ background: #fff5f5;
372
+ border: 1px solid #ffa8a8;
373
+ border-radius: 6px;
374
+ padding: 15px;
375
+ font-family: var(--mono-font);
376
+ font-size: 0.9em;
377
+ color: #c92a2a;
378
+ margin-top: 10px;
379
+ }
380
+ .health-header .first-error .error-type { font-weight: 600; margin-bottom: 5px; }
381
+
382
+ /* Filter Bar */
383
+ .filter-bar {
384
+ display: flex;
385
+ gap: 10px;
386
+ margin-bottom: 20px;
387
+ padding: 10px 15px;
388
+ background: var(--card-bg);
389
+ border-radius: 8px;
390
+ box-shadow: 0 2px 4px rgba(0,0,0,0.05);
391
+ align-items: center;
392
+ }
393
+ .filter-bar label { font-weight: 600; color: #666; font-size: 0.9em; }
394
+ .filter-btn {
395
+ padding: 6px 16px;
396
+ border: 1px solid var(--border-color);
397
+ background: #fff;
398
+ border-radius: 20px;
399
+ cursor: pointer;
400
+ font-size: 0.9em;
401
+ transition: all 0.2s;
402
+ }
403
+ .filter-btn:hover { background: #f8f9fa; }
404
+ .filter-btn.active { background: var(--primary-color); color: #fff; border-color: var(--primary-color); }
405
+ .filter-btn .count { opacity: 0.7; font-size: 0.85em; margin-left: 4px; }
406
+
407
+ /* Copy Button */
408
+ .copy-btn {
409
+ padding: 4px 10px;
410
+ border: 1px solid var(--border-color);
411
+ background: #f8f9fa;
412
+ border-radius: 4px;
413
+ cursor: pointer;
414
+ font-size: 0.8em;
415
+ color: #666;
416
+ transition: all 0.2s;
417
+ display: inline-flex;
418
+ align-items: center;
419
+ gap: 4px;
420
+ }
421
+ .copy-btn:hover { background: #e9ecef; color: #333; }
422
+ .copy-btn.copied { background: var(--success-color); color: #fff; border-color: var(--success-color); }
423
+
424
+ /* Schema Diff */
425
+ .schema-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
426
+ .schema-box { background: #f8f9fa; padding: 15px; border-radius: 6px; }
427
+ .schema-list { list-style: none; padding: 0; margin: 0; }
428
+ .schema-list li { padding: 4px 0; border-bottom: 1px solid #eee; display: flex; justify-content: space-between; }
429
+ .col-added { color: var(--success-color); font-weight: bold; }
430
+ .col-removed { color: var(--error-color); text-decoration: line-through; }
431
+
432
+ /* Config Diff Viewer */
433
+ .config-diff-container { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 20px; }
434
+ .config-diff-pane { background: var(--code-bg); border-radius: 6px; overflow: hidden; }
435
+ .config-diff-header { padding: 8px 12px; font-weight: 600; font-size: 0.85em; border-bottom: 1px solid var(--border-color); }
436
+ .config-diff-header.previous { background: #fff5f5; color: #c92a2a; }
437
+ .config-diff-header.current { background: #e6fffa; color: #0ca678; }
438
+ [data-theme="dark"] .config-diff-header.previous { background: #3b1a1a; color: #f87171; }
439
+ [data-theme="dark"] .config-diff-header.current { background: #1a2f2a; color: #4ade80; }
440
+ .config-diff-content { padding: 12px; font-family: var(--mono-font); font-size: 0.85em; overflow-x: auto; white-space: pre-wrap; word-break: break-word; }
441
+ .diff-line-added { background: rgba(40, 167, 69, 0.15); color: var(--success-color); }
442
+ .diff-line-removed { background: rgba(220, 53, 69, 0.15); color: var(--error-color); }
443
+ .diff-line-changed { background: rgba(255, 193, 7, 0.15); }
444
+
445
+ /* Duration Sparkline */
446
+ .sparkline-container { display: inline-flex; align-items: center; gap: 4px; vertical-align: middle; }
447
+ .sparkline { display: inline-block; }
448
+ .sparkline-tooltip { font-size: 0.75em; color: #666; }
449
+
450
+ </style>
451
+ </head>
452
+ <body>
453
+
454
+ <!-- Custom tooltip for DAG nodes -->
455
+ <div class="dag-tooltip" id="dag-tooltip"></div>
456
+
457
+ <div class="container">
458
+ <!-- Nigerian accent bar -->
459
+ <div style="height: 4px; background: linear-gradient(to right, #008751 33%, #fff 33%, #fff 66%, #008751 66%); margin-bottom: 15px; border-radius: 2px;"></div>
460
+
461
+ <!-- Header -->
462
+ <div class="header">
463
+ <p style="color: #008751; font-size: 0.85em; margin: 0 0 5px 0; font-weight: 500;">Ndewo — Welcome to your data story</p>
464
+ <h1>{{ metadata.pipeline_name }}</h1>
465
+ <div class="meta-grid">
466
+ <div class="meta-item">
467
+ <label>Run ID</label>
468
+ <span>{{ metadata.run_id }}</span>
469
+ </div>
470
+ <div class="meta-item">
471
+ <label>Started</label>
472
+ <span class="timestamp" data-utc="{{ metadata.started_at }}">{{ metadata.started_at }}</span>
473
+ </div>
474
+ <div class="meta-item">
475
+ <label>Duration</label>
476
+ <span>{{ "%.2f"|format(metadata.duration) }}s</span>
477
+ </div>
478
+ <div class="meta-item">
479
+ <label>Context</label>
480
+ <span>
481
+ {% if metadata.project %}{{ metadata.project }}{% endif %}
482
+ {% if metadata.plant %} / {{ metadata.plant }}{% endif %}
483
+ </span>
484
+ </div>
485
+ {% if metadata.git_info and metadata.git_info.commit != 'unknown' %}
486
+ <div class="meta-item">
487
+ <label>Git</label>
488
+ <span style="font-family: var(--mono-font); font-size: 0.9em;">
489
+ {{ metadata.git_info.branch }} @ {{ metadata.git_info.commit }}
490
+ </span>
491
+ </div>
492
+ {% endif %}
493
+ </div>
494
+ </div>
495
+
496
+ <!-- Toolbar (Phase 4 - Polish & UX) -->
497
+ <div class="filter-bar" style="justify-content: space-between;">
498
+ <div style="display: flex; gap: 15px; align-items: center;">
499
+ <div style="display: flex; align-items: center; gap: 8px;">
500
+ <label style="font-weight: 500;">View:</label>
501
+ <select id="view-mode" onchange="setViewMode(this.value)"
502
+ style="padding: 6px 12px; border: 1px solid var(--border-color); border-radius: 6px; font-size: 0.9em; background: var(--card-bg); color: var(--text-color);">
503
+ <option value="developer">👨‍💻 Developer</option>
504
+ <option value="stakeholder">📊 Stakeholder</option>
505
+ </select>
506
+ </div>
507
+ </div>
508
+ <div style="display: flex; gap: 10px; align-items: center;">
509
+ <button class="copy-btn" onclick="alert('Keyboard Shortcuts:\n\nF - Jump to first failed node\nN - Next node\nP - Previous node\nEnter - Toggle current node\nEsc - Collapse all nodes\n\nCtrl+P - Print/PDF')" title="Keyboard shortcuts">⌨️ Shortcuts</button>
510
+ </div>
511
+ </div>
512
+
513
+ <!-- Stats -->
514
+ <div class="stats-bar">
515
+ <div class="stat-card">
516
+ <div class="stat-value">{{ metadata.total_nodes }}</div>
517
+ <div class="stat-label">Nodes</div>
518
+ </div>
519
+ <div class="stat-card">
520
+ <div class="stat-value stat-success">{{ metadata.completed_nodes }}</div>
521
+ <div class="stat-label">Completed</div>
522
+ </div>
523
+ <div class="stat-card">
524
+ <div class="stat-value stat-error">{{ metadata.failed_nodes }}</div>
525
+ <div class="stat-label">Failed</div>
526
+ </div>
527
+ <div class="stat-card">
528
+ <div class="stat-value">{{ "{:,}".format(metadata.get_total_rows_processed()) }}</div>
529
+ <div class="stat-label">Total Rows</div>
530
+ </div>
531
+ <div class="stat-card">
532
+ <div class="stat-value">{{ "%.1f"|format(metadata.get_success_rate()) }}%</div>
533
+ <div class="stat-label">Success Rate</div>
534
+ </div>
535
+ </div>
536
+
537
+ <!-- Run Health Header (Phase 1 - Triage) -->
538
+ {% set health = metadata.get_run_health_summary() %}
539
+ {% if health.has_failures %}
540
+ <div class="health-header">
541
+ <h2>
542
+ <span style="font-size: 1.2em;">⚠️</span>
543
+ Run Failed - {{ health.failed_count }} node{% if health.failed_count > 1 %}s{% endif %} failed
544
+ </h2>
545
+ <div class="failed-list">
546
+ {% for node_name in health.failed_nodes %}
547
+ <span class="failed-chip" onclick="scrollToNode('{{ node_name }}')">{{ node_name }}</span>
548
+ {% endfor %}
549
+ </div>
550
+ {% if health.first_failure_error %}
551
+ <div class="first-error">
552
+ <div class="error-type">{{ health.first_failure_type }}: {{ health.first_failure_node }}</div>
553
+ <div>{{ health.first_failure_error }}</div>
554
+ </div>
555
+ {% endif %}
556
+ </div>
557
+ {% elif health.anomaly_count > 0 %}
558
+ <div class="health-header" style="border-left-color: var(--warning-color);">
559
+ <h2>
560
+ <span style="font-size: 1.2em;">⚡</span>
561
+ Run Succeeded with {{ health.anomaly_count }} Anomal{% if health.anomaly_count > 1 %}ies{% else %}y{% endif %}
562
+ </h2>
563
+ <div class="failed-list">
564
+ {% for node_name in health.anomalous_nodes %}
565
+ <span class="failed-chip" style="background: #fff9db; border-color: #ffe066; color: #e0a800;" onclick="scrollToNode('{{ node_name }}')">{{ node_name }}</span>
566
+ {% endfor %}
567
+ </div>
568
+ </div>
569
+ {% else %}
570
+ <div class="health-header success">
571
+ <h2>
572
+ <span style="font-size: 1.2em;">✅</span>
573
+ Run Completed Successfully
574
+ </h2>
575
+ </div>
576
+ {% endif %}
577
+
578
+ <!-- Changes Summary Card (Phase 3 - Cross-Run Context) -->
579
+ {% if metadata.change_summary and metadata.change_summary.has_changes %}
580
+ <div class="node-card" style="padding: 20px; border-left: 4px solid #7c3aed;">
581
+ <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px;">
582
+ <h3 style="margin: 0; color: #7c3aed;">📊 Changes vs Last Success</h3>
583
+ <span style="font-size: 0.85em; color: #666;">Compared to previous run ({{ metadata.compared_to_run_id | format_run_id }})</span>
584
+ </div>
585
+ <div style="display: flex; flex-wrap: wrap; gap: 15px;">
586
+ {% if metadata.change_summary.newly_failing_count > 0 %}
587
+ <div style="background: #fff5f5; border: 1px solid #ffa8a8; padding: 12px 16px; border-radius: 8px; min-width: 150px;">
588
+ <div style="font-size: 1.5em; font-weight: bold; color: var(--error-color);">{{ metadata.change_summary.newly_failing_count }}</div>
589
+ <div style="font-size: 0.85em; color: #666;">Newly Failing</div>
590
+ <div style="font-size: 0.8em; color: #999; margin-top: 4px;">{{ metadata.change_summary.newly_failing_nodes | join(', ') }}</div>
591
+ </div>
592
+ {% endif %}
593
+ {% if metadata.change_summary.sql_changed_count > 0 %}
594
+ <div style="background: #f3e8ff; border: 1px solid #c4b5fd; padding: 12px 16px; border-radius: 8px; min-width: 150px;">
595
+ <div style="font-size: 1.5em; font-weight: bold; color: #7c3aed;">{{ metadata.change_summary.sql_changed_count }}</div>
596
+ <div style="font-size: 0.85em; color: #666;">SQL Changed</div>
597
+ <div style="font-size: 0.8em; color: #999; margin-top: 4px;">{{ metadata.change_summary.sql_changed_nodes | join(', ') }}</div>
598
+ </div>
599
+ {% endif %}
600
+ {% if metadata.change_summary.schema_changed_count > 0 %}
601
+ <div style="background: #dbeafe; border: 1px solid #93c5fd; padding: 12px 16px; border-radius: 8px; min-width: 150px;">
602
+ <div style="font-size: 1.5em; font-weight: bold; color: #1e40af;">{{ metadata.change_summary.schema_changed_count }}</div>
603
+ <div style="font-size: 0.85em; color: #666;">Schema Changed</div>
604
+ <div style="font-size: 0.8em; color: #999; margin-top: 4px;">{{ metadata.change_summary.schema_changed_nodes | join(', ') }}</div>
605
+ </div>
606
+ {% endif %}
607
+ {% if metadata.change_summary.rows_changed_count > 0 %}
608
+ <div style="background: #fef3c7; border: 1px solid #fcd34d; padding: 12px 16px; border-radius: 8px; min-width: 150px;">
609
+ <div style="font-size: 1.5em; font-weight: bold; color: #92400e;">{{ metadata.change_summary.rows_changed_count }}</div>
610
+ <div style="font-size: 0.85em; color: #666;">Row Count Changed</div>
611
+ <div style="font-size: 0.8em; color: #999; margin-top: 4px;">{{ metadata.change_summary.rows_changed_nodes | join(', ') }}</div>
612
+ </div>
613
+ {% endif %}
614
+ </div>
615
+ </div>
616
+ {% elif metadata.compared_to_run_id %}
617
+ <div class="node-card" style="padding: 15px; border-left: 4px solid var(--success-color);">
618
+ <span style="color: var(--success-color); font-weight: 600;">✓ No significant changes</span>
619
+ <span style="color: #666; font-size: 0.9em; margin-left: 10px;">vs previous run ({{ metadata.compared_to_run_id | format_run_id }})</span>
620
+ </div>
621
+ {% endif %}
622
+
623
+ <!-- Data Quality Summary Card (Phase 5 - Quality & Documentation) -->
624
+ {% set quality = metadata.get_data_quality_summary() %}
625
+ {% set freshness = metadata.get_freshness_info() %}
626
+ {% if quality.has_quality_issues or quality.top_null_columns %}
627
+ <div class="node-card" style="padding: 20px; border-left: 4px solid #f59e0b;">
628
+ <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px;">
629
+ <h3 style="margin: 0; color: #f59e0b;">🔍 Data Quality Summary</h3>
630
+ {% if freshness %}
631
+ <span style="font-size: 0.85em; color: #666;">
632
+ 📅 Data as of: <strong>{{ freshness.formatted }}</strong>
633
+ <span style="color: #999;">({{ freshness.column }} in {{ freshness.node }})</span>
634
+ </span>
635
+ {% endif %}
636
+ </div>
637
+ <div style="display: flex; flex-wrap: wrap; gap: 15px; margin-bottom: 15px;">
638
+ {% if quality.total_validations_failed > 0 %}
639
+ <div style="background: #fff5f5; border: 1px solid #ffa8a8; padding: 12px 16px; border-radius: 8px; min-width: 120px;">
640
+ <div style="font-size: 1.5em; font-weight: bold; color: var(--error-color);">{{ quality.total_validations_failed }}</div>
641
+ <div style="font-size: 0.85em; color: #666;">Validations Failed</div>
642
+ </div>
643
+ {% endif %}
644
+ {% if quality.total_failed_rows > 0 %}
645
+ <div style="background: #fff5f5; border: 1px solid #ffa8a8; padding: 12px 16px; border-radius: 8px; min-width: 120px;">
646
+ <div style="font-size: 1.5em; font-weight: bold; color: var(--error-color);">{{ "{:,}".format(quality.total_failed_rows) }}</div>
647
+ <div style="font-size: 0.85em; color: #666;">Total Failed Rows</div>
648
+ </div>
649
+ {% endif %}
650
+ {% if quality.nodes_with_warnings %}
651
+ <div style="background: #fff9db; border: 1px solid #ffe066; padding: 12px 16px; border-radius: 8px; min-width: 150px;">
652
+ <div style="font-size: 1.5em; font-weight: bold; color: #e0a800;">{{ quality.nodes_with_warnings|length }}</div>
653
+ <div style="font-size: 0.85em; color: #666;">Nodes with Warnings</div>
654
+ <div style="font-size: 0.8em; color: #999; margin-top: 4px;">{{ quality.nodes_with_warnings[:3] | join(', ') }}{% if quality.nodes_with_warnings|length > 3 %}...{% endif %}</div>
655
+ </div>
656
+ {% endif %}
657
+ </div>
658
+ {% if quality.top_null_columns %}
659
+ <div style="margin-top: 15px;">
660
+ <h4 style="margin: 0 0 10px 0; font-size: 0.9em; color: #666;">Top Columns by Null %</h4>
661
+ <div style="display: flex; flex-wrap: wrap; gap: 8px;">
662
+ {% for col in quality.top_null_columns[:5] %}
663
+ <span style="background: {% if col.null_pct > 0.9 %}#fff5f5; border-color: #ffa8a8; color: #c92a2a;{% elif col.null_pct > 0.5 %}#fff9db; border-color: #ffe066; color: #e0a800;{% else %}#f8f9fa; border-color: #e1e4e8; color: #666;{% endif %} border: 1px solid; padding: 4px 10px; border-radius: 16px; font-size: 0.85em;">
664
+ <strong>{{ col.column }}</strong> {{ "%.0f"|format(col.null_pct * 100) }}%
665
+ <span style="color: #999; font-size: 0.85em;">({{ col.node }})</span>
666
+ </span>
667
+ {% endfor %}
668
+ </div>
669
+ </div>
670
+ {% endif %}
671
+ </div>
672
+ {% elif freshness %}
673
+ <!-- Show freshness indicator even without quality issues -->
674
+ <div class="node-card" style="padding: 15px; border-left: 4px solid var(--success-color);">
675
+ <span style="color: var(--success-color); font-weight: 600;">✓ Data Quality: No issues detected</span>
676
+ <span style="color: #666; font-size: 0.9em; margin-left: 15px;">
677
+ 📅 Data as of: <strong>{{ freshness.formatted }}</strong>
678
+ </span>
679
+ </div>
680
+ {% endif %}
681
+
682
+ <!-- Filter Bar (Phase 1 - Triage) -->
683
+ {% set changed_count = metadata.change_summary.sql_changed_count + metadata.change_summary.schema_changed_count if metadata.change_summary else 0 %}
684
+ <div class="filter-bar">
685
+ <label>Show:</label>
686
+ <button class="filter-btn active" onclick="filterNodes('all', this)">All <span class="count">({{ metadata.total_nodes }})</span></button>
687
+ <button class="filter-btn" onclick="filterNodes('failed', this)">Failed <span class="count">({{ metadata.failed_nodes }})</span></button>
688
+ <button class="filter-btn" onclick="filterNodes('anomalous', this)">Anomalous <span class="count">({{ health.anomaly_count }})</span></button>
689
+ {% if changed_count > 0 %}
690
+ <button class="filter-btn" onclick="filterNodes('changed', this)">Changed <span class="count">({{ changed_count }})</span></button>
691
+ {% endif %}
692
+ </div>
693
+
694
+ <!-- Execution Timeline (Collapsible - default collapsed) -->
695
+ <div class="timeline-container" id="execution-timeline" style="padding: 0; border: none;">
696
+ <div class="collapsible-header collapsed" onclick="toggleSection('timeline')">
697
+ <h3>Execution Timeline <span style="font-weight: normal; font-size: 0.8em; color: #666;">Total: {{ "%.2f"|format(metadata.duration) }}s</span></h3>
698
+ <span class="toggle-icon">▼</span>
699
+ </div>
700
+ <div class="collapsible-content collapsed" id="timeline-content" style="border: 1px solid var(--border-color); border-top: none; border-radius: 0 0 8px 8px; padding: 16px; background: var(--card-bg);">
701
+ <div id="timeline-chart"></div>
702
+ </div>
703
+ </div>
704
+
705
+ <!-- Node List -->
706
+ <div class="node-list">
707
+
708
+ <!-- Pipeline Graph (Graphviz) -->
709
+ <!-- Pipeline Flow (Collapsible - default expanded) -->
710
+ {% set node_count = metadata.nodes|length %}
711
+ <div id="pipeline-graph-card" style="margin-bottom: 20px;">
712
+ <div class="collapsible-header" onclick="toggleSection('pipeline')">
713
+ <div style="display: flex; align-items: center; gap: 15px; flex-wrap: wrap;">
714
+ <h3>Pipeline Flow <span style="font-weight: normal; font-size: 0.8em; color: #666;">({{ node_count }} nodes)</span></h3>
715
+ <span style="font-size: 0.8em; color: #666;">Click node for details</span>
716
+ </div>
717
+ <div style="display: flex; gap: 8px; align-items: center;">
718
+ <select id="graph-view-type" onchange="event.stopPropagation(); changeGraphViewType(this.value)" style="padding: 4px 8px; border: 1px solid var(--border-color); border-radius: 4px; font-size: 0.85em; background: var(--card-bg); color: var(--text-color);">
719
+ <option value="dag" selected>DAG View</option>
720
+ <option value="list">List View</option>
721
+ </select>
722
+ <span class="toggle-icon">▼</span>
723
+ </div>
724
+ </div>
725
+ <div class="collapsible-content" id="pipeline-content" style="border: 1px solid var(--border-color); border-top: none; border-radius: 0 0 8px 8px; padding: 16px; background: var(--card-bg);">
726
+ <!-- Layout fixed to Left→Right for optimal readability -->
727
+ <input type="hidden" id="graph-layout" value="LR">
728
+ <div id="graphviz-container" style="min-height: 400px; max-height: 800px; overflow: auto; background: #f8f9fa; border-radius: 8px; border: 1px solid var(--border-color); text-align: center;"></div>
729
+ <div id="pipeline-list-view" style="display: none; max-height: 600px; overflow-y: auto; background: #f8f9fa; border-radius: 8px; border: 1px solid var(--border-color); padding: 16px;"></div>
730
+ </div>
731
+ </div>
732
+
733
+ {% for node in metadata.nodes %}
734
+ <div class="node-card" id="node-{{ node.node_name }}" data-status="{{ node.status }}" data-anomaly="{{ 'true' if node.is_anomaly else 'false' }}" data-changed="{{ 'true' if node.changed_from_last_success else 'false' }}">
735
+ <div class="node-header" onclick="toggleNode(this)">
736
+ <div class="node-title">
737
+ <div class="status-icon status-{{ node.status }}">
738
+ {% if node.status == 'success' %}✓{% elif node.status == 'failed' %}!{% else %}-{% endif %}
739
+ </div>
740
+ <div>
741
+ {{ node.node_name }}
742
+ {% if node.description %}
743
+ <div style="font-size: 0.75em; color: #666; font-weight: normal; margin-top: 2px;">{{ node.description }}</div>
744
+ {% endif %}
745
+ </div>
746
+ <span class="badge badge-blue">{{ node.operation }}</span>
747
+ {% if node.is_anomaly %}
748
+ {% if node.is_slow %}
749
+ <span class="anomaly-badge slow" title="{{ node.anomaly_reasons|join(', ') }}">🐢 Slow</span>
750
+ {% endif %}
751
+ {% if node.has_row_anomaly %}
752
+ <span class="anomaly-badge rows" title="{{ node.anomaly_reasons|join(', ') }}">📊 Row Δ</span>
753
+ {% endif %}
754
+ {% endif %}
755
+ {% if node.changed_from_last_success %}
756
+ <span class="badge badge-purple" title="Changed: {{ node.changes_detected|join(', ') }}">Δ {{ node.changes_detected|join(', ') }}</span>
757
+ {% endif %}
758
+ {% if node.runbook_url and node.status == 'failed' %}
759
+ <a href="{{ node.runbook_url }}" target="_blank" style="font-size: 0.8em; color: var(--primary-color); text-decoration: none; margin-left: 8px;" onclick="event.stopPropagation();">Troubleshooting guide →</a>
760
+ {% endif %}
761
+ </div>
762
+ <div class="node-metrics">
763
+ {% if node.rows_in is not none %}
764
+ <span title="Rows Read">📥 {{ "{:,}".format(node.rows_in) }}</span>
765
+ {% endif %}
766
+ {% if node.rows_written is not none %}
767
+ <span title="Rows Written">📤 {{ "{:,}".format(node.rows_written) }}{% if node.rows_in is not none and node.rows_written == 0 and node.rows_in > 0 %} (no changes){% endif %}</span>
768
+ {% elif node.rows_out is not none %}
769
+ <span>{{ "{:,}".format(node.rows_out) }} rows</span>
770
+ {% endif %}
771
+
772
+ {% if node.rows_change is not none and node.rows_change != 0 %}
773
+ <span class="badge {% if node.rows_change > 0 %}badge-green{% else %}badge-red{% endif %}">
774
+ {% if node.rows_change > 0 %}+{% endif %}{{ "{:,}".format(node.rows_change) }}
775
+ </span>
776
+ {% endif %}
777
+
778
+ <span class="sparkline-container">
779
+ {{ "%.4f"|format(node.duration) }}s
780
+ {% if node.duration_history and node.duration_history|length > 1 %}
781
+ <span class="sparkline" id="sparkline-{{ loop.index }}"
782
+ data-history="{{ node.duration_history | tojson | e }}"
783
+ title="Duration trend (last {{ node.duration_history|length }} runs)"></span>
784
+ {% endif %}
785
+ </span>
786
+ </div>
787
+ </div>
788
+
789
+ <div class="node-body">
790
+ <div class="tabs">
791
+ <button class="tab-btn active" onclick="openTab(event, 'info-{{ loop.index }}')">Info & Schema</button>
792
+ {% if node.executed_sql %}
793
+ <button class="tab-btn" onclick="openTab(event, 'sql-{{ loop.index }}')">SQL Logic</button>
794
+ {% endif %}
795
+ {% if node.sample_data or node.sample_in or node.data_diff %}
796
+ <button class="tab-btn" onclick="openTab(event, 'data-{{ loop.index }}')">Data Preview</button>
797
+ {% endif %}
798
+ {% if node.config_snapshot %}
799
+ <button class="tab-btn" onclick="openTab(event, 'config-{{ loop.index }}')">Config</button>
800
+ {% endif %}
801
+ </div>
802
+
803
+ <!-- Tab: Info -->
804
+ <div id="info-{{ loop.index }}" class="tab-content active">
805
+ <!-- Quick Actions -->
806
+ <div style="display: flex; gap: 8px; margin-bottom: 15px; flex-wrap: wrap;">
807
+ <button class="copy-btn" onclick="copyToClipboard(this, 'odibi run {{ metadata.config_file or 'pipeline.yaml' }} --node {{ node.node_name }}'); event.stopPropagation();" title="Copy CLI command to re-run this node">
808
+ 🔄 Copy Re-run Command
809
+ </button>
810
+ <button class="copy-btn" onclick="copyToClipboard(this, 'spark.sql(\"SELECT * FROM {{ node.node_name }} LIMIT 10\").show()'); event.stopPropagation();" title="Copy Spark debug query">
811
+ 🔍 Copy Debug Query
812
+ </button>
813
+ <button class="copy-btn" onclick="copyNodeLink(this, '{{ node.node_name }}'); event.stopPropagation();" title="Copy link to this node">
814
+ 🔗 Copy Link
815
+ </button>
816
+ </div>
817
+
818
+ {% if node.validation_warnings %}
819
+ <div style="background: #fff9db; color: #e0a800; padding: 15px; border-radius: 4px; border: 1px solid #ffe066; margin-bottom: 20px;">
820
+ <strong>Validation Warnings:</strong>
821
+ <ul style="margin: 5px 0 0 20px; padding: 0;">
822
+ {% for warning in node.validation_warnings %}
823
+ <li>{{ warning }}</li>
824
+ {% endfor %}
825
+ </ul>
826
+ </div>
827
+ {% endif %}
828
+
829
+ {% if node.error_message %}
830
+ <div style="background: #fff5f5; color: #c92a2a; padding: 15px; border-radius: 4px; border: 1px solid #ffa8a8; margin-bottom: 20px; position: relative;">
831
+ <button class="copy-btn" style="position: absolute; top: 10px; right: 10px;" onclick="copyError(this, '{{ node.node_name }}'); event.stopPropagation();">📋 Copy Error</button>
832
+ <strong>Error: {{ node.error_type }}</strong>
833
+ <pre style="background: none; color: #c92a2a; padding: 0; margin-top: 10px; padding-right: 80px;" id="error-{{ loop.index }}">{{ node.error_message }}</pre>
834
+
835
+ {% if node.error_traceback_cleaned %}
836
+ <details style="margin-top: 15px;">
837
+ <summary style="cursor: pointer; font-weight: 600; color: #c92a2a;">Show Traceback (Cleaned)</summary>
838
+ <pre style="background: #2d2d2d; color: #f8f8f2; padding: 15px; border-radius: 4px; margin-top: 10px; overflow-x: auto; font-size: 0.85em;" id="traceback-cleaned-{{ loop.index }}">{{ node.error_traceback_cleaned }}</pre>
839
+ </details>
840
+ {% endif %}
841
+
842
+ {% if node.error_traceback %}
843
+ <details style="margin-top: 10px;">
844
+ <summary style="cursor: pointer; font-weight: 500; color: #999; font-size: 0.9em;">Show Full Traceback (Raw)</summary>
845
+ <pre style="background: #2d2d2d; color: #f8f8f2; padding: 15px; border-radius: 4px; margin-top: 10px; overflow-x: auto; font-size: 0.8em; max-height: 400px; overflow-y: auto;" id="traceback-full-{{ loop.index }}">{{ node.error_traceback }}</pre>
846
+ </details>
847
+ {% endif %}
848
+ </div>
849
+ {% endif %}
850
+
851
+ {% if node.execution_steps %}
852
+ <div style="margin-bottom: 20px; border: 1px solid #e1e4e8; border-radius: 6px; overflow: hidden;">
853
+ <details open>
854
+ <summary style="background: #f8f9fa; padding: 10px 15px; cursor: pointer; font-weight: 600; color: #333;">
855
+ 🔧 Execution Steps ({{ node.execution_steps|length }})
856
+ </summary>
857
+ <div style="padding: 15px;">
858
+ <ol style="margin: 0; padding-left: 20px; font-size: 0.9em; color: #555;">
859
+ {% for step in node.execution_steps %}
860
+ <li style="padding: 4px 0;">{{ step }}</li>
861
+ {% endfor %}
862
+ </ol>
863
+ </div>
864
+ </details>
865
+ </div>
866
+ {% endif %}
867
+
868
+ {% if node.retry_history and node.retry_history|length > 1 %}
869
+ <div style="margin-bottom: 20px; border: 1px solid #ffe066; border-radius: 6px; overflow: hidden;">
870
+ <details open>
871
+ <summary style="background: #fff9db; padding: 10px 15px; cursor: pointer; font-weight: 600; color: #e0a800;">
872
+ 🔄 Retry History ({{ node.retry_history|length }} attempts)
873
+ </summary>
874
+ <div style="padding: 15px;">
875
+ <table style="width: 100%; border-collapse: collapse; font-size: 0.9em;">
876
+ <thead>
877
+ <tr>
878
+ <th style="padding: 8px; text-align: left; border-bottom: 1px solid #e1e4e8;">Attempt</th>
879
+ <th style="padding: 8px; text-align: left; border-bottom: 1px solid #e1e4e8;">Status</th>
880
+ <th style="padding: 8px; text-align: left; border-bottom: 1px solid #e1e4e8;">Duration</th>
881
+ <th style="padding: 8px; text-align: left; border-bottom: 1px solid #e1e4e8;">Error</th>
882
+ </tr>
883
+ </thead>
884
+ <tbody>
885
+ {% for attempt in node.retry_history %}
886
+ <tr>
887
+ <td style="padding: 8px; border-bottom: 1px solid #eee;">#{{ attempt.attempt }}</td>
888
+ <td style="padding: 8px; border-bottom: 1px solid #eee;">
889
+ {% if attempt.success %}
890
+ <span class="badge badge-green">✓ Success</span>
891
+ {% else %}
892
+ <span class="badge badge-red">✗ Failed</span>
893
+ {% endif %}
894
+ </td>
895
+ <td style="padding: 8px; border-bottom: 1px solid #eee;">{{ attempt.duration }}s</td>
896
+ <td style="padding: 8px; border-bottom: 1px solid #eee; font-family: var(--mono-font); font-size: 0.85em; color: #c92a2a;">
897
+ {% if attempt.error %}
898
+ <details>
899
+ <summary style="cursor: pointer;">{{ attempt.error|truncate(80) }}</summary>
900
+ <div style="margin-top: 8px; padding: 10px; background: #fff5f5; border-radius: 4px; white-space: pre-wrap; max-height: 300px; overflow-y: auto;">{{ attempt.error_traceback or attempt.error }}</div>
901
+ </details>
902
+ {% else %}-{% endif %}
903
+ </td>
904
+ </tr>
905
+ {% endfor %}
906
+ </tbody>
907
+ </table>
908
+ </div>
909
+ </details>
910
+ </div>
911
+ {% endif %}
912
+
913
+ {% if node.failed_rows_samples %}
914
+ <div style="margin-bottom: 20px; border: 1px solid #ffa8a8; border-radius: 6px; overflow: hidden;">
915
+ <details>
916
+ <summary style="background: #fff5f5; padding: 10px 15px; cursor: pointer; font-weight: 600; color: #c92a2a;">
917
+ ❌ Failed Rows Samples ({{ node.failed_rows_samples|length }} validation{% if node.failed_rows_samples|length > 1 %}s{% endif %})
918
+ {% if node.failed_rows_truncated %}<span style="font-weight: normal; font-size: 0.85em;">(truncated)</span>{% endif %}
919
+ </summary>
920
+ <div style="padding: 15px;">
921
+ {% for validation_name, rows in node.failed_rows_samples.items() %}
922
+ <div style="margin-bottom: 20px;">
923
+ <h5 style="margin: 0 0 10px 0; color: #c92a2a; font-size: 0.95em;">
924
+ {{ validation_name }}
925
+ {% if node.failed_rows_counts and node.failed_rows_counts[validation_name] %}
926
+ <span style="font-weight: normal; color: #999;">({{ node.failed_rows_counts[validation_name] }} total failed)</span>
927
+ {% endif %}
928
+ </h5>
929
+ {% if rows|length > 0 %}
930
+ <div style="overflow-x: auto; border: 1px solid #ffa8a8; border-radius: 4px;">
931
+ <table style="width: 100%; margin: 0; font-size: 0.85em;">
932
+ <thead>
933
+ <tr style="background: #fff5f5;">
934
+ {% for key in rows[0].keys() %}<th style="padding: 6px 10px;">{{ key }}</th>{% endfor %}
935
+ </tr>
936
+ </thead>
937
+ <tbody>
938
+ {% for row in rows %}
939
+ <tr>
940
+ {% for val in row.values() %}<td style="padding: 6px 10px; border-top: 1px solid #ffa8a8;">{{ val }}</td>{% endfor %}
941
+ </tr>
942
+ {% endfor %}
943
+ </tbody>
944
+ </table>
945
+ </div>
946
+ {% else %}
947
+ <p style="color: #999; font-style: italic; margin: 0;">No sample data available</p>
948
+ {% endif %}
949
+ </div>
950
+ {% endfor %}
951
+
952
+ {% if node.truncated_validations %}
953
+ <div style="padding: 10px; background: #f8f9fa; border-radius: 4px; color: #666; font-size: 0.9em;">
954
+ <strong>{{ node.truncated_validations|length }} more validation{% if node.truncated_validations|length > 1 %}s{% endif %} failed</strong> (showing counts only):
955
+ <ul style="margin: 5px 0 0 0; padding-left: 20px;">
956
+ {% for val_name in node.truncated_validations %}
957
+ <li>{{ val_name }}: {{ node.failed_rows_counts.get(val_name, 'N/A') }} failed rows</li>
958
+ {% endfor %}
959
+ </ul>
960
+ </div>
961
+ {% endif %}
962
+ </div>
963
+ </details>
964
+ </div>
965
+ {% endif %}
966
+
967
+ {% if node.source_files %}
968
+ <div style="margin-bottom: 20px;">
969
+ <h4 style="margin: 0 0 10px 0; font-size: 0.95em; color: #666;">Source Files ({{ node.source_files|length }})</h4>
970
+ <div style="max-height: 100px; overflow-y: auto; background: #f8f9fa; padding: 10px; border: 1px solid #e1e4e8; border-radius: 4px; font-family: var(--mono-font); font-size: 0.8em; color: #555;">
971
+ {% for file in node.source_files %}
972
+ <div style="white-space: nowrap;">{{ file }}</div>
973
+ {% endfor %}
974
+ </div>
975
+ </div>
976
+ {% endif %}
977
+
978
+ <div class="schema-grid">
979
+ <div class="schema-box">
980
+ <h4>Input Schema ({% if node.schema_in %}{{ node.schema_in|length }}{% else %}0{% endif %})</h4>
981
+ <ul class="schema-list">
982
+ {% if node.schema_in %}
983
+ {% if node.schema_in is mapping %}
984
+ {% for col, dtype in node.schema_in.items() %}
985
+ <li class="{% if col in node.columns_removed %}col-removed{% endif %}">
986
+ <span style="font-weight: 500;">{{ col }}</span>
987
+ <span style="color: #666; font-family: var(--mono-font); font-size: 0.85em;">{{ dtype }}</span>
988
+ </li>
989
+ {% endfor %}
990
+ {% else %}
991
+ {% for col in node.schema_in %}
992
+ <li class="{% if col in node.columns_removed %}col-removed{% endif %}">{{ col }}</li>
993
+ {% endfor %}
994
+ {% endif %}
995
+ {% else %}
996
+ <li style="color: #999; font-style: italic;">No input schema</li>
997
+ {% endif %}
998
+ </ul>
999
+ </div>
1000
+ <div class="schema-box">
1001
+ <h4>Output Schema ({% if node.schema_out %}{{ node.schema_out|length }}{% else %}0{% endif %})</h4>
1002
+ <ul class="schema-list">
1003
+ {% if node.schema_out %}
1004
+ {% if node.schema_out is mapping %}
1005
+ {% for col, dtype in node.schema_out.items() %}
1006
+ <li class="{% if col in node.columns_added %}col-added{% endif %}">
1007
+ <div>
1008
+ <span style="font-weight: 500;">{{ col }}</span>
1009
+ {% if col in node.columns_added %}<span style="font-size: 0.8em;">(NEW)</span>{% endif %}
1010
+
1011
+ {% if node.null_profile %}
1012
+ {% set null_pct = node.null_profile.get(col, 0.0) or 0.0 %}
1013
+ {% if null_pct > 0 %}
1014
+ <span class="badge {% if null_pct > 0.9 %}badge-red{% elif null_pct > 0.1 %}badge-orange{% else %}badge-gray{% endif %}" style="font-size: 0.75em; margin-left: 6px;">
1015
+ {{ "%.0f"|format(null_pct * 100) }}% Null
1016
+ </span>
1017
+ {% endif %}
1018
+ {% endif %}
1019
+ </div>
1020
+ <span style="color: #666; font-family: var(--mono-font); font-size: 0.85em;">{{ dtype }}</span>
1021
+ </li>
1022
+ {% endfor %}
1023
+ {% else %}
1024
+ {% for col in node.schema_out %}
1025
+ <li class="{% if col in node.columns_added %}col-added{% endif %}">
1026
+ {{ col }}
1027
+ {% if col in node.columns_added %}<span style="font-size: 0.8em;">(NEW)</span>{% endif %}
1028
+
1029
+ {% if node.null_profile %}
1030
+ {% set null_pct = node.null_profile.get(col, 0.0) or 0.0 %}
1031
+ {% if null_pct > 0 %}
1032
+ <span class="badge {% if null_pct > 0.9 %}badge-red{% elif null_pct > 0.1 %}badge-orange{% else %}badge-gray{% endif %}" style="font-size: 0.75em; margin-left: 6px;">
1033
+ {{ "%.0f"|format(null_pct * 100) }}% Null
1034
+ </span>
1035
+ {% endif %}
1036
+ {% endif %}
1037
+ </li>
1038
+ {% endfor %}
1039
+ {% endif %}
1040
+ {% else %}
1041
+ <li style="color: #999; font-style: italic;">No output schema</li>
1042
+ {% endif %}
1043
+ </ul>
1044
+ </div>
1045
+ </div>
1046
+
1047
+ {% if node.column_statistics %}
1048
+ <div style="margin-top: 20px; border: 1px solid #e1e4e8; border-radius: 6px; overflow: hidden;">
1049
+ <details>
1050
+ <summary style="background: #f8f9fa; padding: 10px 15px; cursor: pointer; font-weight: 600; color: #333;">
1051
+ 📊 Column Statistics ({{ node.column_statistics|length }} columns)
1052
+ </summary>
1053
+ <div style="padding: 15px; overflow-x: auto;">
1054
+ <table style="width: 100%; margin: 0; font-size: 0.85em;">
1055
+ <thead>
1056
+ <tr>
1057
+ <th style="padding: 6px 10px;">Column</th>
1058
+ <th style="padding: 6px 10px; text-align: right;">Min</th>
1059
+ <th style="padding: 6px 10px; text-align: right;">Max</th>
1060
+ <th style="padding: 6px 10px; text-align: right;">Mean</th>
1061
+ <th style="padding: 6px 10px; text-align: right;">Std Dev</th>
1062
+ </tr>
1063
+ </thead>
1064
+ <tbody>
1065
+ {% for col_name, stats in node.column_statistics.items() %}
1066
+ <tr>
1067
+ <td style="padding: 6px 10px; font-weight: 500;">{{ col_name }}</td>
1068
+ <td style="padding: 6px 10px; text-align: right; font-family: var(--mono-font);">{{ stats.min if stats.min is not none else '-' }}</td>
1069
+ <td style="padding: 6px 10px; text-align: right; font-family: var(--mono-font);">{{ stats.max if stats.max is not none else '-' }}</td>
1070
+ <td style="padding: 6px 10px; text-align: right; font-family: var(--mono-font);">{{ "%.2f"|format(stats.mean) if stats.mean is not none else '-' }}</td>
1071
+ <td style="padding: 6px 10px; text-align: right; font-family: var(--mono-font);">{{ "%.2f"|format(stats.stddev) if stats.stddev is not none else '-' }}</td>
1072
+ </tr>
1073
+ {% endfor %}
1074
+ </tbody>
1075
+ </table>
1076
+ </div>
1077
+ </details>
1078
+ </div>
1079
+ {% endif %}
1080
+
1081
+ {% if node.delta_info %}
1082
+ <div style="margin-top: 20px; border: 1px solid #b8daff; background: #f1f8ff; border-radius: 6px; overflow: hidden;">
1083
+ <div style="background: #e7f5ff; padding: 10px 15px; border-bottom: 1px solid #b8daff; display: flex; justify-content: space-between; align-items: center;">
1084
+ <h4 style="margin: 0; font-size: 0.95em; color: #004085; display: flex; align-items: center; gap: 8px;">
1085
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M8 0L1 4l7 4 7-4-7-4zM1 12l7 4 7-4V8l-7 4-7-4v4z"/></svg>
1086
+ Delta Lake Write
1087
+ </h4>
1088
+ <span class="badge badge-blue" style="font-size: 0.8em;">v{{ node.delta_info.version }}</span>
1089
+ </div>
1090
+ <div style="padding: 15px; display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 15px; font-size: 0.9em;">
1091
+ <div>
1092
+ <div style="color: #6c757d; font-size: 0.85em; text-transform: uppercase;">Operation</div>
1093
+ <div style="font-weight: 600; color: #333;">{{ node.delta_info.operation }}</div>
1094
+ </div>
1095
+ <div>
1096
+ <div style="color: #6c757d; font-size: 0.85em; text-transform: uppercase;">Timestamp</div>
1097
+ <div style="font-weight: 600; color: #333;">{{ node.delta_info.timestamp }}</div>
1098
+ </div>
1099
+
1100
+ <!-- Metrics Highlights -->
1101
+ {% if node.delta_info.operation_metrics %}
1102
+ {% set metrics = node.delta_info.operation_metrics %}
1103
+ {% set inserted = metrics.numTargetRowsInserted or metrics.numOutputRows or metrics.num_added_rows %}
1104
+ {% set updated = metrics.numTargetRowsUpdated or metrics.num_updated_rows %}
1105
+ {% set deleted = metrics.numTargetRowsDeleted or metrics.num_deleted_rows %}
1106
+ {% set files = metrics.numAddedFiles or metrics.numFilesAdded or metrics.num_added_files %}
1107
+
1108
+ {% if inserted %}
1109
+ <div>
1110
+ <div style="color: #6c757d; font-size: 0.85em; text-transform: uppercase;">Rows Inserted</div>
1111
+ <div style="font-weight: 600; color: var(--success-color);">+{{ inserted }}</div>
1112
+ </div>
1113
+ {% endif %}
1114
+ {% if updated %}
1115
+ <div>
1116
+ <div style="color: #6c757d; font-size: 0.85em; text-transform: uppercase;">Rows Updated</div>
1117
+ <div style="font-weight: 600; color: #f59f00;">~{{ updated }}</div>
1118
+ </div>
1119
+ {% endif %}
1120
+ {% if deleted %}
1121
+ <div>
1122
+ <div style="color: #6c757d; font-size: 0.85em; text-transform: uppercase;">Rows Deleted</div>
1123
+ <div style="font-weight: 600; color: var(--error-color);">-{{ deleted }}</div>
1124
+ </div>
1125
+ {% endif %}
1126
+
1127
+ <!-- Generic fallback if specific metrics missing -->
1128
+ {% if not inserted and not updated and not deleted %}
1129
+ <div>
1130
+ <div style="color: #6c757d; font-size: 0.85em; text-transform: uppercase;">Files Added</div>
1131
+ <div style="font-weight: 600;">{{ files }}</div>
1132
+ </div>
1133
+ {% endif %}
1134
+ {% endif %}
1135
+ </div>
1136
+ </div>
1137
+ {% endif %}
1138
+ </div>
1139
+
1140
+ <!-- Tab: SQL -->
1141
+ {% if node.executed_sql %}
1142
+ <div id="sql-{{ loop.index }}" class="tab-content">
1143
+ <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px;">
1144
+ <h4 style="margin: 0; color: #333;">
1145
+ 💾 Executed SQL ({{ node.executed_sql|length }} statement{% if node.executed_sql|length > 1 %}s{% endif %})
1146
+ </h4>
1147
+ <button class="copy-btn" onclick="copyAllSQL(this, '{{ node.node_name }}')">📋 Copy All SQL</button>
1148
+ </div>
1149
+ {% for sql in node.executed_sql %}
1150
+ <details {% if loop.first %}open{% endif %} style="margin-bottom: 15px;">
1151
+ <summary style="cursor: pointer; padding: 8px; background: #f8f9fa; border: 1px solid #e1e4e8; border-radius: 4px; font-weight: 500; font-size: 0.9em; display: flex; justify-content: space-between; align-items: center;">
1152
+ <span>
1153
+ Statement #{{ loop.index }}
1154
+ {% if sql|length > 100 %}
1155
+ <span style="color: #666; font-weight: normal;"> - {{ sql[:50]|replace('\n', ' ') }}...</span>
1156
+ {% endif %}
1157
+ </span>
1158
+ </summary>
1159
+ <div style="position: relative; margin-top: 10px;">
1160
+ <button class="copy-btn" style="position: absolute; top: 8px; right: 8px;" onclick="copyToClipboard(this, `{{ sql|e }}`); event.stopPropagation();">📋 Copy</button>
1161
+ <pre style="border-left: 3px solid var(--primary-color); padding-right: 80px;" id="sql-{{ node.node_name }}-{{ loop.index }}">{{ sql }}</pre>
1162
+ </div>
1163
+ </details>
1164
+ {% endfor %}
1165
+ {% if node.sql_hash %}
1166
+ <div style="margin-top: 15px; padding: 10px; background: #f8f9fa; border-radius: 4px; color: #666; font-size: 0.85em;">
1167
+ <strong>SQL Hash:</strong> <code style="background: #e9ecef; padding: 2px 6px; border-radius: 3px;">{{ node.sql_hash }}</code>
1168
+ </div>
1169
+ {% endif %}
1170
+ </div>
1171
+ {% endif %}
1172
+
1173
+ <!-- Tab: Data -->
1174
+ {% if node.sample_data or node.sample_in or node.data_diff %}
1175
+ <div id="data-{{ loop.index }}" class="tab-content">
1176
+ {% if node.data_diff %}
1177
+ <div style="margin-bottom: 20px; border: 1px solid #e1e4e8; border-radius: 6px; overflow: hidden;">
1178
+ <div style="background: #f8f9fa; padding: 10px 15px; border-bottom: 1px solid #e1e4e8;">
1179
+ <h4 style="margin: 0; font-size: 1em; color: #666;">
1180
+ 📉 Changes vs Previous Version
1181
+ {% if node.delta_info and node.delta_info.read_version is not none %}
1182
+ <span style="font-weight: normal; font-size: 0.9em;">(v{{ node.delta_info.version }} vs v{{ node.delta_info.version - 1 }})</span>
1183
+ {% endif %}
1184
+ </h4>
1185
+ </div>
1186
+
1187
+ <div style="padding: 15px;">
1188
+ <div style="display: flex; gap: 20px; margin-bottom: 15px; font-size: 0.95em;">
1189
+ <div><strong>Net Change:</strong> {{ node.data_diff.rows_change }}</div>
1190
+ {% if node.data_diff.rows_added is defined and node.data_diff.rows_added is not none %}
1191
+ <div style="color: var(--success-color);"><strong>Added:</strong> {{ node.data_diff.rows_added }}</div>
1192
+ {% endif %}
1193
+ {% if node.data_diff.rows_updated is defined and node.data_diff.rows_updated is not none %}
1194
+ <div style="color: #f59f00;"><strong>Updated:</strong> {{ node.data_diff.rows_updated }}</div>
1195
+ {% endif %}
1196
+ {% if node.data_diff.rows_removed is defined and node.data_diff.rows_removed is not none %}
1197
+ <div style="color: var(--error-color);"><strong>Removed:</strong> {{ node.data_diff.rows_removed }}</div>
1198
+ {% endif %}
1199
+ {% if node.data_diff.rows_added is not defined and node.data_diff.rows_updated is not defined and node.data_diff.rows_removed is not defined %}
1200
+ <div style="color: #888; font-style: italic; font-size: 0.9em;">(Enable deep diff for detailed breakdown)</div>
1201
+ {% endif %}
1202
+ </div>
1203
+
1204
+ {% if node.data_diff.schema_previous is not none %}
1205
+ <div style="margin-bottom: 20px; padding: 15px; background: #fff; border: 1px solid #e1e4e8; border-radius: 4px;">
1206
+ <h5 style="margin-top: 0; margin-bottom: 10px; color: #333; display: flex; align-items: center; gap: 8px;">
1207
+ <span>📋</span> Schema Evolution
1208
+ </h5>
1209
+
1210
+ {% if not node.data_diff.schema_added and not node.data_diff.schema_removed %}
1211
+ <div style="color: #666; font-style: italic;">No schema changes detected vs previous run.</div>
1212
+ {% else %}
1213
+ <div style="display: grid; gap: 10px;">
1214
+ {% if node.data_diff.schema_added %}
1215
+ <div>
1216
+ <span class="badge badge-green" style="margin-right: 8px;">+ ADDED</span>
1217
+ <span style="color: var(--success-color); font-family: var(--mono-font);">{{ node.data_diff.schema_added|join(', ') }}</span>
1218
+ </div>
1219
+ {% endif %}
1220
+ {% if node.data_diff.schema_removed %}
1221
+ <div>
1222
+ <span class="badge badge-red" style="margin-right: 8px;">- REMOVED</span>
1223
+ <span style="color: var(--error-color); font-family: var(--mono-font);">{{ node.data_diff.schema_removed|join(', ') }}</span>
1224
+ </div>
1225
+ {% endif %}
1226
+ </div>
1227
+ {% endif %}
1228
+ </div>
1229
+ {% endif %}
1230
+
1231
+ {% if node.data_diff.sample_updated %}
1232
+ <h5 style="margin-top: 0; margin-bottom: 10px; font-size: 0.9em; color: #666;">UPDATED ROWS (Values changed from previous run)</h5>
1233
+ <div style="overflow-x: auto; margin-bottom: 20px; border: 1px solid #eee;">
1234
+ <table style="margin: 0;">
1235
+ <thead>
1236
+ <tr>
1237
+ <th>Key(s)</th>
1238
+ <th>Changes</th>
1239
+ </tr>
1240
+ </thead>
1241
+ <tbody>
1242
+ {% for row in node.data_diff.sample_updated %}
1243
+ <tr>
1244
+ <td style="white-space: nowrap;">
1245
+ {% for k, v in row.items() if k != '_changes' %}
1246
+ <div><small>{{ k }}:</small> <strong>{{ v }}</strong></div>
1247
+ {% endfor %}
1248
+ </td>
1249
+ <td>
1250
+ {% for col, change in row._changes.items() %}
1251
+ <div style="margin-bottom: 4px;">
1252
+ <strong>{{ col }}</strong>:
1253
+ <span style="color: var(--error-color); text-decoration: line-through;">{{ change.old }}</span>
1254
+ &rarr;
1255
+ <span style="color: var(--success-color);">{{ change.new }}</span>
1256
+ </div>
1257
+ {% endfor %}
1258
+ </td>
1259
+ </tr>
1260
+ {% endfor %}
1261
+ </tbody>
1262
+ </table>
1263
+ </div>
1264
+ {% endif %}
1265
+
1266
+ {% if node.data_diff.sample_added %}
1267
+ <h5 style="margin-top: 0; margin-bottom: 10px; font-size: 0.9em; color: #666;">ADDED ROWS (New in this run)</h5>
1268
+ <div style="overflow-x: auto; margin-bottom: 20px; border: 1px solid #eee;">
1269
+ <table style="margin: 0;">
1270
+ <thead>
1271
+ <tr>
1272
+ {% for key in node.data_diff.sample_added[0].keys() %}<th>{{ key }}</th>{% endfor %}
1273
+ </tr>
1274
+ </thead>
1275
+ <tbody>
1276
+ {% for row in node.data_diff.sample_added %}
1277
+ <tr class="diff-added">
1278
+ {% for val in row.values() %}<td>{{ val }}</td>{% endfor %}
1279
+ </tr>
1280
+ {% endfor %}
1281
+ </tbody>
1282
+ </table>
1283
+ </div>
1284
+ {% endif %}
1285
+
1286
+ {% if node.data_diff.sample_removed %}
1287
+ <h5 style="margin-top: 0; margin-bottom: 10px; font-size: 0.9em; color: #666;">REMOVED ROWS (Existed in previous run, gone now)</h5>
1288
+ <div style="overflow-x: auto; border: 1px solid #eee;">
1289
+ <table style="margin: 0;">
1290
+ <thead>
1291
+ <tr>
1292
+ {% for key in node.data_diff.sample_removed[0].keys() %}<th>{{ key }}</th>{% endfor %}
1293
+ </tr>
1294
+ </thead>
1295
+ <tbody>
1296
+ {% for row in node.data_diff.sample_removed %}
1297
+ <tr class="diff-removed">
1298
+ {% for val in row.values() %}<td>{{ val }}</td>{% endfor %}
1299
+ </tr>
1300
+ {% endfor %}
1301
+ </tbody>
1302
+ </table>
1303
+ </div>
1304
+ {% endif %}
1305
+ </div> <!-- End padding container -->
1306
+ </div>
1307
+ {% endif %}
1308
+
1309
+ {% if node.sample_in %}
1310
+ <h4>Input Sample</h4>
1311
+ <div style="overflow-x: auto; margin-bottom: 20px;">
1312
+ <table>
1313
+ <thead>
1314
+ <tr>
1315
+ {% if node.sample_in|length > 0 %}
1316
+ {% for key in node.sample_in[0].keys() %}<th>{{ key }}</th>{% endfor %}
1317
+ {% endif %}
1318
+ </tr>
1319
+ </thead>
1320
+ <tbody>
1321
+ {% for row in node.sample_in %}
1322
+ <tr>
1323
+ {% for val in row.values() %}<td>{{ val }}</td>{% endfor %}
1324
+ </tr>
1325
+ {% endfor %}
1326
+ </tbody>
1327
+ </table>
1328
+ </div>
1329
+ {% endif %}
1330
+
1331
+ {% if node.sample_data %}
1332
+ <h4>Output Sample</h4>
1333
+ <div style="overflow-x: auto;">
1334
+ <table>
1335
+ <thead>
1336
+ <tr>
1337
+ {% if node.sample_data|length > 0 %}
1338
+ {% for key in node.sample_data[0].keys() %}<th>{{ key }}</th>{% endfor %}
1339
+ {% endif %}
1340
+ </tr>
1341
+ </thead>
1342
+ <tbody>
1343
+ {% for row in node.sample_data %}
1344
+ <tr>
1345
+ {% for val in row.values() %}<td>{{ val }}</td>{% endfor %}
1346
+ </tr>
1347
+ {% endfor %}
1348
+ </tbody>
1349
+ </table>
1350
+ </div>
1351
+ {% else %}
1352
+ <p>No sample data available.</p>
1353
+ {% endif %}
1354
+ </div>
1355
+ {% endif %}
1356
+
1357
+ <!-- Tab: Config -->
1358
+ {% if node.config_snapshot %}
1359
+ <div id="config-{{ loop.index }}" class="tab-content">
1360
+ {% if node.environment %}
1361
+ <div style="margin-bottom: 20px; padding: 15px; background: #f8f9fa; border-radius: 6px; border: 1px solid #e1e4e8;">
1362
+ <h5 style="margin-top: 0; margin-bottom: 10px; color: #666;">Execution Environment</h5>
1363
+ <div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 10px; font-size: 0.9em;">
1364
+ <div><span style="color: #999;">Host:</span> <strong>{{ node.environment.host }}</strong></div>
1365
+ <div><span style="color: #999;">User:</span> <strong>{{ node.environment.user }}</strong></div>
1366
+ <div><span style="color: #999;">Python:</span> <strong>{{ node.environment.python }}</strong></div>
1367
+ <div><span style="color: #999;">Platform:</span> <strong>{{ node.environment.platform }}</strong></div>
1368
+ {% if node.environment.pandas %}<div><span style="color: #999;">Pandas:</span> <strong>{{ node.environment.pandas }}</strong></div>{% endif %}
1369
+ {% if node.environment.pyspark %}<div><span style="color: #999;">PySpark:</span> <strong>{{ node.environment.pyspark }}</strong></div>{% endif %}
1370
+ {% if node.environment.odibi %}<div><span style="color: #999;">Odibi:</span> <strong>{{ node.environment.odibi }}</strong></div>{% endif %}
1371
+ </div>
1372
+ </div>
1373
+ {% endif %}
1374
+
1375
+ {% if node.previous_config_snapshot %}
1376
+ <!-- Config Diff Viewer -->
1377
+ <div style="margin-bottom: 20px;">
1378
+ <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
1379
+ <h4 style="margin: 0; color: #7c3aed; display: flex; align-items: center; gap: 8px;">
1380
+ <span>📋</span> Config Changes vs Last Run
1381
+ </h4>
1382
+ <button class="copy-btn" onclick="toggleConfigDiffView({{ loop.index }})" id="config-diff-toggle-{{ loop.index }}">
1383
+ Show Side-by-Side
1384
+ </button>
1385
+ </div>
1386
+
1387
+ <!-- Inline Diff View (default) -->
1388
+ <div id="config-diff-inline-{{ loop.index }}" class="config-diff-inline">
1389
+ <pre id="config-diff-output-{{ loop.index }}" style="padding: 15px; max-height: 400px; overflow-y: auto;"></pre>
1390
+ </div>
1391
+
1392
+ <!-- Side-by-Side View (hidden by default) -->
1393
+ <div id="config-diff-sidebyside-{{ loop.index }}" class="config-diff-container" style="display: none;">
1394
+ <div class="config-diff-pane">
1395
+ <div class="config-diff-header previous">Previous Run</div>
1396
+ <div class="config-diff-content">
1397
+ <pre style="margin: 0; background: none; padding: 0;">{{ node.previous_config_snapshot | to_yaml }}</pre>
1398
+ </div>
1399
+ </div>
1400
+ <div class="config-diff-pane">
1401
+ <div class="config-diff-header current">Current Run</div>
1402
+ <div class="config-diff-content">
1403
+ <pre style="margin: 0; background: none; padding: 0;">{{ node.config_snapshot | to_yaml }}</pre>
1404
+ </div>
1405
+ </div>
1406
+ </div>
1407
+
1408
+ <script>
1409
+ (function() {
1410
+ const prev = {{ node.previous_config_snapshot | tojson | safe }};
1411
+ const curr = {{ node.config_snapshot | tojson | safe }};
1412
+ const outputEl = document.getElementById('config-diff-output-{{ loop.index }}');
1413
+ if (outputEl) {
1414
+ outputEl.innerHTML = generateConfigDiff(prev, curr);
1415
+ }
1416
+ })();
1417
+ </script>
1418
+ </div>
1419
+ <hr style="border: none; border-top: 1px solid var(--border-color); margin: 20px 0;">
1420
+ <h4 style="margin: 0 0 10px 0; color: #666;">Current Configuration</h4>
1421
+ {% endif %}
1422
+
1423
+ <div style="position: relative;">
1424
+ <button class="copy-btn" style="position: absolute; top: 8px; right: 8px; z-index: 1;" onclick="copyConfig(this, '{{ node.node_name }}'); event.stopPropagation();">📋 Copy YAML</button>
1425
+ <pre style="padding-right: 90px;" id="config-yaml-{{ loop.index }}">{{ node.config_snapshot | to_yaml }}</pre>
1426
+ </div>
1427
+ </div>
1428
+ {% endif %}
1429
+
1430
+ </div>
1431
+ </div>
1432
+ {% endfor %}
1433
+ </div>
1434
+
1435
+ <div style="text-align: center; margin-top: 40px; color: #666; font-size: 0.9em; font-style: italic;">
1436
+ "Where others saw gaps, I built bridges."
1437
+ </div>
1438
+ <div style="text-align: center; margin-top: 8px; color: #888; font-size: 0.8em;">
1439
+ Odibi v{{ odibi_version }} · Henry Odibi
1440
+ <svg style="vertical-align: middle; margin-left: 4px;" width="20" height="14" viewBox="0 0 3 2">
1441
+ <rect width="1" height="2" x="0" fill="#008751"/>
1442
+ <rect width="1" height="2" x="1" fill="#ffffff"/>
1443
+ <rect width="1" height="2" x="2" fill="#008751"/>
1444
+ </svg>
1445
+ </div>
1446
+ </div>
1447
+
1448
+ <script>
1449
+ // ========================================
1450
+ // Config Diff Functions
1451
+ // ========================================
1452
+
1453
+ function generateConfigDiff(prev, curr) {
1454
+ const prevYaml = objectToYamlLines(prev);
1455
+ const currYaml = objectToYamlLines(curr);
1456
+
1457
+ const diff = computeLineDiff(prevYaml, currYaml);
1458
+ return diff.map(line => {
1459
+ if (line.type === 'added') {
1460
+ return `<span class="diff-line-added">+ ${escapeHtml(line.text)}</span>`;
1461
+ } else if (line.type === 'removed') {
1462
+ return `<span class="diff-line-removed">- ${escapeHtml(line.text)}</span>`;
1463
+ } else {
1464
+ return ` ${escapeHtml(line.text)}`;
1465
+ }
1466
+ }).join('\n');
1467
+ }
1468
+
1469
+ function objectToYamlLines(obj, indent = 0) {
1470
+ const lines = [];
1471
+ const prefix = ' '.repeat(indent);
1472
+
1473
+ if (obj === null || obj === undefined) {
1474
+ return ['null'];
1475
+ }
1476
+
1477
+ if (typeof obj !== 'object') {
1478
+ return [String(obj)];
1479
+ }
1480
+
1481
+ if (Array.isArray(obj)) {
1482
+ obj.forEach(item => {
1483
+ if (typeof item === 'object' && item !== null) {
1484
+ lines.push(`${prefix}-`);
1485
+ objectToYamlLines(item, indent + 1).forEach(l => lines.push(l));
1486
+ } else {
1487
+ lines.push(`${prefix}- ${item}`);
1488
+ }
1489
+ });
1490
+ } else {
1491
+ Object.keys(obj).sort().forEach(key => {
1492
+ const val = obj[key];
1493
+ if (val === null || val === undefined) {
1494
+ lines.push(`${prefix}${key}: null`);
1495
+ } else if (typeof val === 'object') {
1496
+ lines.push(`${prefix}${key}:`);
1497
+ objectToYamlLines(val, indent + 1).forEach(l => lines.push(l));
1498
+ } else {
1499
+ lines.push(`${prefix}${key}: ${val}`);
1500
+ }
1501
+ });
1502
+ }
1503
+ return lines;
1504
+ }
1505
+
1506
+ function computeLineDiff(oldLines, newLines) {
1507
+ const result = [];
1508
+ const oldSet = new Set(oldLines);
1509
+ const newSet = new Set(newLines);
1510
+
1511
+ // Find removed lines
1512
+ oldLines.forEach(line => {
1513
+ if (!newSet.has(line)) {
1514
+ result.push({ type: 'removed', text: line });
1515
+ }
1516
+ });
1517
+
1518
+ // Find added and unchanged lines
1519
+ newLines.forEach(line => {
1520
+ if (!oldSet.has(line)) {
1521
+ result.push({ type: 'added', text: line });
1522
+ } else {
1523
+ result.push({ type: 'unchanged', text: line });
1524
+ }
1525
+ });
1526
+
1527
+ // Sort: removed first, then unchanged/added in order
1528
+ const removed = result.filter(r => r.type === 'removed');
1529
+ const rest = result.filter(r => r.type !== 'removed');
1530
+ return [...removed, ...rest];
1531
+ }
1532
+
1533
+ function escapeHtml(text) {
1534
+ const div = document.createElement('div');
1535
+ div.textContent = text;
1536
+ return div.innerHTML;
1537
+ }
1538
+
1539
+ function toggleConfigDiffView(index) {
1540
+ const inline = document.getElementById('config-diff-inline-' + index);
1541
+ const sideBySide = document.getElementById('config-diff-sidebyside-' + index);
1542
+ const btn = document.getElementById('config-diff-toggle-' + index);
1543
+
1544
+ if (inline && sideBySide && btn) {
1545
+ if (sideBySide.style.display === 'none') {
1546
+ sideBySide.style.display = 'grid';
1547
+ inline.style.display = 'none';
1548
+ btn.textContent = 'Show Inline Diff';
1549
+ } else {
1550
+ sideBySide.style.display = 'none';
1551
+ inline.style.display = 'block';
1552
+ btn.textContent = 'Show Side-by-Side';
1553
+ }
1554
+ }
1555
+ }
1556
+
1557
+ // ========================================
1558
+ // Duration Sparkline Functions
1559
+ // ========================================
1560
+
1561
+ function renderSparkline(data, width = 60, height = 16) {
1562
+ if (!data || data.length < 2) return '';
1563
+
1564
+ const durations = data.map(d => d.duration).reverse(); // Oldest to newest
1565
+ const min = Math.min(...durations);
1566
+ const max = Math.max(...durations);
1567
+ const range = max - min || 1;
1568
+
1569
+ const points = durations.map((d, i) => {
1570
+ const x = (i / (durations.length - 1)) * width;
1571
+ const y = height - ((d - min) / range) * (height - 2) - 1;
1572
+ return `${x},${y}`;
1573
+ }).join(' ');
1574
+
1575
+ const lastDuration = durations[durations.length - 1];
1576
+ const avgDuration = durations.reduce((a, b) => a + b, 0) / durations.length;
1577
+ const trend = lastDuration > avgDuration * 1.5 ? '#dc3545' :
1578
+ lastDuration < avgDuration * 0.5 ? '#28a745' : '#6c757d';
1579
+
1580
+ return `<svg width="${width}" height="${height}" style="vertical-align: middle;">
1581
+ <polyline points="${points}" fill="none" stroke="${trend}" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
1582
+ <circle cx="${width}" cy="${height - ((lastDuration - min) / range) * (height - 2) - 1}" r="2" fill="${trend}"/>
1583
+ </svg>`;
1584
+ }
1585
+
1586
+ function initSparklines() {
1587
+ document.querySelectorAll('.sparkline[data-history]').forEach(el => {
1588
+ try {
1589
+ const history = JSON.parse(el.dataset.history);
1590
+ if (history && history.length > 1) {
1591
+ el.innerHTML = renderSparkline(history);
1592
+ }
1593
+ } catch (e) {
1594
+ console.debug('Failed to render sparkline:', e);
1595
+ }
1596
+ });
1597
+ }
1598
+
1599
+ // Toggle node card open/closed
1600
+ function toggleNode(header) {
1601
+ const body = header.nextElementSibling;
1602
+ body.classList.toggle('open');
1603
+ }
1604
+
1605
+ // Tab switching within a node
1606
+ function openTab(evt, tabId) {
1607
+ var nodeBody = evt.currentTarget.closest('.node-body');
1608
+ var tabContents = nodeBody.getElementsByClassName("tab-content");
1609
+ for (var i = 0; i < tabContents.length; i++) {
1610
+ tabContents[i].classList.remove("active");
1611
+ }
1612
+ var tabBtns = nodeBody.getElementsByClassName("tab-btn");
1613
+ for (var i = 0; i < tabBtns.length; i++) {
1614
+ tabBtns[i].classList.remove("active");
1615
+ }
1616
+ document.getElementById(tabId).classList.add("active");
1617
+ evt.currentTarget.classList.add("active");
1618
+ evt.stopPropagation();
1619
+ }
1620
+
1621
+ // Scroll to a specific node card - exposed on window for dynamic onclick handlers
1622
+ window.scrollToNode = function(nodeName) {
1623
+ // Handle both 'node-xxx' and 'xxx' formats
1624
+ const cleanName = nodeName && nodeName.startsWith('node-') ? nodeName.substring(5) : nodeName;
1625
+ const targetId = 'node-' + cleanName;
1626
+
1627
+ // Use querySelector with .node-card class to avoid matching SVG elements
1628
+ const nodeCard = document.querySelector('div.node-card#' + CSS.escape(targetId));
1629
+
1630
+ if (nodeCard) {
1631
+ nodeCard.scrollIntoView({ behavior: 'smooth', block: 'start' });
1632
+ // Flash highlight effect
1633
+ nodeCard.classList.add('flash');
1634
+ setTimeout(() => nodeCard.classList.remove('flash'), 1500);
1635
+ // Auto-expand the node
1636
+ const body = nodeCard.querySelector('.node-body');
1637
+ if (body && !body.classList.contains('open')) {
1638
+ body.classList.add('open');
1639
+ }
1640
+ }
1641
+ };
1642
+
1643
+ // Filter nodes by status
1644
+ function filterNodes(filter, btn) {
1645
+ // Update active button
1646
+ document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
1647
+ btn.classList.add('active');
1648
+
1649
+ // Get all node cards (skip the pipeline graph card)
1650
+ const nodeCards = document.querySelectorAll('.node-card[data-status]');
1651
+ nodeCards.forEach(card => {
1652
+ const status = card.dataset.status;
1653
+ const isAnomaly = card.dataset.anomaly === 'true';
1654
+
1655
+ const isChanged = card.dataset.changed === 'true';
1656
+
1657
+ if (filter === 'all') {
1658
+ card.style.display = '';
1659
+ } else if (filter === 'failed') {
1660
+ card.style.display = status === 'failed' ? '' : 'none';
1661
+ } else if (filter === 'anomalous') {
1662
+ card.style.display = isAnomaly ? '' : 'none';
1663
+ } else if (filter === 'changed') {
1664
+ card.style.display = isChanged ? '' : 'none';
1665
+ }
1666
+ });
1667
+ }
1668
+
1669
+ // Copy text to clipboard with button feedback
1670
+ function copyToClipboard(btn, text) {
1671
+ navigator.clipboard.writeText(text).then(() => {
1672
+ btn.classList.add('copied');
1673
+ const originalText = btn.innerHTML;
1674
+ btn.innerHTML = '✓ Copied!';
1675
+ setTimeout(() => {
1676
+ btn.classList.remove('copied');
1677
+ btn.innerHTML = originalText;
1678
+ }, 2000);
1679
+ }).catch(err => {
1680
+ console.error('Failed to copy:', err);
1681
+ alert('Failed to copy to clipboard');
1682
+ });
1683
+ }
1684
+
1685
+ // Copy all SQL for a node
1686
+ function copyAllSQL(btn, nodeName) {
1687
+ const nodeCard = document.getElementById('node-' + nodeName);
1688
+ const sqlBlocks = nodeCard.querySelectorAll('[id^="sql-"][id$="-"]');
1689
+ let allSQL = [];
1690
+ sqlBlocks.forEach(block => {
1691
+ if (block.tagName === 'PRE') {
1692
+ allSQL.push(block.textContent);
1693
+ }
1694
+ });
1695
+ // Fallback: get from details > pre
1696
+ if (allSQL.length === 0) {
1697
+ nodeCard.querySelectorAll('.tab-content details pre').forEach(pre => {
1698
+ allSQL.push(pre.textContent);
1699
+ });
1700
+ }
1701
+ copyToClipboard(btn, allSQL.join('\n\n-- Next Statement --\n\n'));
1702
+ }
1703
+
1704
+ // Copy error info for a node
1705
+ function copyError(btn, nodeName) {
1706
+ const nodeCard = document.getElementById('node-' + nodeName);
1707
+ const errorPre = nodeCard.querySelector('[id^="error-"]');
1708
+ const tracebackCleaned = nodeCard.querySelector('[id^="traceback-cleaned-"]');
1709
+ let errorText = errorPre ? errorPre.textContent : '';
1710
+ if (tracebackCleaned) {
1711
+ errorText += '\n\n--- Traceback ---\n' + tracebackCleaned.textContent;
1712
+ }
1713
+ copyToClipboard(btn, errorText);
1714
+ }
1715
+
1716
+ // Copy config YAML for a node
1717
+ function copyConfig(btn, nodeName) {
1718
+ const nodeCard = document.getElementById('node-' + nodeName);
1719
+ const configPre = nodeCard.querySelector('[id^="config-yaml-"]');
1720
+ if (configPre) {
1721
+ copyToClipboard(btn, configPre.textContent);
1722
+ }
1723
+ }
1724
+
1725
+ // Auto-expand failed nodes on page load
1726
+ document.addEventListener('DOMContentLoaded', function() {
1727
+ // Find all failed node cards and expand them
1728
+ document.querySelectorAll('.node-card[data-status="failed"]').forEach(card => {
1729
+ const body = card.querySelector('.node-body');
1730
+ if (body) {
1731
+ body.classList.add('open');
1732
+ }
1733
+ });
1734
+
1735
+ // Also expand anomalous nodes
1736
+ document.querySelectorAll('.node-card[data-anomaly="true"]').forEach(card => {
1737
+ const body = card.querySelector('.node-body');
1738
+ if (body) {
1739
+ body.classList.add('open');
1740
+ }
1741
+ });
1742
+
1743
+ // Initialize Graphviz graph
1744
+ initGraphvizGraph();
1745
+
1746
+ // Initialize dark mode from localStorage
1747
+ initDarkMode();
1748
+
1749
+ // Localize timestamps
1750
+ localizeTimestamps();
1751
+
1752
+ // Initialize view mode from localStorage
1753
+ initViewMode();
1754
+
1755
+ // Initialize duration sparklines
1756
+ initSparklines();
1757
+ });
1758
+
1759
+ // Dark Mode (Phase 4)
1760
+ function initDarkMode() {
1761
+ const savedTheme = localStorage.getItem('odibi-story-theme');
1762
+ }
1763
+
1764
+ // Collapsible Sections
1765
+ function toggleSection(sectionId) {
1766
+ const header = document.querySelector(`#${sectionId === 'timeline' ? 'execution-timeline' : 'pipeline-graph-card'} .collapsible-header`);
1767
+ const content = document.getElementById(`${sectionId}-content`);
1768
+
1769
+ if (header && content) {
1770
+ const isCollapsed = header.classList.toggle('collapsed');
1771
+ content.classList.toggle('collapsed', isCollapsed);
1772
+
1773
+ // Save state to localStorage
1774
+ localStorage.setItem(`odibi-section-${sectionId}`, isCollapsed ? 'collapsed' : 'expanded');
1775
+
1776
+ // If expanding pipeline, re-render graph to fix sizing
1777
+ if (sectionId === 'pipeline' && !isCollapsed) {
1778
+ setTimeout(() => renderGraphviz('LR'), 100);
1779
+ }
1780
+ // If expanding timeline, re-render timeline
1781
+ if (sectionId === 'timeline' && !isCollapsed) {
1782
+ setTimeout(renderExecutionTimeline, 100);
1783
+ }
1784
+ }
1785
+ }
1786
+
1787
+ function initCollapsibleSections() {
1788
+ // Timeline: default collapsed, Pipeline: default expanded
1789
+ const timelineState = localStorage.getItem('odibi-section-timeline') || 'collapsed';
1790
+ const pipelineState = localStorage.getItem('odibi-section-pipeline') || 'expanded';
1791
+
1792
+ const timelineHeader = document.querySelector('#execution-timeline .collapsible-header');
1793
+ const timelineContent = document.getElementById('timeline-content');
1794
+ const pipelineHeader = document.querySelector('#pipeline-graph-card .collapsible-header');
1795
+ const pipelineContent = document.getElementById('pipeline-content');
1796
+
1797
+ if (timelineHeader && timelineContent) {
1798
+ if (timelineState === 'collapsed') {
1799
+ timelineHeader.classList.add('collapsed');
1800
+ timelineContent.classList.add('collapsed');
1801
+ } else {
1802
+ timelineHeader.classList.remove('collapsed');
1803
+ timelineContent.classList.remove('collapsed');
1804
+ }
1805
+ }
1806
+
1807
+ if (pipelineHeader && pipelineContent) {
1808
+ if (pipelineState === 'collapsed') {
1809
+ pipelineHeader.classList.add('collapsed');
1810
+ pipelineContent.classList.add('collapsed');
1811
+ } else {
1812
+ pipelineHeader.classList.remove('collapsed');
1813
+ pipelineContent.classList.remove('collapsed');
1814
+ }
1815
+ }
1816
+ }
1817
+
1818
+ // Timestamp Localizer (Phase 4)
1819
+ function localizeTimestamps() {
1820
+ document.querySelectorAll('.timestamp').forEach(el => {
1821
+ const utc = el.dataset.utc;
1822
+ if (utc) {
1823
+ try {
1824
+ const date = new Date(utc);
1825
+ if (!isNaN(date.getTime())) {
1826
+ el.textContent = date.toLocaleString();
1827
+ el.title = utc + ' (UTC)';
1828
+ }
1829
+ } catch (e) {
1830
+ console.debug('Could not parse timestamp:', utc);
1831
+ }
1832
+ }
1833
+ });
1834
+ }
1835
+
1836
+ // View Mode (Phase 4)
1837
+ function initViewMode() {
1838
+ const savedMode = localStorage.getItem('odibi-story-view-mode') || 'developer';
1839
+ document.getElementById('view-mode').value = savedMode;
1840
+ applyViewMode(savedMode);
1841
+ }
1842
+
1843
+ function setViewMode(mode) {
1844
+ localStorage.setItem('odibi-story-view-mode', mode);
1845
+ applyViewMode(mode);
1846
+ }
1847
+
1848
+ function applyViewMode(mode) {
1849
+ const developerElements = document.querySelectorAll('.tab-btn[onclick*="sql-"], .tab-btn[onclick*="config-"], details:has(pre), .error-traceback');
1850
+ const tracebackDetails = document.querySelectorAll('details:has([id^="traceback"])');
1851
+
1852
+ if (mode === 'stakeholder') {
1853
+ // Hide SQL tabs, config tabs, and tracebacks
1854
+ document.querySelectorAll('.tabs').forEach(tabs => {
1855
+ tabs.querySelectorAll('.tab-btn').forEach(btn => {
1856
+ const onclick = btn.getAttribute('onclick') || '';
1857
+ if (onclick.includes('sql-') || onclick.includes('config-')) {
1858
+ btn.style.display = 'none';
1859
+ }
1860
+ });
1861
+ });
1862
+ tracebackDetails.forEach(el => el.style.display = 'none');
1863
+ } else {
1864
+ // Show all tabs
1865
+ document.querySelectorAll('.tabs .tab-btn').forEach(btn => {
1866
+ btn.style.display = '';
1867
+ });
1868
+ tracebackDetails.forEach(el => el.style.display = '');
1869
+ }
1870
+ }
1871
+
1872
+ // Graph data from template
1873
+ const graphData = {{ metadata.graph_data | tojson | safe if metadata.graph_data else '{"nodes":[],"edges":[]}' }};
1874
+
1875
+ // Status colors for Graphviz nodes
1876
+ const statusColors = {
1877
+ success: { fill: '#e6fffa', border: '#0ca678' },
1878
+ failed: { fill: '#fff5f5', border: '#e03131' },
1879
+ skipped: { fill: '#f8f9fa', border: '#adb5bd' },
1880
+ unknown: { fill: '#f8f9fa', border: '#868e96' },
1881
+ external: { fill: '#e7f5ff', border: '#1c7ed6' } // Blue for cross-pipeline dependencies
1882
+ };
1883
+
1884
+ // Generate DOT syntax from graph data
1885
+ function generateDOT(rankdir = 'LR') {
1886
+ if (!graphData.nodes || graphData.nodes.length === 0) {
1887
+ return 'digraph { node [label="No data"] }';
1888
+ }
1889
+
1890
+ let dot = `digraph pipeline {
1891
+ rankdir=${rankdir};
1892
+ bgcolor="transparent";
1893
+ node [shape=box, style="filled,rounded", fontname="Segoe UI, Arial", fontsize=11, margin="0.2,0.1"];
1894
+ edge [color="#adb5bd", arrowsize=0.7];
1895
+ nodesep=0.4;
1896
+ ranksep=0.6;
1897
+ `;
1898
+
1899
+ // Add nodes with status-based colors
1900
+ graphData.nodes.forEach(node => {
1901
+ const colors = statusColors[node.status] || statusColors.unknown;
1902
+ const label = (node.label || node.id).replace(/"/g, '\\"');
1903
+ // Truncate long names
1904
+ const displayLabel = label.length > 25 ? label.substring(0, 22) + '...' : label;
1905
+ // Use different shape for external (cross-pipeline) nodes
1906
+ const shape = node.is_external ? 'ellipse' : 'box';
1907
+ const style = node.is_external ? 'filled,dashed' : 'filled,rounded';
1908
+ const externalLabel = node.is_external ? `↗ ${displayLabel}` : displayLabel;
1909
+
1910
+ // Build tooltip with dependencies info
1911
+ let tooltipText = label;
1912
+ if (node.dependencies && node.dependencies.length > 0) {
1913
+ const depsStr = node.dependencies.join(', ');
1914
+ tooltipText += `\\nDepends on: ${depsStr}`;
1915
+ }
1916
+ if (node.is_external && node.source_pipeline) {
1917
+ tooltipText += `\\nFrom: ${node.source_pipeline}`;
1918
+ }
1919
+
1920
+ dot += ` "${node.id}" [label="${externalLabel}", shape=${shape}, style="${style}", fillcolor="${colors.fill}", color="${colors.border}", penwidth=2, tooltip="${tooltipText}", id="dag-${node.id}", class="dag-node"];\n`;
1921
+ });
1922
+
1923
+ // Add edges
1924
+ graphData.edges.forEach(edge => {
1925
+ dot += ` "${edge.source}" -> "${edge.target}";\n`;
1926
+ });
1927
+
1928
+ dot += '}';
1929
+ return dot;
1930
+ }
1931
+
1932
+ // Render graph using Graphviz
1933
+ async function renderGraphviz(rankdir = 'LR') {
1934
+ const container = document.getElementById('graphviz-container');
1935
+ if (!container) return;
1936
+
1937
+ if (!graphData.nodes || graphData.nodes.length === 0) {
1938
+ container.innerHTML = '<div style="display: flex; align-items: center; justify-content: center; height: 300px; color: #666;">No graph data available</div>';
1939
+ return;
1940
+ }
1941
+
1942
+ container.innerHTML = '<div style="display: flex; align-items: center; justify-content: center; height: 300px; color: #666;">Loading graph...</div>';
1943
+
1944
+ try {
1945
+ const graphviz = await window["@hpcc-js/wasm"].Graphviz.load();
1946
+ const dot = generateDOT(rankdir);
1947
+ const svg = graphviz.dot(dot);
1948
+ container.innerHTML = svg;
1949
+
1950
+ // Make SVG responsive
1951
+ const svgEl = container.querySelector('svg');
1952
+ if (svgEl) {
1953
+ svgEl.style.maxWidth = '100%';
1954
+ svgEl.style.height = 'auto';
1955
+ svgEl.style.minHeight = '400px';
1956
+
1957
+ // Add click and hover handlers to nodes (Graphviz uses .node class for node groups)
1958
+ container.querySelectorAll('.node').forEach(nodeEl => {
1959
+ nodeEl.style.cursor = 'pointer';
1960
+
1961
+ // Click to navigate
1962
+ nodeEl.addEventListener('click', function(e) {
1963
+ const titleEl = this.querySelector('title');
1964
+ if (titleEl) {
1965
+ const nodeId = titleEl.textContent.trim();
1966
+ window.scrollToNode(nodeId);
1967
+ highlightNodeInGraph(nodeId);
1968
+ }
1969
+ });
1970
+
1971
+ // Hover to show tooltip
1972
+ nodeEl.addEventListener('mouseenter', function(e) {
1973
+ const titleEl = this.querySelector('title');
1974
+ if (titleEl) {
1975
+ const nodeId = titleEl.textContent.trim();
1976
+ showDagTooltip(nodeId, e);
1977
+ }
1978
+ });
1979
+
1980
+ nodeEl.addEventListener('mousemove', function(e) {
1981
+ moveDagTooltip(e);
1982
+ });
1983
+
1984
+ nodeEl.addEventListener('mouseleave', function(e) {
1985
+ hideDagTooltip();
1986
+ });
1987
+ });
1988
+ }
1989
+ } catch (error) {
1990
+ console.error('Graphviz error:', error);
1991
+ container.innerHTML = '<div style="display: flex; align-items: center; justify-content: center; height: 300px; color: #e03131;">Error rendering graph: ' + error.message + '</div>';
1992
+ }
1993
+ }
1994
+
1995
+ // Highlight a node in the SVG graph
1996
+ function highlightNodeInGraph(nodeId) {
1997
+ const container = document.getElementById('graphviz-container');
1998
+ if (!container) return;
1999
+
2000
+ // Reset all nodes
2001
+ container.querySelectorAll('.node polygon, .node path, .node ellipse').forEach(el => {
2002
+ el.style.strokeWidth = '';
2003
+ el.style.stroke = '';
2004
+ });
2005
+
2006
+ // Find and highlight selected node by matching title content
2007
+ container.querySelectorAll('.node').forEach(nodeEl => {
2008
+ const titleEl = nodeEl.querySelector('title');
2009
+ if (titleEl && titleEl.textContent === nodeId) {
2010
+ const shape = nodeEl.querySelector('polygon, path, ellipse');
2011
+ if (shape) {
2012
+ shape.style.strokeWidth = '4';
2013
+ shape.style.stroke = '#0066cc';
2014
+ }
2015
+ }
2016
+ });
2017
+ }
2018
+
2019
+ // Tooltip functions for DAG nodes
2020
+ function showDagTooltip(nodeId, event) {
2021
+ const tooltip = document.getElementById('dag-tooltip');
2022
+ if (!tooltip || !graphData.nodes) return;
2023
+
2024
+ // Find node data
2025
+ const node = graphData.nodes.find(n => n.id === nodeId);
2026
+ if (!node) return;
2027
+
2028
+ // Build tooltip HTML
2029
+ let html = `<h4>${node.label || node.id}`;
2030
+ if (node.is_external) {
2031
+ html += `<span class="external-badge">External</span>`;
2032
+ }
2033
+ html += `</h4>`;
2034
+
2035
+ // Status
2036
+ const statusColors = {
2037
+ success: '#0ca678',
2038
+ failed: '#e03131',
2039
+ skipped: '#adb5bd',
2040
+ external: '#1c7ed6'
2041
+ };
2042
+ const statusColor = statusColors[node.status] || '#666';
2043
+ html += `<div class="tooltip-row">
2044
+ <span class="tooltip-label">Status:</span>
2045
+ <span class="tooltip-value" style="color: ${statusColor}; font-weight: 500;">${node.status}</span>
2046
+ </div>`;
2047
+
2048
+ // Duration (if not external)
2049
+ if (!node.is_external && node.duration !== undefined) {
2050
+ html += `<div class="tooltip-row">
2051
+ <span class="tooltip-label">Duration:</span>
2052
+ <span class="tooltip-value">${node.duration.toFixed(2)}s</span>
2053
+ </div>`;
2054
+ }
2055
+
2056
+ // Rows Read (if available)
2057
+ if (node.rows_in !== null && node.rows_in !== undefined) {
2058
+ html += `<div class="tooltip-row">
2059
+ <span class="tooltip-label">Rows Read:</span>
2060
+ <span class="tooltip-value">${node.rows_in.toLocaleString()}</span>
2061
+ </div>`;
2062
+ }
2063
+
2064
+ // Rows Written (if available)
2065
+ if (node.rows_written !== null && node.rows_written !== undefined) {
2066
+ const noChanges = node.rows_in && node.rows_written === 0 && node.rows_in > 0 ? ' (no changes)' : '';
2067
+ html += `<div class="tooltip-row">
2068
+ <span class="tooltip-label">Rows Written:</span>
2069
+ <span class="tooltip-value">${node.rows_written.toLocaleString()}${noChanges}</span>
2070
+ </div>`;
2071
+ } else if (node.rows_out !== null && node.rows_out !== undefined) {
2072
+ html += `<div class="tooltip-row">
2073
+ <span class="tooltip-label">Rows Out:</span>
2074
+ <span class="tooltip-value">${node.rows_out.toLocaleString()}</span>
2075
+ </div>`;
2076
+ }
2077
+
2078
+ // Source pipeline (for external nodes)
2079
+ if (node.is_external && node.source_pipeline) {
2080
+ html += `<div class="tooltip-row">
2081
+ <span class="tooltip-label">From:</span>
2082
+ <span class="tooltip-value">${node.source_pipeline}</span>
2083
+ </div>`;
2084
+ }
2085
+
2086
+ // Dependencies
2087
+ if (node.dependencies && node.dependencies.length > 0) {
2088
+ html += `<div class="tooltip-row" style="flex-direction: column;">
2089
+ <span class="tooltip-label">Depends on:</span>
2090
+ <ul class="deps-list">`;
2091
+ node.dependencies.forEach(dep => {
2092
+ html += `<li>${dep}</li>`;
2093
+ });
2094
+ html += `</ul></div>`;
2095
+ }
2096
+
2097
+ tooltip.innerHTML = html;
2098
+ tooltip.classList.add('visible');
2099
+ moveDagTooltip(event);
2100
+ }
2101
+
2102
+ function moveDagTooltip(event) {
2103
+ const tooltip = document.getElementById('dag-tooltip');
2104
+ if (!tooltip) return;
2105
+
2106
+ // Position tooltip near cursor but not overlapping
2107
+ let x = event.clientX + 15;
2108
+ let y = event.clientY + 15;
2109
+
2110
+ // Keep tooltip within viewport
2111
+ const rect = tooltip.getBoundingClientRect();
2112
+ if (x + rect.width > window.innerWidth - 10) {
2113
+ x = event.clientX - rect.width - 15;
2114
+ }
2115
+ if (y + rect.height > window.innerHeight - 10) {
2116
+ y = event.clientY - rect.height - 15;
2117
+ }
2118
+
2119
+ tooltip.style.left = x + 'px';
2120
+ tooltip.style.top = y + 'px';
2121
+ }
2122
+
2123
+ function hideDagTooltip() {
2124
+ const tooltip = document.getElementById('dag-tooltip');
2125
+ if (tooltip) {
2126
+ tooltip.classList.remove('visible');
2127
+ }
2128
+ }
2129
+
2130
+ // Initialize Graphviz graph on page load
2131
+ function initGraphvizGraph() {
2132
+ renderGraphviz('LR');
2133
+ }
2134
+
2135
+ // Switch between DAG and List view
2136
+ function changeGraphViewType(viewType) {
2137
+ const graphContainer = document.getElementById('graphviz-container');
2138
+ const listView = document.getElementById('pipeline-list-view');
2139
+
2140
+ if (viewType === 'list') {
2141
+ graphContainer.style.display = 'none';
2142
+ listView.style.display = 'block';
2143
+ renderListView();
2144
+ } else {
2145
+ graphContainer.style.display = 'block';
2146
+ listView.style.display = 'none';
2147
+ renderGraphviz('LR');
2148
+ }
2149
+ }
2150
+
2151
+ // Render pipeline as a readable list with dependencies
2152
+ function renderListView() {
2153
+ const listView = document.getElementById('pipeline-list-view');
2154
+ if (!graphData.nodes || graphData.nodes.length === 0) {
2155
+ listView.innerHTML = '<div style="color: #666; text-align: center; padding: 20px;">No nodes to display</div>';
2156
+ return;
2157
+ }
2158
+
2159
+ // Build edge lookup for dependencies
2160
+ const edgesByTarget = {};
2161
+ const edgesBySource = {};
2162
+ graphData.edges.forEach(edge => {
2163
+ if (!edgesByTarget[edge.target]) edgesByTarget[edge.target] = [];
2164
+ if (!edgesBySource[edge.source]) edgesBySource[edge.source] = [];
2165
+ edgesByTarget[edge.target].push(edge.source);
2166
+ edgesBySource[edge.source].push(edge.target);
2167
+ });
2168
+
2169
+ const statusIcons = {
2170
+ success: '✓',
2171
+ failed: '✗',
2172
+ skipped: '○',
2173
+ unknown: '?',
2174
+ external: '↗'
2175
+ };
2176
+ const statusColors = {
2177
+ success: '#0ca678',
2178
+ failed: '#e03131',
2179
+ skipped: '#adb5bd',
2180
+ unknown: '#868e96',
2181
+ external: '#1c7ed6'
2182
+ };
2183
+
2184
+ // Helper to create clickable dependency links - use data attributes for event delegation
2185
+ const makeDepLinks = (deps, color) => {
2186
+ return deps.map(d => `<a href="#node-${encodeURIComponent(d)}" class="dep-link" data-node-id="${d.replace(/"/g, '&quot;')}" style="color: ${color}; text-decoration: none; border-bottom: 1px dashed ${color};">${d}</a>`).join(', ');
2187
+ };
2188
+
2189
+ let html = `<div style="font-size: 13px; color: #666; margin-bottom: 16px;">
2190
+ ${graphData.nodes.length} nodes • Click node name for details • Click dependency to navigate
2191
+ </div>`;
2192
+
2193
+ // Render nodes in order
2194
+ graphData.nodes.forEach(node => {
2195
+ const upstream = edgesByTarget[node.id] || [];
2196
+ const downstream = edgesBySource[node.id] || [];
2197
+ const statusIcon = statusIcons[node.status] || statusIcons.unknown;
2198
+ const statusColor = statusColors[node.status] || statusColors.unknown;
2199
+ const isExternal = node.is_external === true;
2200
+ const borderStyle = isExternal ? '2px dashed' : '1px solid';
2201
+ const bgColor = isExternal ? '#f0f9ff' : 'white';
2202
+ const labelPrefix = isExternal ? '↗ ' : '';
2203
+
2204
+ html += `
2205
+ <div style="display: flex; align-items: flex-start; padding: 12px 16px; margin-bottom: 8px; background: ${bgColor}; border-radius: 8px; border: ${borderStyle} #e1e4e8; transition: all 0.15s;"
2206
+ onmouseover="this.style.boxShadow='0 2px 8px rgba(0,0,0,0.1)'; this.style.borderColor='${statusColor}';"
2207
+ onmouseout="this.style.boxShadow='none'; this.style.borderColor='#e1e4e8';">
2208
+ <div style="min-width: 28px; height: 28px; display: flex; align-items: center; justify-content: center; background: ${statusColor}20; color: ${statusColor}; border-radius: ${isExternal ? '50%' : '6px'}; font-weight: bold; margin-right: 14px;">
2209
+ ${statusIcon}
2210
+ </div>
2211
+ <div style="flex: 1; min-width: 0;">
2212
+ <div style="font-size: 15px; font-weight: 600; color: #333; margin-bottom: 4px; word-break: break-word;">
2213
+ ${isExternal ? `<span style="color: ${statusColor};">${labelPrefix}${node.label || node.id}</span> <span style="font-size: 11px; color: #666; font-weight: normal;">(cross-pipeline)</span>` : `<a href="#node-${encodeURIComponent(node.id)}" class="node-link" data-node-id="${(node.id || '').replace(/"/g, '&quot;')}" style="color: #333; text-decoration: none;">${node.label || node.id}</a>`}
2214
+ </div>
2215
+ <div style="font-size: 13px; color: #666; display: flex; flex-wrap: wrap; gap: 12px;">
2216
+ ${!isExternal && node.duration !== undefined ? `<span>⏱ ${node.duration.toFixed(3)}s</span>` : ''}
2217
+ ${!isExternal && node.rows_in !== null && node.rows_in !== undefined ? `<span title="Rows Read">📥 ${node.rows_in.toLocaleString()}</span>` : ''}
2218
+ ${!isExternal && node.rows_written !== null && node.rows_written !== undefined ? `<span title="Rows Written">📤 ${node.rows_written.toLocaleString()}${node.rows_in && node.rows_written === 0 && node.rows_in > 0 ? ' (no changes)' : ''}</span>` : (!isExternal && node.rows_out !== null && node.rows_out !== undefined ? `<span>📊 ${node.rows_out.toLocaleString()} rows</span>` : '')}
2219
+ ${isExternal ? '<span style="font-style: italic;">External dependency</span>' : ''}
2220
+ </div>
2221
+ ${upstream.length > 0 ? `<div style="font-size: 12px; margin-top: 6px;">← <span style="color: #666;">${upstream.length} upstream:</span> ${makeDepLinks(upstream, '#0066cc')}</div>` : (isExternal ? '' : '<div style="font-size: 12px; color: #999; margin-top: 6px;">← No dependencies (source)</div>')}
2222
+ ${downstream.length > 0 ? `<div style="font-size: 12px; margin-top: 2px;">→ <span style="color: #666;">${downstream.length} downstream:</span> ${makeDepLinks(downstream, '#0ca678')}</div>` : ''}
2223
+ </div>
2224
+ </div>`;
2225
+ });
2226
+
2227
+ listView.innerHTML = html;
2228
+ }
2229
+
2230
+ // Set up event delegation for list view links (once, on document level)
2231
+ document.addEventListener('click', function(e) {
2232
+ const link = e.target.closest('.node-link, .dep-link');
2233
+ if (link) {
2234
+ e.preventDefault();
2235
+ e.stopPropagation();
2236
+ const nodeId = link.dataset.nodeId;
2237
+ if (nodeId && typeof window.scrollToNode === 'function') {
2238
+ window.scrollToNode(nodeId);
2239
+ }
2240
+ }
2241
+ });
2242
+
2243
+ // Focus on a node in the graph from list view
2244
+ function focusGraphNode(nodeId) {
2245
+ highlightNodeInGraph(nodeId);
2246
+ }
2247
+
2248
+ // Copy node deep link to clipboard
2249
+ function copyNodeLink(btn, nodeName) {
2250
+ const url = window.location.href.split('#')[0] + '#node-' + nodeName;
2251
+ copyToClipboard(btn, url);
2252
+ }
2253
+
2254
+ // ========================================
2255
+ // Keyboard Navigation
2256
+ // ========================================
2257
+ let currentFocusedIndex = -1;
2258
+ const nodeCards = [];
2259
+
2260
+ function initKeyboardNavigation() {
2261
+ // Collect all node cards (excluding pipeline-graph-card)
2262
+ document.querySelectorAll('.node-card[data-status]').forEach(card => {
2263
+ nodeCards.push(card);
2264
+ });
2265
+
2266
+ document.addEventListener('keydown', function(e) {
2267
+ // Ignore if typing in an input
2268
+ if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
2269
+
2270
+ switch(e.key.toLowerCase()) {
2271
+ case 'f': // Jump to first failed node
2272
+ e.preventDefault();
2273
+ jumpToFirstFailed();
2274
+ break;
2275
+ case 'n': // Next node
2276
+ e.preventDefault();
2277
+ navigateNodes(1);
2278
+ break;
2279
+ case 'p': // Previous node
2280
+ e.preventDefault();
2281
+ navigateNodes(-1);
2282
+ break;
2283
+ case 'escape': // Collapse all nodes
2284
+ e.preventDefault();
2285
+ collapseAllNodes();
2286
+ break;
2287
+ case 'enter': // Toggle current node
2288
+ if (currentFocusedIndex >= 0) {
2289
+ e.preventDefault();
2290
+ const card = nodeCards[currentFocusedIndex];
2291
+ const header = card.querySelector('.node-header');
2292
+ if (header) toggleNode(header);
2293
+ }
2294
+ break;
2295
+ }
2296
+ });
2297
+ }
2298
+
2299
+ function jumpToFirstFailed() {
2300
+ for (let i = 0; i < nodeCards.length; i++) {
2301
+ if (nodeCards[i].dataset.status === 'failed') {
2302
+ focusNodeCard(i);
2303
+ return;
2304
+ }
2305
+ }
2306
+ }
2307
+
2308
+ function navigateNodes(direction) {
2309
+ const newIndex = currentFocusedIndex + direction;
2310
+ if (newIndex >= 0 && newIndex < nodeCards.length) {
2311
+ focusNodeCard(newIndex);
2312
+ } else if (newIndex < 0) {
2313
+ focusNodeCard(nodeCards.length - 1);
2314
+ } else {
2315
+ focusNodeCard(0);
2316
+ }
2317
+ }
2318
+
2319
+ function focusNodeCard(index) {
2320
+ // Remove previous focus
2321
+ nodeCards.forEach(card => card.classList.remove('keyboard-focus'));
2322
+
2323
+ currentFocusedIndex = index;
2324
+ const card = nodeCards[index];
2325
+ card.classList.add('keyboard-focus');
2326
+ card.scrollIntoView({ behavior: 'smooth', block: 'center' });
2327
+ }
2328
+
2329
+ function collapseAllNodes() {
2330
+ document.querySelectorAll('.node-body.open').forEach(body => {
2331
+ body.classList.remove('open');
2332
+ });
2333
+ // Remove keyboard focus
2334
+ nodeCards.forEach(card => card.classList.remove('keyboard-focus'));
2335
+ currentFocusedIndex = -1;
2336
+ }
2337
+
2338
+ // ========================================
2339
+ // Deep Links (URL Hash Navigation)
2340
+ // ========================================
2341
+ function handleHashNavigation() {
2342
+ const hash = window.location.hash;
2343
+ if (hash && hash.startsWith('#node-')) {
2344
+ const nodeName = hash.substring(6); // Remove '#node-'
2345
+ setTimeout(() => {
2346
+ window.scrollToNode(nodeName);
2347
+ }, 500); // Delay to ensure DOM is ready
2348
+ }
2349
+ }
2350
+
2351
+ // ========================================
2352
+ // Execution Timeline
2353
+ // ========================================
2354
+ let timelineExpanded = false;
2355
+
2356
+ function renderExecutionTimeline() {
2357
+ const container = document.getElementById('timeline-chart');
2358
+ if (!container || !graphData.nodes || graphData.nodes.length === 0) return;
2359
+
2360
+ // Get nodes with timing data
2361
+ const nodesWithTiming = graphData.nodes.filter(n => n.duration > 0 && !n.is_external);
2362
+ if (nodesWithTiming.length === 0) {
2363
+ container.innerHTML = '<div style="display: flex; align-items: center; justify-content: center; height: 100%; color: #666;">No timing data available</div>';
2364
+ return;
2365
+ }
2366
+
2367
+ // Find max duration for scaling
2368
+ const maxDuration = Math.max(...nodesWithTiming.map(n => n.duration));
2369
+
2370
+ // Sort by duration descending (slowest first)
2371
+ nodesWithTiming.sort((a, b) => b.duration - a.duration);
2372
+
2373
+ // Show top 15 collapsed, all when expanded
2374
+ const initialCount = 15;
2375
+ const hasMore = nodesWithTiming.length > initialCount;
2376
+ const nodesToShow = timelineExpanded ? nodesWithTiming : nodesWithTiming.slice(0, initialCount);
2377
+
2378
+ let html = '';
2379
+ nodesToShow.forEach((node, index) => {
2380
+ const widthPct = (node.duration / maxDuration) * 100;
2381
+ const statusClass = node.status || 'unknown';
2382
+
2383
+ html += `<div class="timeline-row">
2384
+ <div class="timeline-label" title="${node.label}">${node.label}</div>
2385
+ <div class="timeline-bar-container">
2386
+ <div class="timeline-bar ${statusClass}"
2387
+ style="width: ${widthPct}%;"
2388
+ onclick="window.scrollToNode('${node.id}')"
2389
+ title="${node.label}: ${node.duration.toFixed(3)}s">
2390
+ ${node.duration.toFixed(2)}s
2391
+ </div>
2392
+ </div>
2393
+ </div>`;
2394
+ });
2395
+
2396
+ if (hasMore) {
2397
+ const remaining = nodesWithTiming.length - initialCount;
2398
+ if (timelineExpanded) {
2399
+ html += `<div style="text-align: center; margin-top: 10px;">
2400
+ <button onclick="toggleTimeline()" style="background: none; border: 1px solid var(--border-color); padding: 6px 16px; border-radius: 4px; cursor: pointer; color: var(--text-color); font-size: 12px;">
2401
+ ▲ Show less
2402
+ </button>
2403
+ </div>`;
2404
+ } else {
2405
+ html += `<div style="text-align: center; margin-top: 10px;">
2406
+ <button onclick="toggleTimeline()" style="background: none; border: 1px solid var(--border-color); padding: 6px 16px; border-radius: 4px; cursor: pointer; color: var(--text-color); font-size: 12px;">
2407
+ ▼ Show ${remaining} more nodes
2408
+ </button>
2409
+ </div>`;
2410
+ }
2411
+ }
2412
+
2413
+ container.style.height = 'auto';
2414
+ container.innerHTML = html;
2415
+ }
2416
+
2417
+ function toggleTimeline() {
2418
+ timelineExpanded = !timelineExpanded;
2419
+ renderExecutionTimeline();
2420
+ }
2421
+
2422
+ // ========================================
2423
+ // Failed Path Trace (Auto-highlight)
2424
+ // ========================================
2425
+ function highlightFailedPaths() {
2426
+ if (!graphData.nodes || !graphData.edges) return;
2427
+
2428
+ const failedNodes = graphData.nodes.filter(n => n.status === 'failed').map(n => n.id);
2429
+ if (failedNodes.length === 0) return;
2430
+
2431
+ // Build reverse edge map (target -> sources)
2432
+ const upstreamMap = {};
2433
+ graphData.edges.forEach(e => {
2434
+ if (!upstreamMap[e.target]) upstreamMap[e.target] = [];
2435
+ upstreamMap[e.target].push(e.source);
2436
+ });
2437
+
2438
+ // Find all nodes in failed paths (trace upstream from failed nodes)
2439
+ const failedPathNodes = new Set(failedNodes);
2440
+ const queue = [...failedNodes];
2441
+
2442
+ while (queue.length > 0) {
2443
+ const current = queue.shift();
2444
+ const upstream = upstreamMap[current] || [];
2445
+ upstream.forEach(u => {
2446
+ if (!failedPathNodes.has(u)) {
2447
+ failedPathNodes.add(u);
2448
+ queue.push(u);
2449
+ }
2450
+ });
2451
+ }
2452
+
2453
+ // Highlight these nodes in the SVG with red border
2454
+ const container = document.getElementById('graphviz-container');
2455
+ if (!container) return;
2456
+
2457
+ container.querySelectorAll('.node').forEach(nodeEl => {
2458
+ const titleEl = nodeEl.querySelector('title');
2459
+ if (titleEl) {
2460
+ const nodeId = titleEl.textContent.trim();
2461
+ if (failedNodes.includes(nodeId)) {
2462
+ // Failed node - thick red border
2463
+ const shape = nodeEl.querySelector('polygon, path, ellipse');
2464
+ if (shape) {
2465
+ shape.style.strokeWidth = '4';
2466
+ shape.style.stroke = '#e03131';
2467
+ }
2468
+ } else if (failedPathNodes.has(nodeId)) {
2469
+ // Upstream of failed - orange dashed
2470
+ const shape = nodeEl.querySelector('polygon, path, ellipse');
2471
+ if (shape) {
2472
+ shape.style.strokeWidth = '2';
2473
+ shape.style.stroke = '#f59f00';
2474
+ shape.style.strokeDasharray = '5,3';
2475
+ }
2476
+ }
2477
+ }
2478
+ });
2479
+ }
2480
+
2481
+ // ========================================
2482
+ // Initialize on page load
2483
+ // ========================================
2484
+ document.addEventListener('DOMContentLoaded', function() {
2485
+ // Initialize collapsible sections first
2486
+ initCollapsibleSections();
2487
+
2488
+ const viewTypeSelect = document.getElementById('graph-view-type');
2489
+ const pipelineContent = document.getElementById('pipeline-content');
2490
+ const isPipelineExpanded = pipelineContent && !pipelineContent.classList.contains('collapsed');
2491
+
2492
+ // Only render graph if pipeline section is expanded
2493
+ if (isPipelineExpanded) {
2494
+ if (viewTypeSelect && viewTypeSelect.value === 'list') {
2495
+ changeGraphViewType('list');
2496
+ } else {
2497
+ initGraphvizGraph();
2498
+ }
2499
+ }
2500
+
2501
+ // Initialize other features
2502
+ initKeyboardNavigation();
2503
+ handleHashNavigation();
2504
+
2505
+ // Render timeline only if expanded
2506
+ const timelineContent = document.getElementById('timeline-content');
2507
+ if (timelineContent && !timelineContent.classList.contains('collapsed')) {
2508
+ setTimeout(renderExecutionTimeline, 100);
2509
+ }
2510
+
2511
+ // Highlight failed paths after graph renders
2512
+ setTimeout(highlightFailedPaths, 500);
2513
+ });
2514
+
2515
+ // Also handle hash changes for deep linking
2516
+ window.addEventListener('hashchange', handleHashNavigation);
2517
+ </script>
2518
+
2519
+ </body>
2520
+ </html>