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.
- {mangleframes-0.1.9 → mangleframes-0.2.1}/Cargo.lock +34 -0
- {mangleframes-0.1.9 → mangleframes-0.2.1}/PKG-INFO +1 -1
- {mangleframes-0.1.9 → mangleframes-0.2.1}/pyproject.toml +1 -1
- {mangleframes-0.1.9 → mangleframes-0.2.1}/python/mangleframes/__init__.py +1 -1
- {mangleframes-0.1.9 → mangleframes-0.2.1}/python/mangleframes/protocol.py +2 -1
- {mangleframes-0.1.9 → mangleframes-0.2.1}/viewer/Cargo.toml +2 -1
- mangleframes-0.2.1/viewer/src/dashboard.rs +428 -0
- {mangleframes-0.1.9 → mangleframes-0.2.1}/viewer/src/join_handlers.rs +7 -20
- {mangleframes-0.1.9 → mangleframes-0.2.1}/viewer/src/main.rs +3 -0
- mangleframes-0.2.1/viewer/src/reconcile.rs +533 -0
- mangleframes-0.2.1/viewer/src/reconcile_handlers.rs +316 -0
- {mangleframes-0.1.9 → mangleframes-0.2.1}/viewer/src/web_server.rs +6 -0
- {mangleframes-0.1.9 → mangleframes-0.2.1}/viewer/static/app.js +645 -0
- {mangleframes-0.1.9 → mangleframes-0.2.1}/viewer/static/index.html +125 -0
- {mangleframes-0.1.9 → mangleframes-0.2.1}/viewer/static/style.css +414 -0
- {mangleframes-0.1.9 → mangleframes-0.2.1}/Cargo.toml +0 -0
- {mangleframes-0.1.9 → mangleframes-0.2.1}/python/mangleframes/launcher.py +0 -0
- {mangleframes-0.1.9 → mangleframes-0.2.1}/python/mangleframes/server.py +0 -0
- {mangleframes-0.1.9 → mangleframes-0.2.1}/viewer/src/arrow_reader.rs +0 -0
- {mangleframes-0.1.9 → mangleframes-0.2.1}/viewer/src/dq_handlers.rs +0 -0
- {mangleframes-0.1.9 → mangleframes-0.2.1}/viewer/src/export.rs +0 -0
- {mangleframes-0.1.9 → mangleframes-0.2.1}/viewer/src/handlers.rs +0 -0
- {mangleframes-0.1.9 → mangleframes-0.2.1}/viewer/src/history_analysis.rs +0 -0
- {mangleframes-0.1.9 → mangleframes-0.2.1}/viewer/src/history_handlers.rs +0 -0
- {mangleframes-0.1.9 → mangleframes-0.2.1}/viewer/src/join_analysis.rs +0 -0
- {mangleframes-0.1.9 → mangleframes-0.2.1}/viewer/src/perf.rs +0 -0
- {mangleframes-0.1.9 → mangleframes-0.2.1}/viewer/src/query_engine.rs +0 -0
- {mangleframes-0.1.9 → mangleframes-0.2.1}/viewer/src/socket_client.rs +0 -0
- {mangleframes-0.1.9 → mangleframes-0.2.1}/viewer/src/stats.rs +0 -0
- {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"
|
|
@@ -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('&', "&")
|
|
425
|
+
.replace('<', "<")
|
|
426
|
+
.replace('>', ">")
|
|
427
|
+
.replace('"', """)
|
|
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::
|
|
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
|
-
//
|
|
175
|
-
|
|
176
|
-
|
|
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;
|