mangleframes 0.1.9__tar.gz → 0.2.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. {mangleframes-0.1.9 → mangleframes-0.2.1}/Cargo.lock +34 -0
  2. {mangleframes-0.1.9 → mangleframes-0.2.1}/PKG-INFO +1 -1
  3. {mangleframes-0.1.9 → mangleframes-0.2.1}/pyproject.toml +1 -1
  4. {mangleframes-0.1.9 → mangleframes-0.2.1}/python/mangleframes/__init__.py +1 -1
  5. {mangleframes-0.1.9 → mangleframes-0.2.1}/python/mangleframes/protocol.py +2 -1
  6. {mangleframes-0.1.9 → mangleframes-0.2.1}/viewer/Cargo.toml +2 -1
  7. mangleframes-0.2.1/viewer/src/dashboard.rs +428 -0
  8. {mangleframes-0.1.9 → mangleframes-0.2.1}/viewer/src/join_handlers.rs +7 -20
  9. {mangleframes-0.1.9 → mangleframes-0.2.1}/viewer/src/main.rs +3 -0
  10. mangleframes-0.2.1/viewer/src/reconcile.rs +533 -0
  11. mangleframes-0.2.1/viewer/src/reconcile_handlers.rs +316 -0
  12. {mangleframes-0.1.9 → mangleframes-0.2.1}/viewer/src/web_server.rs +6 -0
  13. {mangleframes-0.1.9 → mangleframes-0.2.1}/viewer/static/app.js +645 -0
  14. {mangleframes-0.1.9 → mangleframes-0.2.1}/viewer/static/index.html +125 -0
  15. {mangleframes-0.1.9 → mangleframes-0.2.1}/viewer/static/style.css +414 -0
  16. {mangleframes-0.1.9 → mangleframes-0.2.1}/Cargo.toml +0 -0
  17. {mangleframes-0.1.9 → mangleframes-0.2.1}/python/mangleframes/launcher.py +0 -0
  18. {mangleframes-0.1.9 → mangleframes-0.2.1}/python/mangleframes/server.py +0 -0
  19. {mangleframes-0.1.9 → mangleframes-0.2.1}/viewer/src/arrow_reader.rs +0 -0
  20. {mangleframes-0.1.9 → mangleframes-0.2.1}/viewer/src/dq_handlers.rs +0 -0
  21. {mangleframes-0.1.9 → mangleframes-0.2.1}/viewer/src/export.rs +0 -0
  22. {mangleframes-0.1.9 → mangleframes-0.2.1}/viewer/src/handlers.rs +0 -0
  23. {mangleframes-0.1.9 → mangleframes-0.2.1}/viewer/src/history_analysis.rs +0 -0
  24. {mangleframes-0.1.9 → mangleframes-0.2.1}/viewer/src/history_handlers.rs +0 -0
  25. {mangleframes-0.1.9 → mangleframes-0.2.1}/viewer/src/join_analysis.rs +0 -0
  26. {mangleframes-0.1.9 → mangleframes-0.2.1}/viewer/src/perf.rs +0 -0
  27. {mangleframes-0.1.9 → mangleframes-0.2.1}/viewer/src/query_engine.rs +0 -0
  28. {mangleframes-0.1.9 → mangleframes-0.2.1}/viewer/src/socket_client.rs +0 -0
  29. {mangleframes-0.1.9 → mangleframes-0.2.1}/viewer/src/stats.rs +0 -0
  30. {mangleframes-0.1.9 → mangleframes-0.2.1}/viewer/src/websocket.rs +0 -0
@@ -418,6 +418,7 @@ dependencies = [
418
418
  "matchit",
419
419
  "memchr",
420
420
  "mime",
421
+ "multer",
421
422
  "percent-encoding",
422
423
  "pin-project-lite",
423
424
  "serde_core",
@@ -1280,6 +1281,15 @@ version = "1.15.0"
1280
1281
  source = "registry+https://github.com/rust-lang/crates.io-index"
1281
1282
  checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
1282
1283
 
1284
+ [[package]]
1285
+ name = "encoding_rs"
1286
+ version = "0.8.35"
1287
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1288
+ checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
1289
+ dependencies = [
1290
+ "cfg-if",
1291
+ ]
1292
+
1283
1293
  [[package]]
1284
1294
  name = "equivalent"
1285
1295
  version = "1.0.2"
@@ -1957,6 +1967,7 @@ dependencies = [
1957
1967
  "arrow-ipc",
1958
1968
  "arrow-json",
1959
1969
  "axum",
1970
+ "base64",
1960
1971
  "chrono",
1961
1972
  "clap",
1962
1973
  "datafusion",
@@ -2041,6 +2052,23 @@ dependencies = [
2041
2052
  "windows-sys 0.61.2",
2042
2053
  ]
2043
2054
 
2055
+ [[package]]
2056
+ name = "multer"
2057
+ version = "3.1.0"
2058
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2059
+ checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b"
2060
+ dependencies = [
2061
+ "bytes",
2062
+ "encoding_rs",
2063
+ "futures-util",
2064
+ "http",
2065
+ "httparse",
2066
+ "memchr",
2067
+ "mime",
2068
+ "spin",
2069
+ "version_check",
2070
+ ]
2071
+
2044
2072
  [[package]]
2045
2073
  name = "ndk-context"
2046
2074
  version = "0.1.1"
@@ -2767,6 +2795,12 @@ dependencies = [
2767
2795
  "windows-sys 0.60.2",
2768
2796
  ]
2769
2797
 
2798
+ [[package]]
2799
+ name = "spin"
2800
+ version = "0.9.8"
2801
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2802
+ checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
2803
+
2770
2804
  [[package]]
2771
2805
  name = "sqlparser"
2772
2806
  version = "0.53.0"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mangleframes
3
- Version: 0.1.9
3
+ Version: 0.2.1
4
4
  Classifier: Programming Language :: Python :: 3
5
5
  Classifier: Programming Language :: Rust
6
6
  Classifier: License :: OSI Approved :: MIT License
@@ -4,7 +4,7 @@ build-backend = "maturin"
4
4
 
5
5
  [project]
6
6
  name = "mangleframes"
7
- version = "0.1.9"
7
+ version = "0.2.1"
8
8
  description = "PySpark DataFrame viewer with modern web UI"
9
9
  requires-python = ">=3.11"
10
10
  license = { text = "MIT" }
@@ -16,7 +16,7 @@ from .server import DataFrameServer
16
16
  if TYPE_CHECKING:
17
17
  from pyspark.sql import DataFrame
18
18
 
19
- __version__ = "0.1.9"
19
+ __version__ = "0.2.1"
20
20
  __all__ = ["register", "unregister", "show", "cleanup"]
21
21
 
22
22
  _registry: dict[str, DataFrame] = {}
@@ -131,8 +131,9 @@ def handle_get(registry: dict[str, DataFrame], name: str, limit: int) -> bytes:
131
131
  return encode_error(f"DataFrame '{name}' not found")
132
132
 
133
133
  # Check cache first - return cached data if limit is sufficient
134
+ # limit=0 means "fetch all rows", so bypass cache in that case
134
135
  with _arrow_cache_lock:
135
- if name in _arrow_cache:
136
+ if name in _arrow_cache and limit > 0:
136
137
  cached_limit, cached_payload = _arrow_cache[name]
137
138
  if cached_limit >= limit:
138
139
  return encode_response(STATUS_OK, cached_payload)
@@ -9,7 +9,7 @@ path = "src/main.rs"
9
9
 
10
10
  [dependencies]
11
11
  tokio = { version = "1", features = ["full"] }
12
- axum = { version = "0.8", features = ["ws"] }
12
+ axum = { version = "0.8", features = ["ws", "multipart"] }
13
13
  tower-http = { version = "0.6", features = ["cors", "fs"] }
14
14
  rust-embed = "8"
15
15
  arrow = { version = "54", features = ["ipc"] }
@@ -28,6 +28,7 @@ thiserror = "2"
28
28
  anyhow = "1"
29
29
  futures-util = "0.3"
30
30
  chrono = "0.4"
31
+ base64 = "0.22"
31
32
 
32
33
  [profile.release]
33
34
  lto = true
@@ -0,0 +1,428 @@
1
+ //! HTML dashboard generation for reconciliation reports.
2
+
3
+ use crate::reconcile::{AggregatedReconcileResult, ColumnTotalComparison};
4
+ use chrono::Utc;
5
+
6
+ /// Metadata for the dashboard report.
7
+ pub struct DashboardMetadata {
8
+ pub source_frame: String,
9
+ pub target_frame: String,
10
+ pub source_type: String,
11
+ pub group_by_source: Vec<String>,
12
+ pub group_by_target: Vec<String>,
13
+ pub join_keys_source: Vec<String>,
14
+ pub join_keys_target: Vec<String>,
15
+ }
16
+
17
+ /// Generate a self-contained HTML dashboard report.
18
+ pub fn generate_reconcile_dashboard(
19
+ result: &AggregatedReconcileResult,
20
+ metadata: &DashboardMetadata,
21
+ ) -> String {
22
+ let timestamp = Utc::now().format("%Y-%m-%d %H:%M:%S UTC").to_string();
23
+ let stats = &result.statistics;
24
+
25
+ let status_class = if stats.match_rate >= 0.99 { "pass" } else { "warn" };
26
+ let status_text = if stats.match_rate >= 0.99 { "PASS" } else { "REVIEW" };
27
+
28
+ format!(
29
+ r#"<!DOCTYPE html>
30
+ <html lang="en">
31
+ <head>
32
+ <meta charset="UTF-8">
33
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
34
+ <title>Reconciliation Report - {} vs {}</title>
35
+ <style>{}</style>
36
+ </head>
37
+ <body>
38
+ <header>
39
+ <h1>Reconciliation Report</h1>
40
+ <div class="status-badge {}">{}</div>
41
+ </header>
42
+
43
+ <section class="metadata">
44
+ <h2>Report Details</h2>
45
+ <table class="metadata-table">
46
+ <tr><td>Generated</td><td>{}</td></tr>
47
+ <tr><td>Source Frame</td><td>{} ({})</td></tr>
48
+ <tr><td>Target Frame</td><td>{}</td></tr>
49
+ <tr><td>Source Group By</td><td>{}</td></tr>
50
+ <tr><td>Target Group By</td><td>{}</td></tr>
51
+ <tr><td>Join Keys (Source)</td><td>{}</td></tr>
52
+ <tr><td>Join Keys (Target)</td><td>{}</td></tr>
53
+ </table>
54
+ </section>
55
+
56
+ <section class="summary">
57
+ <h2>Executive Summary</h2>
58
+ <div class="stats-grid">
59
+ <div class="stat-card">
60
+ <div class="stat-value">{:.1}%</div>
61
+ <div class="stat-label">Match Rate</div>
62
+ </div>
63
+ <div class="stat-card">
64
+ <div class="stat-value">{}</div>
65
+ <div class="stat-label">Source Groups</div>
66
+ </div>
67
+ <div class="stat-card">
68
+ <div class="stat-value">{}</div>
69
+ <div class="stat-label">Target Groups</div>
70
+ </div>
71
+ <div class="stat-card">
72
+ <div class="stat-value">{}</div>
73
+ <div class="stat-label">Matched Groups</div>
74
+ </div>
75
+ <div class="stat-card highlight-warn">
76
+ <div class="stat-value">{}</div>
77
+ <div class="stat-label">Source Only</div>
78
+ </div>
79
+ <div class="stat-card highlight-warn">
80
+ <div class="stat-value">{}</div>
81
+ <div class="stat-label">Target Only</div>
82
+ </div>
83
+ </div>
84
+ </section>
85
+
86
+ <section class="column-totals">
87
+ <h2>Column Totals Comparison</h2>
88
+ {}
89
+ </section>
90
+
91
+ <section class="differences">
92
+ <h2>Detailed Differences</h2>
93
+
94
+ <div class="subsection">
95
+ <h3>Source Only ({} groups)</h3>
96
+ {}
97
+ </div>
98
+
99
+ <div class="subsection">
100
+ <h3>Target Only ({} groups)</h3>
101
+ {}
102
+ </div>
103
+ </section>
104
+
105
+ <section class="matched-sample">
106
+ <h2>Matched Sample ({} of {} groups)</h2>
107
+ {}
108
+ </section>
109
+
110
+ <footer>
111
+ <p>Generated by MangleFrames | {}</p>
112
+ </footer>
113
+ </body>
114
+ </html>"#,
115
+ metadata.source_frame,
116
+ metadata.target_frame,
117
+ get_css(),
118
+ status_class,
119
+ status_text,
120
+ timestamp,
121
+ metadata.source_frame,
122
+ metadata.source_type,
123
+ metadata.target_frame,
124
+ metadata.group_by_source.join(", "),
125
+ metadata.group_by_target.join(", "),
126
+ metadata.join_keys_source.join(", "),
127
+ metadata.join_keys_target.join(", "),
128
+ stats.match_rate * 100.0,
129
+ stats.source_groups,
130
+ stats.target_groups,
131
+ stats.matched_groups,
132
+ stats.source_only_groups,
133
+ stats.target_only_groups,
134
+ render_column_totals_table(&result.column_totals),
135
+ result.source_only.total,
136
+ render_json_rows_table(&result.source_only.rows, 100),
137
+ result.target_only.total,
138
+ render_json_rows_table(&result.target_only.rows, 100),
139
+ result.matched_rows.rows.len(),
140
+ result.matched_rows.total,
141
+ render_json_rows_table(&result.matched_rows.rows, 50),
142
+ timestamp
143
+ )
144
+ }
145
+
146
+ fn get_css() -> &'static str {
147
+ r#"
148
+ * { box-sizing: border-box; margin: 0; padding: 0; }
149
+
150
+ body {
151
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
152
+ line-height: 1.5;
153
+ color: #1a1a2e;
154
+ background: #fff;
155
+ padding: 40px;
156
+ max-width: 1200px;
157
+ margin: 0 auto;
158
+ }
159
+
160
+ header {
161
+ display: flex;
162
+ justify-content: space-between;
163
+ align-items: center;
164
+ border-bottom: 2px solid #1a1a2e;
165
+ padding-bottom: 20px;
166
+ margin-bottom: 30px;
167
+ }
168
+
169
+ h1 { font-size: 28px; font-weight: 700; }
170
+ h2 { font-size: 20px; font-weight: 600; margin-bottom: 16px; color: #1a1a2e; }
171
+ h3 { font-size: 16px; font-weight: 600; margin-bottom: 12px; color: #444; }
172
+
173
+ .status-badge {
174
+ padding: 8px 20px;
175
+ border-radius: 20px;
176
+ font-weight: 700;
177
+ font-size: 14px;
178
+ text-transform: uppercase;
179
+ }
180
+
181
+ .status-badge.pass { background: #d4edda; color: #155724; }
182
+ .status-badge.warn { background: #fff3cd; color: #856404; }
183
+
184
+ section { margin-bottom: 40px; }
185
+
186
+ .metadata-table {
187
+ width: 100%;
188
+ border-collapse: collapse;
189
+ font-size: 14px;
190
+ }
191
+
192
+ .metadata-table td {
193
+ padding: 8px 12px;
194
+ border-bottom: 1px solid #e0e0e0;
195
+ }
196
+
197
+ .metadata-table td:first-child {
198
+ font-weight: 600;
199
+ width: 200px;
200
+ color: #666;
201
+ }
202
+
203
+ .stats-grid {
204
+ display: grid;
205
+ grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
206
+ gap: 16px;
207
+ }
208
+
209
+ .stat-card {
210
+ background: #f8f9fa;
211
+ border: 1px solid #e0e0e0;
212
+ border-radius: 8px;
213
+ padding: 20px;
214
+ text-align: center;
215
+ }
216
+
217
+ .stat-card.highlight-warn { border-left: 4px solid #ffc107; }
218
+
219
+ .stat-value {
220
+ font-size: 28px;
221
+ font-weight: 700;
222
+ color: #1a1a2e;
223
+ font-family: 'SF Mono', Monaco, monospace;
224
+ }
225
+
226
+ .stat-label {
227
+ font-size: 12px;
228
+ color: #666;
229
+ text-transform: uppercase;
230
+ margin-top: 4px;
231
+ }
232
+
233
+ table.data-table {
234
+ width: 100%;
235
+ border-collapse: collapse;
236
+ font-size: 13px;
237
+ margin-top: 12px;
238
+ }
239
+
240
+ table.data-table th,
241
+ table.data-table td {
242
+ padding: 10px 12px;
243
+ text-align: left;
244
+ border-bottom: 1px solid #e0e0e0;
245
+ }
246
+
247
+ table.data-table th {
248
+ background: #f8f9fa;
249
+ font-weight: 600;
250
+ font-size: 11px;
251
+ text-transform: uppercase;
252
+ color: #666;
253
+ }
254
+
255
+ table.data-table tr:hover { background: #f8f9fa; }
256
+
257
+ .diff-positive { color: #28a745; }
258
+ .diff-negative { color: #dc3545; }
259
+ .diff-zero { color: #666; }
260
+
261
+ .subsection { margin-bottom: 24px; }
262
+
263
+ .empty-notice {
264
+ padding: 20px;
265
+ text-align: center;
266
+ color: #666;
267
+ font-style: italic;
268
+ background: #f8f9fa;
269
+ border-radius: 4px;
270
+ }
271
+
272
+ footer {
273
+ margin-top: 40px;
274
+ padding-top: 20px;
275
+ border-top: 1px solid #e0e0e0;
276
+ text-align: center;
277
+ font-size: 12px;
278
+ color: #666;
279
+ }
280
+
281
+ @media print {
282
+ body { padding: 20px; }
283
+ section { page-break-inside: avoid; }
284
+ }
285
+ "#
286
+ }
287
+
288
+ fn render_column_totals_table(totals: &[ColumnTotalComparison]) -> String {
289
+ if totals.is_empty() {
290
+ return r#"<div class="empty-notice">No aggregation columns configured</div>"#.to_string();
291
+ }
292
+
293
+ let mut rows = String::new();
294
+ for total in totals {
295
+ let diff_class = match total.difference {
296
+ Some(d) if d > 0.001 => "diff-positive",
297
+ Some(d) if d < -0.001 => "diff-negative",
298
+ _ => "diff-zero",
299
+ };
300
+
301
+ rows.push_str(&format!(
302
+ r#"<tr>
303
+ <td>{}</td>
304
+ <td>{}</td>
305
+ <td>{}</td>
306
+ <td>{}</td>
307
+ <td class="{}">{}</td>
308
+ <td>{}</td>
309
+ </tr>"#,
310
+ total.column,
311
+ total.aggregation,
312
+ format_number(total.source_total),
313
+ format_number(total.target_total),
314
+ diff_class,
315
+ format_number(total.difference),
316
+ format_percent(total.percent_diff)
317
+ ));
318
+ }
319
+
320
+ format!(
321
+ r#"<table class="data-table">
322
+ <thead>
323
+ <tr>
324
+ <th>Column</th>
325
+ <th>Aggregation</th>
326
+ <th>Source Total</th>
327
+ <th>Target Total</th>
328
+ <th>Difference</th>
329
+ <th>% Diff</th>
330
+ </tr>
331
+ </thead>
332
+ <tbody>{}</tbody>
333
+ </table>"#,
334
+ rows
335
+ )
336
+ }
337
+
338
+ fn render_json_rows_table(rows: &[serde_json::Value], max_rows: usize) -> String {
339
+ if rows.is_empty() {
340
+ return r#"<div class="empty-notice">No rows to display</div>"#.to_string();
341
+ }
342
+
343
+ let columns: Vec<String> = if let Some(obj) = rows[0].as_object() {
344
+ obj.keys().cloned().collect()
345
+ } else {
346
+ return r#"<div class="empty-notice">Invalid data format</div>"#.to_string();
347
+ };
348
+
349
+ let header: String = columns.iter()
350
+ .map(|c| format!("<th>{}</th>", html_escape(c)))
351
+ .collect();
352
+
353
+ let display_rows = rows.iter().take(max_rows);
354
+ let mut body = String::new();
355
+ for row in display_rows {
356
+ if let Some(obj) = row.as_object() {
357
+ let cells: String = columns.iter()
358
+ .map(|col| {
359
+ let val = obj.get(col)
360
+ .map(|v| format_json_value(v))
361
+ .unwrap_or_default();
362
+ format!("<td>{}</td>", html_escape(&val))
363
+ })
364
+ .collect();
365
+ body.push_str(&format!("<tr>{}</tr>", cells));
366
+ }
367
+ }
368
+
369
+ let truncated_notice = if rows.len() > max_rows {
370
+ format!(r#"<div class="empty-notice">Showing {} of {} rows</div>"#, max_rows, rows.len())
371
+ } else {
372
+ String::new()
373
+ };
374
+
375
+ format!(
376
+ r#"<table class="data-table">
377
+ <thead><tr>{}</tr></thead>
378
+ <tbody>{}</tbody>
379
+ </table>
380
+ {}"#,
381
+ header, body, truncated_notice
382
+ )
383
+ }
384
+
385
+ fn format_number(val: Option<f64>) -> String {
386
+ match val {
387
+ Some(v) if v.abs() >= 1_000_000.0 => format!("{:.2}M", v / 1_000_000.0),
388
+ Some(v) if v.abs() >= 1_000.0 => format!("{:.2}K", v / 1_000.0),
389
+ Some(v) => format!("{:.2}", v),
390
+ None => "-".to_string(),
391
+ }
392
+ }
393
+
394
+ fn format_percent(val: Option<f64>) -> String {
395
+ match val {
396
+ Some(v) => format!("{:.2}%", v),
397
+ None => "-".to_string(),
398
+ }
399
+ }
400
+
401
+ fn format_json_value(val: &serde_json::Value) -> String {
402
+ match val {
403
+ serde_json::Value::Null => "".to_string(),
404
+ serde_json::Value::Bool(b) => b.to_string(),
405
+ serde_json::Value::Number(n) => {
406
+ if let Some(f) = n.as_f64() {
407
+ if f.abs() >= 1_000_000.0 {
408
+ format!("{:.2}M", f / 1_000_000.0)
409
+ } else if f.abs() >= 1_000.0 {
410
+ format!("{:.2}K", f / 1_000.0)
411
+ } else {
412
+ format!("{:.2}", f)
413
+ }
414
+ } else {
415
+ n.to_string()
416
+ }
417
+ }
418
+ serde_json::Value::String(s) => s.clone(),
419
+ _ => val.to_string(),
420
+ }
421
+ }
422
+
423
+ fn html_escape(s: &str) -> String {
424
+ s.replace('&', "&amp;")
425
+ .replace('<', "&lt;")
426
+ .replace('>', "&gt;")
427
+ .replace('"', "&quot;")
428
+ }
@@ -1,7 +1,6 @@
1
1
  //! HTTP handlers for join analysis endpoints.
2
2
 
3
3
  use std::sync::Arc;
4
- use std::time::Instant;
5
4
 
6
5
  use arrow::record_batch::RecordBatch;
7
6
  use axum::body::Body;
@@ -15,7 +14,10 @@ use serde_json::json;
15
14
  use crate::arrow_reader;
16
15
  use crate::export;
17
16
  use crate::join_analysis::JoinAnalyzer;
18
- use crate::web_server::{AppState, CachedFrame};
17
+ use crate::web_server::AppState;
18
+
19
+ /// Limit of 0 means fetch all rows (no limit)
20
+ const JOIN_FETCH_LIMIT: usize = 0;
19
21
 
20
22
  #[derive(Deserialize)]
21
23
  pub struct JoinRequest {
@@ -171,30 +173,15 @@ async fn get_or_fetch_frame(
171
173
  state: &AppState,
172
174
  frame_name: &str,
173
175
  ) -> Result<Vec<RecordBatch>, axum::response::Response> {
174
- // Check cache first
175
- {
176
- let cache = state.cache.read().await;
177
- if let Some(cached) = cache.get(frame_name) {
178
- return Ok(cached.batches.clone());
179
- }
180
- }
181
-
182
- // Fetch from Python server
183
- let response = state.client.get_frame(frame_name, 100000).map_err(|e| {
176
+ // Always fetch fresh for join analysis to ensure we get all rows
177
+ // (UI cache may have truncated data from paginated viewing)
178
+ let response = state.client.get_frame(frame_name, JOIN_FETCH_LIMIT).map_err(|e| {
184
179
  error_response(StatusCode::NOT_FOUND, &format!("Failed to fetch {}: {}", frame_name, e))
185
180
  })?;
186
181
 
187
182
  let batches = arrow_reader::parse_arrow_stream(&response.data)
188
183
  .map_err(|e| error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()))?;
189
184
 
190
- // Cache for future use
191
- state.evict_frame_if_needed().await;
192
- let mut cache = state.cache.write().await;
193
- cache.insert(
194
- frame_name.to_string(),
195
- CachedFrame { batches: batches.clone(), stats: None, last_access: Instant::now() },
196
- );
197
-
198
185
  Ok(batches)
199
186
  }
200
187
 
@@ -1,6 +1,7 @@
1
1
  //! MangleFrames Viewer - Web-based PySpark DataFrame viewer.
2
2
 
3
3
  mod arrow_reader;
4
+ mod dashboard;
4
5
  mod dq_handlers;
5
6
  mod export;
6
7
  mod handlers;
@@ -9,6 +10,8 @@ mod history_handlers;
9
10
  mod join_analysis;
10
11
  mod join_handlers;
11
12
  mod perf;
13
+ mod reconcile;
14
+ mod reconcile_handlers;
12
15
  mod query_engine;
13
16
  mod socket_client;
14
17
  mod stats;