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.
- tracepipe/__init__.py +110 -0
- tracepipe/api.py +563 -0
- tracepipe/context.py +98 -0
- tracepipe/core.py +122 -0
- tracepipe/instrumentation/__init__.py +6 -0
- tracepipe/instrumentation/pandas_inst.py +1024 -0
- tracepipe/safety.py +178 -0
- tracepipe/storage/__init__.py +13 -0
- tracepipe/storage/base.py +174 -0
- tracepipe/storage/lineage_store.py +556 -0
- tracepipe/storage/row_identity.py +217 -0
- tracepipe/utils/__init__.py +6 -0
- tracepipe/utils/value_capture.py +137 -0
- tracepipe/visualization/__init__.py +6 -0
- tracepipe/visualization/html_export.py +1335 -0
- tracepipe-0.2.0.dist-info/METADATA +508 -0
- tracepipe-0.2.0.dist-info/RECORD +19 -0
- tracepipe-0.2.0.dist-info/WHEEL +4 -0
- tracepipe-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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)
|