tracepipe 0.2.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.
@@ -0,0 +1,1335 @@
1
+ # tracepipe/visualization/html_export.py
2
+ """
3
+ Interactive HTML dashboard for TracePipe lineage reports.
4
+
5
+ Design principles:
6
+ 1. Dashboard-first: Key metrics visible immediately
7
+ 2. Progressive disclosure: Summary → click to expand → full details
8
+ 3. Searchable: Find any row by ID instantly
9
+ 4. Scalable: Works with 1M+ rows (uses counts, not lists)
10
+ 5. Visual pipeline: See data flow at a glance
11
+ """
12
+
13
+ import html
14
+ import json
15
+ import linecache
16
+ import os
17
+ from typing import Optional
18
+
19
+ from ..context import get_context
20
+
21
+
22
+ def _escape(value) -> str:
23
+ """Escape value for HTML."""
24
+ if value is None:
25
+ return '<span class="null">NULL</span>'
26
+ s = str(value)
27
+ if len(s) > 50:
28
+ s = s[:47] + "..."
29
+ return html.escape(s)
30
+
31
+
32
+ def _format_number(n: int) -> str:
33
+ """Format large numbers with commas."""
34
+ return f"{n:,}"
35
+
36
+
37
+ def _format_file_name(path: str) -> str:
38
+ """Extract file name from path for display."""
39
+ return path.split("/")[-1] if "/" in path else path
40
+
41
+
42
+ def _get_code_snippet(filepath: str, lineno: int, context: int = 2) -> Optional[str]:
43
+ """Get source code snippet around a line number."""
44
+ if not filepath or not lineno or not os.path.exists(filepath):
45
+ return None
46
+
47
+ try:
48
+ start = max(1, lineno - context)
49
+ end = lineno + context
50
+ lines = []
51
+ for i in range(start, end + 1):
52
+ line = linecache.getline(filepath, i)
53
+ if line:
54
+ # Remove common indentation
55
+ lines.append(line.rstrip())
56
+
57
+ if not lines:
58
+ return None
59
+
60
+ # Dedent
61
+ min_indent = float("inf")
62
+ for line in lines:
63
+ if line.strip():
64
+ indent = len(line) - len(line.lstrip())
65
+ min_indent = min(min_indent, indent)
66
+
67
+ if min_indent == float("inf"):
68
+ min_indent = 0
69
+
70
+ formatted_lines = []
71
+ for i, line in enumerate(lines):
72
+ curr_lineno = start + i
73
+ content = line[int(min_indent) :] if len(line) >= min_indent else line
74
+ is_target = curr_lineno == lineno
75
+ marker = ">" if is_target else " "
76
+ cls = "highlight" if is_target else ""
77
+ formatted_lines.append(
78
+ f'<div class="code-line {cls}"><span class="lineno">{curr_lineno}</span><span class="marker">{marker}</span><span class="content">{html.escape(content)}</span></div>'
79
+ )
80
+
81
+ return "\n".join(formatted_lines)
82
+ except Exception:
83
+ return None
84
+
85
+
86
+ def _get_pipeline_data(ctx) -> list[dict]:
87
+ """Extract pipeline steps for visualization."""
88
+ steps = []
89
+ for step in ctx.store.steps:
90
+ # Format code location
91
+ code_loc = None
92
+ snippet = None
93
+ if step.code_file and step.code_line:
94
+ code_loc = f"{_format_file_name(step.code_file)}:{step.code_line}"
95
+ snippet = _get_code_snippet(step.code_file, step.code_line)
96
+
97
+ step_data = {
98
+ "id": step.step_id,
99
+ "operation": step.operation,
100
+ "stage": step.stage or "",
101
+ "input_shape": list(step.input_shape) if step.input_shape else None,
102
+ "output_shape": list(step.output_shape) if step.output_shape else None,
103
+ "is_mass_update": step.is_mass_update,
104
+ "rows_affected": step.rows_affected,
105
+ "completeness": step.completeness.name,
106
+ "code_file": step.code_file,
107
+ "code_line": step.code_line,
108
+ "code_loc": code_loc,
109
+ "code_snippet": snippet,
110
+ "timestamp": step.timestamp,
111
+ }
112
+ steps.append(step_data)
113
+ return steps
114
+
115
+
116
+ def _get_dropped_summary(ctx) -> dict:
117
+ """Get dropped rows summary."""
118
+ dropped_by_step = ctx.store.get_dropped_by_step()
119
+ total = sum(dropped_by_step.values())
120
+ return {
121
+ "total": total,
122
+ "by_operation": dropped_by_step,
123
+ }
124
+
125
+
126
+ def _get_changes_summary(ctx) -> dict:
127
+ """Get cell changes summary."""
128
+ changes_by_col = {}
129
+ changes_by_step = {}
130
+
131
+ for i in range(len(ctx.store.diff_cols)):
132
+ col = ctx.store.diff_cols[i]
133
+ step_id = ctx.store.diff_step_ids[i]
134
+ change_type = ctx.store.diff_change_types[i]
135
+
136
+ # Only count MODIFIED and ADDED
137
+ if change_type in (0, 2):
138
+ changes_by_col[col] = changes_by_col.get(col, 0) + 1
139
+
140
+ # Find operation name for this step
141
+ for step in ctx.store.steps:
142
+ if step.step_id == step_id:
143
+ op = step.operation
144
+ changes_by_step[op] = changes_by_step.get(op, 0) + 1
145
+ break
146
+
147
+ return {
148
+ "total": ctx.store.total_diff_count,
149
+ "by_column": changes_by_col,
150
+ "by_operation": changes_by_step,
151
+ }
152
+
153
+
154
+ def _get_groups_summary(ctx) -> list[dict]:
155
+ """Get aggregation groups summary."""
156
+ groups = []
157
+ for mapping in ctx.store.aggregation_mappings:
158
+ for group_key, row_ids in mapping.membership.items():
159
+ is_count_only = isinstance(row_ids, int)
160
+ groups.append(
161
+ {
162
+ "key": str(group_key),
163
+ "column": mapping.group_column,
164
+ "row_count": row_ids if is_count_only else len(row_ids),
165
+ "is_count_only": is_count_only,
166
+ "row_ids": [] if is_count_only else row_ids[:100], # First 100 only
167
+ "agg_functions": mapping.agg_functions,
168
+ }
169
+ )
170
+ return groups
171
+
172
+
173
+ def _build_row_index(ctx) -> dict[int, dict]:
174
+ """Build searchable index of row events with full timeline for time-travel."""
175
+ row_events = {}
176
+
177
+ # Build step lookup for code locations
178
+ step_lookup = {}
179
+ for step in ctx.store.steps:
180
+ code_loc = None
181
+ if step.code_file and step.code_line:
182
+ code_loc = f"{_format_file_name(step.code_file)}:{step.code_line}"
183
+ step_lookup[step.step_id] = {
184
+ "operation": step.operation,
185
+ "stage": step.stage or "",
186
+ "code_loc": code_loc,
187
+ "code_file": step.code_file,
188
+ "code_line": step.code_line,
189
+ }
190
+
191
+ # Index diffs
192
+ for i in range(len(ctx.store.diff_row_ids)):
193
+ row_id = ctx.store.diff_row_ids[i]
194
+ if row_id not in row_events:
195
+ row_events[row_id] = {"diffs": [], "dropped_at": None, "timeline": {}}
196
+
197
+ step_id = ctx.store.diff_step_ids[i]
198
+ change_type = ctx.store.diff_change_types[i]
199
+
200
+ # Get step info
201
+ step_info = step_lookup.get(
202
+ step_id, {"operation": f"Step {step_id}", "stage": "", "code_loc": None}
203
+ )
204
+
205
+ change_names = {0: "MODIFIED", 1: "DROPPED", 2: "ADDED", 3: "REORDERED"}
206
+
207
+ col = ctx.store.diff_cols[i]
208
+ old_val = ctx.store.diff_old_vals[i]
209
+ new_val = ctx.store.diff_new_vals[i]
210
+
211
+ if change_type == 1: # DROPPED
212
+ row_events[row_id]["dropped_at"] = {
213
+ "step_id": step_id,
214
+ "operation": step_info["operation"],
215
+ "stage": step_info["stage"],
216
+ "code_loc": step_info["code_loc"],
217
+ }
218
+ else:
219
+ row_events[row_id]["diffs"].append(
220
+ {
221
+ "step_id": step_id,
222
+ "operation": step_info["operation"],
223
+ "stage": step_info["stage"],
224
+ "code_loc": step_info["code_loc"],
225
+ "column": col,
226
+ "old_val": old_val,
227
+ "new_val": new_val,
228
+ "change_type": change_names.get(change_type, "UNKNOWN"),
229
+ }
230
+ )
231
+
232
+ return row_events
233
+
234
+
235
+ CSS = """
236
+ <style>
237
+ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;700&display=swap');
238
+
239
+ :root {
240
+ /* Colors - Cosmic Slate Theme */
241
+ --bg-app: #0f1115;
242
+ --bg-panel: #161b22;
243
+ --bg-card: #1c2128;
244
+ --bg-hover: #2d333b;
245
+ --bg-input: #0d1117;
246
+
247
+ --border-subtle: #30363d;
248
+ --border-focus: #58a6ff;
249
+
250
+ --text-primary: #f0f6fc;
251
+ --text-secondary: #8b949e;
252
+ --text-muted: #6e7681;
253
+
254
+ --code-bg: #0d1117;
255
+
256
+ --accent-blue: #58a6ff;
257
+ --accent-blue-dim: rgba(88, 166, 255, 0.15);
258
+ --accent-green: #3fb950;
259
+ --accent-green-dim: rgba(63, 185, 80, 0.15);
260
+ --accent-red: #f85149;
261
+ --accent-red-dim: rgba(248, 81, 73, 0.15);
262
+ --accent-purple: #bc8cff;
263
+ --accent-purple-dim: rgba(188, 140, 255, 0.15);
264
+ --accent-orange: #d29922;
265
+ --accent-cyan: #39c5cf;
266
+
267
+ --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
268
+ --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4);
269
+
270
+ --font-main: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
271
+ --font-mono: 'JetBrains Mono', 'Fira Code', monospace;
272
+ }
273
+
274
+ [data-theme="light"] {
275
+ --bg-app: #ffffff;
276
+ --bg-panel: #f6f8fa;
277
+ --bg-card: #ffffff;
278
+ --bg-hover: #f3f4f6;
279
+ --bg-input: #ffffff;
280
+
281
+ --border-subtle: #d0d7de;
282
+ --border-focus: #0969da;
283
+
284
+ --text-primary: #24292f;
285
+ --text-secondary: #57606a;
286
+ --text-muted: #6e7781;
287
+
288
+ --code-bg: #f6f8fa;
289
+
290
+ --accent-blue: #0969da;
291
+ --accent-blue-dim: rgba(9, 105, 218, 0.1);
292
+ --accent-green: #1a7f37;
293
+ --accent-green-dim: rgba(26, 127, 55, 0.1);
294
+ --accent-red: #cf222e;
295
+ --accent-red-dim: rgba(207, 34, 46, 0.1);
296
+ --accent-purple: #8250df;
297
+ --accent-purple-dim: rgba(130, 80, 223, 0.1);
298
+ --accent-orange: #bf8700;
299
+ --accent-cyan: #0598a6;
300
+
301
+ --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.1);
302
+ --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.1);
303
+ }
304
+
305
+ * { box-sizing: border-box; }
306
+
307
+ body {
308
+ font-family: var(--font-main);
309
+ background: var(--bg-app);
310
+ color: var(--text-primary);
311
+ margin: 0;
312
+ padding: 0;
313
+ line-height: 1.5;
314
+ height: 100vh;
315
+ display: flex;
316
+ overflow: hidden;
317
+ }
318
+
319
+ /* Layout */
320
+ .app-container {
321
+ display: flex;
322
+ width: 100%;
323
+ height: 100%;
324
+ }
325
+
326
+ /* Sidebar */
327
+ .sidebar {
328
+ width: 260px;
329
+ background: var(--bg-panel);
330
+ border-right: 1px solid var(--border-subtle);
331
+ display: flex;
332
+ flex-direction: column;
333
+ flex-shrink: 0;
334
+ }
335
+
336
+ .logo-area {
337
+ padding: 20px 24px;
338
+ border-bottom: 1px solid var(--border-subtle);
339
+ display: flex;
340
+ justify-content: space-between;
341
+ align-items: center;
342
+ }
343
+
344
+ .theme-toggle {
345
+ background: none;
346
+ border: none;
347
+ color: var(--text-secondary);
348
+ cursor: pointer;
349
+ padding: 4px;
350
+ border-radius: 4px;
351
+ display: flex;
352
+ align-items: center;
353
+ justify-content: center;
354
+ transition: all 0.2s;
355
+ }
356
+
357
+ .theme-toggle:hover {
358
+ color: var(--text-primary);
359
+ background: var(--bg-hover);
360
+ }
361
+
362
+ .logo-text {
363
+ font-size: 1.25rem;
364
+ font-weight: 700;
365
+ background: linear-gradient(135deg, var(--accent-blue), var(--accent-purple));
366
+ -webkit-background-clip: text;
367
+ -webkit-text-fill-color: transparent;
368
+ letter-spacing: -0.03em;
369
+ }
370
+
371
+ .nav-menu {
372
+ flex: 1;
373
+ padding: 16px 12px;
374
+ overflow-y: auto;
375
+ }
376
+
377
+ .nav-item {
378
+ display: flex;
379
+ align-items: center;
380
+ gap: 12px;
381
+ padding: 10px 12px;
382
+ margin-bottom: 4px;
383
+ color: var(--text-secondary);
384
+ text-decoration: none;
385
+ border-radius: 6px;
386
+ font-size: 0.9rem;
387
+ font-weight: 500;
388
+ cursor: pointer;
389
+ transition: all 0.2s;
390
+ }
391
+
392
+ .nav-item:hover {
393
+ background: var(--bg-hover);
394
+ color: var(--text-primary);
395
+ }
396
+
397
+ .nav-item.active {
398
+ background: var(--accent-blue-dim);
399
+ color: var(--accent-blue);
400
+ }
401
+
402
+ .nav-icon { width: 18px; text-align: center; }
403
+
404
+ /* Main Content */
405
+ .main-content {
406
+ flex: 1;
407
+ display: flex;
408
+ flex-direction: column;
409
+ overflow: hidden;
410
+ position: relative;
411
+ }
412
+
413
+ /* Top Bar */
414
+ .top-bar {
415
+ height: 64px;
416
+ background: var(--bg-app);
417
+ border-bottom: 1px solid var(--border-subtle);
418
+ display: flex;
419
+ align-items: center;
420
+ justify-content: space-between;
421
+ padding: 0 32px;
422
+ flex-shrink: 0;
423
+ }
424
+
425
+ .page-title {
426
+ font-size: 1.1rem;
427
+ font-weight: 600;
428
+ }
429
+
430
+ .search-wrapper {
431
+ position: relative;
432
+ width: 400px;
433
+ }
434
+
435
+ .search-input {
436
+ width: 100%;
437
+ background: var(--bg-input);
438
+ border: 1px solid var(--border-subtle);
439
+ border-radius: 6px;
440
+ padding: 8px 16px 8px 36px;
441
+ color: var(--text-primary);
442
+ font-family: var(--font-mono);
443
+ font-size: 0.9rem;
444
+ transition: border-color 0.2s;
445
+ }
446
+
447
+ .search-input:focus {
448
+ outline: none;
449
+ border-color: var(--accent-blue);
450
+ box-shadow: 0 0 0 2px var(--accent-blue-dim);
451
+ }
452
+
453
+ .search-icon-abs {
454
+ position: absolute;
455
+ left: 12px;
456
+ top: 50%;
457
+ transform: translateY(-50%);
458
+ color: var(--text-muted);
459
+ font-size: 0.9rem;
460
+ }
461
+
462
+ /* Scrollable Canvas */
463
+ .canvas {
464
+ flex: 1;
465
+ overflow-y: auto;
466
+ padding: 32px;
467
+ }
468
+
469
+ /* Dashboard Grid */
470
+ .grid-cols-4 { display: grid; grid-template-columns: repeat(4, 1fr); gap: 24px; margin-bottom: 32px; }
471
+ .grid-cols-2 { display: grid; grid-template-columns: repeat(2, 1fr); gap: 24px; margin-bottom: 32px; }
472
+
473
+ /* Cards */
474
+ .card {
475
+ background: var(--bg-card);
476
+ border: 1px solid var(--border-subtle);
477
+ border-radius: 12px;
478
+ padding: 24px;
479
+ position: relative;
480
+ overflow: hidden;
481
+ }
482
+
483
+ .card h3 {
484
+ margin: 0 0 16px 0;
485
+ font-size: 0.9rem;
486
+ text-transform: uppercase;
487
+ letter-spacing: 0.05em;
488
+ color: var(--text-secondary);
489
+ font-weight: 600;
490
+ }
491
+
492
+ .metric-value {
493
+ font-size: 2.5rem;
494
+ font-weight: 700;
495
+ color: var(--text-primary);
496
+ line-height: 1;
497
+ margin-bottom: 8px;
498
+ }
499
+
500
+ .metric-sub {
501
+ font-size: 0.85rem;
502
+ color: var(--text-muted);
503
+ }
504
+
505
+ .metric-trend {
506
+ font-size: 0.85rem;
507
+ font-weight: 500;
508
+ }
509
+ .trend-up { color: var(--accent-green); }
510
+ .trend-down { color: var(--accent-red); }
511
+
512
+ /* Pipeline Timeline */
513
+ .pipeline-container {
514
+ display: flex;
515
+ flex-direction: column;
516
+ gap: 16px;
517
+ max-width: 800px;
518
+ margin: 0 auto;
519
+ position: relative;
520
+ }
521
+
522
+ .pipeline-container::before {
523
+ content: '';
524
+ position: absolute;
525
+ left: 24px;
526
+ top: 20px;
527
+ bottom: 20px;
528
+ width: 2px;
529
+ background: var(--border-subtle);
530
+ z-index: 0;
531
+ }
532
+
533
+ .pipeline-step-card {
534
+ display: flex;
535
+ gap: 20px;
536
+ position: relative;
537
+ z-index: 1;
538
+ }
539
+
540
+ .step-marker {
541
+ width: 50px;
542
+ display: flex;
543
+ flex-direction: column;
544
+ align-items: center;
545
+ flex-shrink: 0;
546
+ }
547
+
548
+ .step-dot {
549
+ width: 24px;
550
+ height: 24px;
551
+ border-radius: 50%;
552
+ background: var(--bg-app);
553
+ border: 2px solid var(--accent-blue);
554
+ display: flex;
555
+ align-items: center;
556
+ justify-content: center;
557
+ font-size: 0.75rem;
558
+ font-weight: 700;
559
+ color: var(--accent-blue);
560
+ margin-bottom: 8px;
561
+ }
562
+
563
+ .pipeline-step-card.dropped .step-dot { border-color: var(--accent-red); color: var(--accent-red); }
564
+ .pipeline-step-card.transform .step-dot { border-color: var(--accent-purple); color: var(--accent-purple); }
565
+
566
+ .step-content {
567
+ flex: 1;
568
+ background: var(--bg-card);
569
+ border: 1px solid var(--border-subtle);
570
+ border-radius: 8px;
571
+ overflow: hidden;
572
+ transition: transform 0.2s, box-shadow 0.2s;
573
+ }
574
+
575
+ .step-content:hover {
576
+ transform: translateY(-2px);
577
+ box-shadow: var(--shadow-md);
578
+ border-color: var(--border-focus);
579
+ }
580
+
581
+ .step-header {
582
+ padding: 12px 16px;
583
+ border-bottom: 1px solid var(--border-subtle);
584
+ display: flex;
585
+ justify-content: space-between;
586
+ align-items: center;
587
+ background: var(--bg-hover);
588
+ }
589
+
590
+ .step-title {
591
+ font-family: var(--font-mono);
592
+ font-weight: 600;
593
+ color: var(--accent-blue);
594
+ font-size: 0.95rem;
595
+ }
596
+
597
+ .step-badge {
598
+ font-size: 0.7rem;
599
+ padding: 2px 8px;
600
+ border-radius: 12px;
601
+ background: rgba(255, 255, 255, 0.1);
602
+ color: var(--text-secondary);
603
+ font-weight: 500;
604
+ }
605
+
606
+ .step-body {
607
+ padding: 16px;
608
+ }
609
+
610
+ .step-stats {
611
+ display: flex;
612
+ gap: 24px;
613
+ margin-bottom: 12px;
614
+ font-size: 0.85rem;
615
+ }
616
+
617
+ .stat-item { display: flex; flex-direction: column; }
618
+ .stat-lbl { color: var(--text-muted); font-size: 0.75rem; margin-bottom: 2px; }
619
+ .stat-val { font-weight: 600; }
620
+
621
+ .code-snippet {
622
+ background: var(--code-bg);
623
+ border-radius: 6px;
624
+ padding: 12px;
625
+ margin-top: 12px;
626
+ font-family: var(--font-mono);
627
+ font-size: 0.8rem;
628
+ overflow-x: auto;
629
+ border: 1px solid var(--border-subtle);
630
+ }
631
+
632
+ .code-line { display: flex; gap: 12px; line-height: 1.5; }
633
+ .code-line.highlight { background: rgba(88, 166, 255, 0.15); }
634
+ .lineno { color: var(--text-muted); min-width: 24px; text-align: right; user-select: none; }
635
+ .marker { color: var(--accent-blue); font-weight: bold; width: 10px; user-select: none; }
636
+ .content { color: var(--text-primary); white-space: pre; }
637
+
638
+ /* Row Explorer View */
639
+ .row-explorer {
640
+ display: none; /* Hidden by default */
641
+ height: 100%;
642
+ }
643
+
644
+ .row-explorer.visible { display: flex; }
645
+
646
+ .row-sidebar {
647
+ width: 350px;
648
+ border-right: 1px solid var(--border-subtle);
649
+ display: flex;
650
+ flex-direction: column;
651
+ background: var(--bg-panel);
652
+ }
653
+
654
+ .row-main {
655
+ flex: 1;
656
+ padding: 32px;
657
+ overflow-y: auto;
658
+ background: var(--bg-app);
659
+ }
660
+
661
+ .timeline-list {
662
+ flex: 1;
663
+ overflow-y: auto;
664
+ padding: 16px;
665
+ }
666
+
667
+ .timeline-item {
668
+ display: flex;
669
+ gap: 12px;
670
+ padding: 12px;
671
+ border-radius: 8px;
672
+ cursor: pointer;
673
+ border: 1px solid transparent;
674
+ margin-bottom: 8px;
675
+ transition: all 0.2s;
676
+ }
677
+
678
+ .timeline-item:hover { background: var(--bg-hover); }
679
+ .timeline-item.active { background: var(--bg-hover); border-color: var(--accent-blue); }
680
+
681
+ .tl-icon {
682
+ width: 24px; height: 24px;
683
+ border-radius: 50%;
684
+ background: var(--bg-card);
685
+ border: 2px solid var(--text-muted);
686
+ flex-shrink: 0;
687
+ }
688
+
689
+ .timeline-item.dropped .tl-icon { border-color: var(--accent-red); background: var(--accent-red-dim); }
690
+ .timeline-item.modified .tl-icon { border-color: var(--accent-orange); background: rgba(210, 153, 34, 0.15); }
691
+ .timeline-item.added .tl-icon { border-color: var(--accent-green); background: var(--accent-green-dim); }
692
+
693
+ .tl-content { flex: 1; min-width: 0; }
694
+ .tl-title { font-size: 0.9rem; font-weight: 600; margin-bottom: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
695
+ .tl-meta { font-size: 0.8rem; color: var(--text-muted); display: flex; justify-content: space-between; }
696
+
697
+ /* Data Grid */
698
+ .data-grid {
699
+ display: grid;
700
+ grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
701
+ gap: 16px;
702
+ }
703
+
704
+ .data-cell {
705
+ background: var(--bg-card);
706
+ border: 1px solid var(--border-subtle);
707
+ border-radius: 8px;
708
+ padding: 12px;
709
+ }
710
+
711
+ .cell-label { color: var(--text-secondary); font-size: 0.8rem; margin-bottom: 6px; }
712
+ .cell-value { font-family: var(--font-mono); font-size: 0.95rem; word-break: break-all; }
713
+ .cell-value.changed { color: var(--accent-orange); font-weight: 600; }
714
+
715
+ .diff-pill {
716
+ display: inline-block;
717
+ padding: 2px 8px;
718
+ border-radius: 12px;
719
+ font-size: 0.75rem;
720
+ font-weight: 600;
721
+ margin-left: 8px;
722
+ }
723
+ .diff-pill.mod { background: var(--accent-orange); color: #fff; }
724
+ .diff-pill.new { background: var(--accent-green); color: #fff; }
725
+
726
+ /* Empty States */
727
+ .empty-state {
728
+ text-align: center;
729
+ padding: 64px;
730
+ color: var(--text-muted);
731
+ }
732
+ .empty-icon { font-size: 3rem; margin-bottom: 16px; opacity: 0.5; }
733
+
734
+ /* Helpers */
735
+ .text-green { color: var(--accent-green); }
736
+ .text-red { color: var(--accent-red); }
737
+ .text-mono { font-family: var(--font-mono); }
738
+
739
+ /* Scrollbar */
740
+ ::-webkit-scrollbar { width: 8px; height: 8px; }
741
+ ::-webkit-scrollbar-track { background: transparent; }
742
+ ::-webkit-scrollbar-thumb { background: var(--border-subtle); border-radius: 4px; }
743
+ ::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }
744
+
745
+ @media (max-width: 1024px) {
746
+ .grid-cols-4 { grid-template-columns: repeat(2, 1fr); }
747
+ }
748
+ </style>
749
+ """
750
+
751
+ JAVASCRIPT = """
752
+ <script>
753
+ // Data injected from Python
754
+ const pipelineData = __PIPELINE_DATA__;
755
+ const droppedSummary = __DROPPED_SUMMARY__;
756
+ const changesSummary = __CHANGES_SUMMARY__;
757
+ const groupsSummary = __GROUPS_SUMMARY__;
758
+ const rowIndex = __ROW_INDEX__;
759
+ const suggestedRows = __SUGGESTED_ROWS__;
760
+ const totalRegisteredRows = __TOTAL_REGISTERED_ROWS__;
761
+
762
+ // State
763
+ let activeView = 'dashboard';
764
+ let selectedRowId = null;
765
+ let currentStepIndex = -1; // -1 means end of time
766
+ let rowHistory = [];
767
+ let currentTheme = localStorage.getItem('tracepipe-theme') || 'dark';
768
+
769
+ function toggleTheme() {
770
+ currentTheme = currentTheme === 'dark' ? 'light' : 'dark';
771
+ applyTheme();
772
+ localStorage.setItem('tracepipe-theme', currentTheme);
773
+ }
774
+
775
+ function applyTheme() {
776
+ const root = document.documentElement;
777
+ const icon = document.getElementById('theme-icon');
778
+
779
+ if (currentTheme === 'light') {
780
+ root.setAttribute('data-theme', 'light');
781
+ icon.textContent = '🌙';
782
+ } else {
783
+ root.removeAttribute('data-theme');
784
+ icon.textContent = '☀️';
785
+ }
786
+ }
787
+
788
+ function switchView(viewName) {
789
+ document.querySelectorAll('.view-section').forEach(el => el.style.display = 'none');
790
+ document.getElementById(`view-${viewName}`).style.display = 'block';
791
+
792
+ document.querySelectorAll('.nav-item').forEach(el => el.classList.remove('active'));
793
+ document.getElementById(`nav-${viewName}`).classList.add('active');
794
+
795
+ activeView = viewName;
796
+
797
+ if (viewName === 'row-explorer' && !selectedRowId) {
798
+ renderSuggestedRows();
799
+ }
800
+ }
801
+
802
+ function renderSuggestedRows() {
803
+ const container = document.getElementById('row-timeline-list');
804
+ let html = '<div style="padding: 16px;">';
805
+
806
+ // Helper for sections
807
+ const renderSection = (title, items, icon, descFn) => {
808
+ if (!items || items.length === 0) return '';
809
+ let s = `<div style="margin-bottom: 20px;">
810
+ <div style="font-size: 0.8rem; font-weight: 600; color: var(--text-secondary); margin-bottom: 8px; text-transform: uppercase; letter-spacing: 0.05em;">
811
+ ${title}
812
+ </div>`;
813
+ items.forEach(item => {
814
+ s += `
815
+ <div class="timeline-item" onclick="loadRow(${item.id})" style="margin-bottom: 4px; padding: 8px 12px;">
816
+ <div class="tl-icon" style="width: 20px; height: 20px; font-size: 12px; display: flex; align-items: center; justify-content: center; border: none; background: transparent;">${icon}</div>
817
+ <div class="tl-content">
818
+ <div class="tl-title" style="font-size: 0.85rem;">Row ${item.id}</div>
819
+ <div class="tl-meta" style="font-size: 0.75rem;">${descFn(item)}</div>
820
+ </div>
821
+ </div>`;
822
+ });
823
+ s += '</div>';
824
+ return s;
825
+ };
826
+
827
+ if (suggestedRows) {
828
+ html += renderSection('🚫 Dropped Samples', suggestedRows.dropped, '❌', i => ` at ${i.reason}`);
829
+ html += renderSection('✏️ Most Modified', suggestedRows.modified, '📝', i => `${i.count} changes`);
830
+ html += renderSection('✅ Survivors', suggestedRows.survivors, '🏁', i => 'Successfully processed');
831
+ }
832
+
833
+ if (html === '<div style="padding: 16px;">') {
834
+ html = '<div class="empty-state" style="padding: 32px;">Search for a Row ID to inspect its journey.</div>';
835
+ } else {
836
+ html += '</div>';
837
+ }
838
+
839
+ container.innerHTML = html;
840
+ }
841
+
842
+ function searchRow(e) {
843
+ if (e && e.key !== 'Enter') return;
844
+
845
+ const input = document.getElementById('globalSearch');
846
+ const val = input.value.trim();
847
+ if (!val) return;
848
+
849
+ const rowId = parseInt(val);
850
+ if (!isNaN(rowId)) {
851
+ loadRow(rowId);
852
+ }
853
+ }
854
+
855
+ function loadRow(rowId) {
856
+ selectedRowId = rowId;
857
+ switchView('row-explorer');
858
+
859
+ const rowData = rowIndex[rowId];
860
+ const container = document.getElementById('row-timeline-list');
861
+ const detailContainer = document.getElementById('row-detail-view');
862
+
863
+ if (!rowData && (rowId < 0 || rowId >= totalRegisteredRows)) {
864
+ container.innerHTML = '<div class="empty-state">Row ID not found</div>';
865
+ detailContainer.innerHTML = '';
866
+ return;
867
+ }
868
+
869
+ // Build History
870
+ rowHistory = [];
871
+
872
+ // Initial state (step 0) - empty or implied
873
+ rowHistory.push({
874
+ step_id: 0,
875
+ operation: 'Initial State',
876
+ state: {},
877
+ changes: []
878
+ });
879
+
880
+ let currentState = {};
881
+ let events = [];
882
+
883
+ if (rowData) {
884
+ // Collect all events
885
+ if (rowData.diffs) events.push(...rowData.diffs);
886
+ if (rowData.dropped_at) {
887
+ events.push({
888
+ ...rowData.dropped_at,
889
+ change_type: 'DROPPED',
890
+ is_drop: true
891
+ });
892
+ }
893
+
894
+ events.sort((a, b) => a.step_id - b.step_id);
895
+
896
+ // Replay history
897
+ events.forEach(event => {
898
+ const changes = [];
899
+
900
+ if (event.change_type === 'DROPPED') {
901
+ // Mark as dropped in state
902
+ currentState['__status__'] = 'DROPPED';
903
+ } else if (event.column) {
904
+ // Determine previous value if not in state yet
905
+ if (!(event.column in currentState) && event.old_val !== undefined) {
906
+ currentState[event.column] = event.old_val;
907
+ // Retrospectively update initial state if this is the first time we see it?
908
+ // Ideally we'd have full initial state, but we only have diffs.
909
+ // We can assume the "old_val" was the value at start if it hasn't changed before.
910
+ // Simplify: Just update current state.
911
+ }
912
+
913
+ currentState[event.column] = event.new_val;
914
+ changes.push({
915
+ col: event.column,
916
+ old: event.old_val,
917
+ new: event.new_val
918
+ });
919
+ }
920
+
921
+ rowHistory.push({
922
+ step_id: event.step_id,
923
+ operation: event.operation,
924
+ code_loc: event.code_loc,
925
+ is_drop: event.is_drop,
926
+ state: {...currentState}, // Snapshot
927
+ changes: changes
928
+ });
929
+ });
930
+ } else {
931
+ // Row exists but no tracked changes
932
+ rowHistory.push({
933
+ step_id: 999,
934
+ operation: 'No Changes Tracked',
935
+ state: {},
936
+ changes: []
937
+ });
938
+ }
939
+
940
+ renderTimeline();
941
+ selectTimelineStep(rowHistory.length - 1);
942
+ }
943
+
944
+ function renderTimeline() {
945
+ const container = document.getElementById('row-timeline-list');
946
+ let html = '';
947
+
948
+ rowHistory.forEach((step, index) => {
949
+ let statusClass = '';
950
+ if (step.is_drop) statusClass = 'dropped';
951
+ else if (step.changes.length > 0) statusClass = 'modified';
952
+
953
+ html += `
954
+ <div class="timeline-item ${statusClass}" id="tl-step-${index}" onclick="selectTimelineStep(${index})">
955
+ <div class="tl-icon"></div>
956
+ <div class="tl-content">
957
+ <div class="tl-title">${escapeHtml(step.operation)}</div>
958
+ <div class="tl-meta">
959
+ <span>Step ${step.step_id}</span>
960
+ <span>${step.changes.length} changes</span>
961
+ </div>
962
+ </div>
963
+ </div>
964
+ `;
965
+ });
966
+
967
+ container.innerHTML = html;
968
+ }
969
+
970
+ function selectTimelineStep(index) {
971
+ // UI Update
972
+ document.querySelectorAll('.timeline-item').forEach(el => el.classList.remove('active'));
973
+ const item = document.getElementById(`tl-step-${index}`);
974
+ if (item) item.classList.add('active');
975
+
976
+ const step = rowHistory[index];
977
+ const prevState = index > 0 ? rowHistory[index-1].state : {};
978
+ const currState = step.state;
979
+
980
+ // Merge keys from current and all history to show full picture
981
+ const allKeys = new Set([...Object.keys(currState), ...Object.keys(prevState)]);
982
+ // Filter out internal
983
+ allKeys.delete('__status__');
984
+
985
+ const container = document.getElementById('row-detail-view');
986
+ let gridHtml = '';
987
+
988
+ if (step.is_drop) {
989
+ gridHtml = `
990
+ <div style="grid-column: 1/-1; background: rgba(248, 81, 73, 0.1); border: 1px solid var(--accent-red); padding: 24px; border-radius: 8px; text-align: center;">
991
+ <h3 style="color: var(--accent-red); margin-top: 0;">🚫 Row Dropped</h3>
992
+ <p>This row was removed from the pipeline at this step.</p>
993
+ <div class="code-loc-badge">${escapeHtml(step.code_loc || '')}</div>
994
+ </div>
995
+ `;
996
+ } else {
997
+ if (allKeys.size === 0) {
998
+ gridHtml = '<div style="grid-column: 1/-1; color: var(--text-muted); font-style: italic;">No column values tracked.</div>';
999
+ } else {
1000
+ Array.from(allKeys).sort().forEach(key => {
1001
+ const val = currState[key];
1002
+ const prev = prevState[key];
1003
+ const changed = val !== prev && prev !== undefined; // Simple check
1004
+
1005
+ // Check specific changes list for accuracy
1006
+ const changeRecord = step.changes.find(c => c.col === key);
1007
+ const isChanged = !!changeRecord;
1008
+
1009
+ gridHtml += `
1010
+ <div class="data-cell" style="${isChanged ? 'border-color: var(--accent-orange); background: rgba(210, 153, 34, 0.05);' : ''}">
1011
+ <div class="cell-label">${escapeHtml(key)}</div>
1012
+ <div class="cell-value ${isChanged ? 'changed' : ''}">
1013
+ ${formatValue(val)}
1014
+ ${isChanged ? '<span class="diff-pill mod">MOD</span>' : ''}
1015
+ </div>
1016
+ ${isChanged ? `<div style="margin-top:4px; font-size:0.75rem; color:var(--text-muted);">Was: ${formatValue(changeRecord.old)}</div>` : ''}
1017
+ </div>
1018
+ `;
1019
+ });
1020
+ }
1021
+ }
1022
+
1023
+ container.innerHTML = `
1024
+ <div style="margin-bottom: 24px; display: flex; justify-content: space-between; align-items: center;">
1025
+ <h2 style="margin: 0;">State at Step ${step.step_id}</h2>
1026
+ <span style="color: var(--text-muted);">${escapeHtml(step.operation)}</span>
1027
+ </div>
1028
+ <div class="data-grid">
1029
+ ${gridHtml}
1030
+ </div>
1031
+ `;
1032
+ }
1033
+
1034
+ // Utils
1035
+ function escapeHtml(text) {
1036
+ if (text === null || text === undefined) return '';
1037
+ const div = document.createElement('div');
1038
+ div.textContent = String(text);
1039
+ return div.innerHTML;
1040
+ }
1041
+
1042
+ function formatValue(val) {
1043
+ if (val === null || val === undefined) return '<span style="color: var(--text-muted);">NULL</span>';
1044
+ return escapeHtml(String(val));
1045
+ }
1046
+
1047
+ // Init
1048
+ document.addEventListener('DOMContentLoaded', () => {
1049
+ applyTheme();
1050
+
1051
+ // Parse URL for direct linking ?row=123
1052
+ const urlParams = new URLSearchParams(window.location.search);
1053
+ const rowParam = urlParams.get('row');
1054
+ if (rowParam) {
1055
+ loadRow(parseInt(rowParam));
1056
+ }
1057
+ });
1058
+ </script>
1059
+ """
1060
+
1061
+
1062
+ def save(filepath: str) -> None:
1063
+ """
1064
+ Save interactive lineage report as HTML.
1065
+ """
1066
+ ctx = get_context()
1067
+
1068
+ # Gather data
1069
+ pipeline_data = _get_pipeline_data(ctx)
1070
+ dropped_summary = _get_dropped_summary(ctx)
1071
+ changes_summary = _get_changes_summary(ctx)
1072
+ groups_summary = _get_groups_summary(ctx)
1073
+ row_index = _build_row_index(ctx)
1074
+
1075
+ # Total registered rows (approximate)
1076
+ total_registered = ctx.row_manager.next_row_id if hasattr(ctx.row_manager, "next_row_id") else 0
1077
+
1078
+ # Identify Suggested Rows for UX
1079
+ suggested_rows = {"dropped": [], "modified": [], "survivors": []}
1080
+
1081
+ # 1. Dropped Rows (Sample up to 5 unique operations)
1082
+ dropped_sample_map = {}
1083
+ for i in range(len(ctx.store.diff_row_ids)):
1084
+ if ctx.store.diff_change_types[i] == 1: # DROPPED
1085
+ step_id = ctx.store.diff_step_ids[i]
1086
+ row_id = ctx.store.diff_row_ids[i]
1087
+ if step_id not in dropped_sample_map:
1088
+ dropped_sample_map[step_id] = row_id
1089
+
1090
+ for step_id, row_id in list(dropped_sample_map.items())[:5]:
1091
+ step = next((s for s in ctx.store.steps if s.step_id == step_id), None)
1092
+ op_name = step.operation if step else f"Step {step_id}"
1093
+ suggested_rows["dropped"].append({"id": int(row_id), "reason": op_name})
1094
+
1095
+ # 2. Heavily Modified Rows (Top 5 by change count)
1096
+ change_counts = {}
1097
+ for i in range(len(ctx.store.diff_row_ids)):
1098
+ if ctx.store.diff_change_types[i] == 0: # MODIFIED
1099
+ rid = ctx.store.diff_row_ids[i]
1100
+ change_counts[rid] = change_counts.get(rid, 0) + 1
1101
+
1102
+ top_changed = sorted(change_counts.items(), key=lambda x: -x[1])[:5]
1103
+ for rid, count in top_changed:
1104
+ suggested_rows["modified"].append({"id": int(rid), "count": count})
1105
+
1106
+ # 3. Survivors (Sample 5 that are not dropped)
1107
+ dropped_ids = set(ctx.store.get_dropped_rows())
1108
+ survivors = []
1109
+ # Try a range of potential IDs
1110
+ import random
1111
+
1112
+ potential_ids = list(range(max(0, total_registered - 100), total_registered)) # Last 100
1113
+ if not potential_ids and total_registered > 0:
1114
+ potential_ids = list(range(total_registered))
1115
+
1116
+ random.shuffle(potential_ids)
1117
+ for rid in potential_ids:
1118
+ if rid not in dropped_ids:
1119
+ survivors.append({"id": rid})
1120
+ if len(survivors) >= 5:
1121
+ break
1122
+ suggested_rows["survivors"] = survivors
1123
+
1124
+ # Initial/Final rows for health calc
1125
+ initial_rows = (
1126
+ pipeline_data[0]["input_shape"][0]
1127
+ if pipeline_data and pipeline_data[0]["input_shape"]
1128
+ else 0
1129
+ )
1130
+ final_rows = (
1131
+ pipeline_data[-1]["output_shape"][0]
1132
+ if pipeline_data and pipeline_data[-1]["output_shape"]
1133
+ else 0
1134
+ )
1135
+
1136
+ # HTML Generation
1137
+ pipeline_html = ""
1138
+ for step in pipeline_data:
1139
+ snippet_html = (
1140
+ f'<div class="code-snippet">{step["code_snippet"]}</div>'
1141
+ if step["code_snippet"]
1142
+ else ""
1143
+ )
1144
+
1145
+ status_cls = ""
1146
+ if "dropped" in step["operation"].lower() or step["rows_affected"] < 0:
1147
+ status_cls = "dropped"
1148
+ elif "setitem" in step["operation"] or "replace" in step["operation"]:
1149
+ status_cls = "transform"
1150
+
1151
+ shape_info = ""
1152
+ if step["input_shape"] and step["output_shape"]:
1153
+ shape_info = f"""
1154
+ <div class="stat-item">
1155
+ <span class="stat-lbl">Flow</span>
1156
+ <span class="stat-val">{step["input_shape"][0]} → {step["output_shape"][0]} rows</span>
1157
+ </div>
1158
+ """
1159
+
1160
+ pipeline_html += f"""
1161
+ <div class="pipeline-step-card {status_cls}">
1162
+ <div class="step-marker">
1163
+ <div class="step-dot">{step["id"]}</div>
1164
+ </div>
1165
+ <div class="step-content">
1166
+ <div class="step-header">
1167
+ <span class="step-title">{_escape(step["operation"])}</span>
1168
+ {f'<span class="step-badge">{_escape(step["stage"])}</span>' if step["stage"] else ""}
1169
+ </div>
1170
+ <div class="step-body">
1171
+ <div class="step-stats">
1172
+ {shape_info}
1173
+ <div class="stat-item">
1174
+ <span class="stat-lbl">Location</span>
1175
+ <span class="stat-val" style="font-family: var(--font-mono);">{_escape(step["code_loc"] or "Unknown")}</span>
1176
+ </div>
1177
+ </div>
1178
+ {snippet_html}
1179
+ </div>
1180
+ </div>
1181
+ </div>
1182
+ """
1183
+
1184
+ html_content = f"""
1185
+ <!DOCTYPE html>
1186
+ <html lang="en">
1187
+ <head>
1188
+ <meta charset="utf-8">
1189
+ <meta name="viewport" content="width=device-width, initial-scale=1">
1190
+ <title>TracePipe Dashboard</title>
1191
+ {CSS}
1192
+ </head>
1193
+ <body>
1194
+ <div class="app-container">
1195
+ <!-- Sidebar -->
1196
+ <div class="sidebar">
1197
+ <div class="logo-area">
1198
+ <div class="logo-text">TracePipe</div>
1199
+ <button class="theme-toggle" onclick="toggleTheme()" title="Toggle theme">
1200
+ <span id="theme-icon">☀️</span>
1201
+ </button>
1202
+ </div>
1203
+ <div class="nav-menu">
1204
+ <div class="nav-item active" id="nav-dashboard" onclick="switchView('dashboard')">
1205
+ <span class="nav-icon">📊</span> Dashboard
1206
+ </div>
1207
+ <div class="nav-item" id="nav-pipeline" onclick="switchView('pipeline')">
1208
+ <span class="nav-icon">⚡</span> Pipeline Flow
1209
+ </div>
1210
+ <div class="nav-item" id="nav-row-explorer" onclick="switchView('row-explorer')">
1211
+ <span class="nav-icon">🔍</span> Row Inspector
1212
+ </div>
1213
+ </div>
1214
+ </div>
1215
+
1216
+ <!-- Main Content -->
1217
+ <div class="main-content">
1218
+ <!-- Top Bar -->
1219
+ <div class="top-bar">
1220
+ <div class="page-title">Data Lineage Report</div>
1221
+ <div class="search-wrapper">
1222
+ <i class="search-icon-abs">🔍</i>
1223
+ <input type="text" id="globalSearch" class="search-input"
1224
+ placeholder="Search Row ID (e.g. 12)"
1225
+ onkeydown="searchRow(event)">
1226
+ </div>
1227
+ </div>
1228
+
1229
+ <!-- View: Dashboard -->
1230
+ <div id="view-dashboard" class="view-section canvas">
1231
+ <div class="grid-cols-4">
1232
+ <div class="card">
1233
+ <h3>Pipeline Steps</h3>
1234
+ <div class="metric-value">{len(pipeline_data)}</div>
1235
+ <div class="metric-sub">Total Operations</div>
1236
+ </div>
1237
+ <div class="card">
1238
+ <h3>Retention</h3>
1239
+ <div class="metric-value">{(final_rows / initial_rows * 100) if initial_rows else 0:.1f}%</div>
1240
+ <div class="metric-sub">{_format_number(final_rows)} of {
1241
+ _format_number(initial_rows)
1242
+ } rows</div>
1243
+ </div>
1244
+ <div class="card">
1245
+ <h3>Rows Dropped</h3>
1246
+ <div class="metric-value" style="color: var(--accent-red);">{
1247
+ _format_number(dropped_summary["total"])
1248
+ }</div>
1249
+ <div class="metric-sub">Across {
1250
+ len(dropped_summary["by_operation"])
1251
+ } filters</div>
1252
+ </div>
1253
+ <div class="card">
1254
+ <h3>Cell Changes</h3>
1255
+ <div class="metric-value" style="color: var(--accent-orange);">{
1256
+ _format_number(changes_summary["total"])
1257
+ }</div>
1258
+ <div class="metric-sub">In watched columns</div>
1259
+ </div>
1260
+ </div>
1261
+
1262
+ <div class="grid-cols-2">
1263
+ <div class="card">
1264
+ <h3>Top Drop Reasons</h3>
1265
+ <div style="display: flex; flex-direction: column; gap: 12px; margin-top: 16px;">
1266
+ {
1267
+ "".join(
1268
+ f'<div style="display:flex; justify-content:space-between; border-bottom:1px solid var(--border-subtle); padding-bottom:8px;"><span>{_escape(k)}</span><span style="font-weight:600;">{_format_number(v)}</span></div>'
1269
+ for k, v in list(dropped_summary["by_operation"].items())[:5]
1270
+ )
1271
+ }
1272
+ </div>
1273
+ </div>
1274
+ <div class="card">
1275
+ <h3>Most Changed Columns</h3>
1276
+ <div style="display: flex; flex-direction: column; gap: 12px; margin-top: 16px;">
1277
+ {
1278
+ "".join(
1279
+ f'<div style="display:flex; justify-content:space-between; border-bottom:1px solid var(--border-subtle); padding-bottom:8px;"><span>{_escape(k)}</span><span style="font-weight:600;">{_format_number(v)}</span></div>'
1280
+ for k, v in list(changes_summary["by_column"].items())[:5]
1281
+ )
1282
+ }
1283
+ </div>
1284
+ </div>
1285
+ </div>
1286
+ </div>
1287
+
1288
+ <!-- View: Pipeline -->
1289
+ <div id="view-pipeline" class="view-section canvas" style="display: none;">
1290
+ <div class="pipeline-container">
1291
+ {pipeline_html}
1292
+ </div>
1293
+ </div>
1294
+
1295
+ <!-- View: Row Explorer -->
1296
+ <div id="view-row-explorer" class="view-section row-explorer">
1297
+ <div class="row-sidebar">
1298
+ <div style="padding: 16px; border-bottom: 1px solid var(--border-subtle);">
1299
+ <div style="font-size: 0.85rem; color: var(--text-muted);">Event Timeline</div>
1300
+ </div>
1301
+ <div id="row-timeline-list" class="timeline-list">
1302
+ <div class="empty-state" style="padding: 32px;">
1303
+ Search for a Row ID to inspect its journey.
1304
+ </div>
1305
+ </div>
1306
+ </div>
1307
+ <div id="row-detail-view" class="row-main">
1308
+ <!-- Details will be injected here -->
1309
+ <div class="empty-state">
1310
+ <div class="empty-icon">👈</div>
1311
+ <h3>Select a step</h3>
1312
+ <p>Click a step in the timeline to see the row's state at that point.</p>
1313
+ </div>
1314
+ </div>
1315
+ </div>
1316
+
1317
+ </div>
1318
+ </div>
1319
+
1320
+ <!-- Inject Data -->
1321
+ {
1322
+ JAVASCRIPT.replace("__PIPELINE_DATA__", json.dumps(pipeline_data))
1323
+ .replace("__DROPPED_SUMMARY__", json.dumps(dropped_summary))
1324
+ .replace("__CHANGES_SUMMARY__", json.dumps(changes_summary))
1325
+ .replace("__GROUPS_SUMMARY__", json.dumps(groups_summary))
1326
+ .replace("__ROW_INDEX__", json.dumps(row_index))
1327
+ .replace("__SUGGESTED_ROWS__", json.dumps(suggested_rows))
1328
+ .replace("__TOTAL_REGISTERED_ROWS__", str(total_registered))
1329
+ }
1330
+ </body>
1331
+ </html>
1332
+ """
1333
+
1334
+ with open(filepath, "w") as f:
1335
+ f.write(html_content)