mangleframes 0.2.3__tar.gz → 0.2.4__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.2.3 → mangleframes-0.2.4}/PKG-INFO +1 -1
- {mangleframes-0.2.3 → mangleframes-0.2.4}/pyproject.toml +1 -1
- {mangleframes-0.2.3 → mangleframes-0.2.4}/python/mangleframes/__init__.py +2 -8
- {mangleframes-0.2.3 → mangleframes-0.2.4}/python/mangleframes/protocol.py +2 -30
- {mangleframes-0.2.3 → mangleframes-0.2.4}/viewer/src/handlers.rs +0 -5
- mangleframes-0.2.4/viewer/src/main.rs +62 -0
- {mangleframes-0.2.3 → mangleframes-0.2.4}/viewer/src/web_server.rs +0 -3
- {mangleframes-0.2.3 → mangleframes-0.2.4}/viewer/static/app.js +219 -43
- {mangleframes-0.2.3 → mangleframes-0.2.4}/viewer/static/index.html +14 -0
- {mangleframes-0.2.3 → mangleframes-0.2.4}/viewer/static/style.css +139 -26
- mangleframes-0.2.3/viewer/src/main.rs +0 -143
- {mangleframes-0.2.3 → mangleframes-0.2.4}/Cargo.lock +0 -0
- {mangleframes-0.2.3 → mangleframes-0.2.4}/Cargo.toml +0 -0
- {mangleframes-0.2.3 → mangleframes-0.2.4}/python/mangleframes/launcher.py +0 -0
- {mangleframes-0.2.3 → mangleframes-0.2.4}/python/mangleframes/server.py +0 -0
- {mangleframes-0.2.3 → mangleframes-0.2.4}/viewer/Cargo.toml +0 -0
- {mangleframes-0.2.3 → mangleframes-0.2.4}/viewer/src/arrow_reader.rs +0 -0
- {mangleframes-0.2.3 → mangleframes-0.2.4}/viewer/src/dashboard.rs +0 -0
- {mangleframes-0.2.3 → mangleframes-0.2.4}/viewer/src/dq_handlers.rs +0 -0
- {mangleframes-0.2.3 → mangleframes-0.2.4}/viewer/src/export.rs +0 -0
- {mangleframes-0.2.3 → mangleframes-0.2.4}/viewer/src/history_analysis.rs +0 -0
- {mangleframes-0.2.3 → mangleframes-0.2.4}/viewer/src/history_handlers.rs +0 -0
- {mangleframes-0.2.3 → mangleframes-0.2.4}/viewer/src/join_handlers.rs +0 -0
- {mangleframes-0.2.3 → mangleframes-0.2.4}/viewer/src/perf.rs +0 -0
- {mangleframes-0.2.3 → mangleframes-0.2.4}/viewer/src/reconcile_handlers.rs +0 -0
- {mangleframes-0.2.3 → mangleframes-0.2.4}/viewer/src/socket_client.rs +0 -0
- {mangleframes-0.2.3 → mangleframes-0.2.4}/viewer/src/stats.rs +0 -0
- {mangleframes-0.2.3 → mangleframes-0.2.4}/viewer/src/websocket.rs +0 -0
|
@@ -4,19 +4,18 @@ from __future__ import annotations
|
|
|
4
4
|
import atexit
|
|
5
5
|
import os
|
|
6
6
|
import subprocess
|
|
7
|
-
import threading
|
|
8
7
|
import time
|
|
9
8
|
from pathlib import Path
|
|
10
9
|
from typing import TYPE_CHECKING
|
|
11
10
|
|
|
12
11
|
from .launcher import launch_viewer
|
|
13
|
-
from .protocol import clear_arrow_cache, clear_stats_cache
|
|
12
|
+
from .protocol import clear_arrow_cache, clear_stats_cache
|
|
14
13
|
from .server import DataFrameServer
|
|
15
14
|
|
|
16
15
|
if TYPE_CHECKING:
|
|
17
16
|
from pyspark.sql import DataFrame
|
|
18
17
|
|
|
19
|
-
__version__ = "0.2.
|
|
18
|
+
__version__ = "0.2.4"
|
|
20
19
|
__all__ = ["register", "unregister", "show", "cleanup"]
|
|
21
20
|
|
|
22
21
|
_registry: dict[str, DataFrame] = {}
|
|
@@ -81,11 +80,6 @@ def register(name: str, df: DataFrame) -> None:
|
|
|
81
80
|
clear_stats_cache(name)
|
|
82
81
|
clear_arrow_cache(name)
|
|
83
82
|
|
|
84
|
-
def do_prefetch() -> None:
|
|
85
|
-
prefetch_frame(_registry, name, limit=10000)
|
|
86
|
-
|
|
87
|
-
threading.Thread(target=do_prefetch, daemon=True).start()
|
|
88
|
-
|
|
89
83
|
if _server is None:
|
|
90
84
|
_server = DataFrameServer(_registry)
|
|
91
85
|
_server.start()
|
|
@@ -64,34 +64,6 @@ def _serialize_arrow_ipc(table: pa.Table) -> tuple[bytes, int]:
|
|
|
64
64
|
return sink.getvalue().to_pybytes(), ipc_ms
|
|
65
65
|
|
|
66
66
|
|
|
67
|
-
def prefetch_frame(registry: dict[str, DataFrame], name: str, limit: int = 10000) -> bool:
|
|
68
|
-
"""Prefetch DataFrame as Arrow IPC bytes in background.
|
|
69
|
-
|
|
70
|
-
Returns True if prefetch succeeded, False otherwise.
|
|
71
|
-
"""
|
|
72
|
-
if name not in registry:
|
|
73
|
-
return False
|
|
74
|
-
|
|
75
|
-
try:
|
|
76
|
-
df = registry[name]
|
|
77
|
-
start = time.perf_counter()
|
|
78
|
-
limited_df = df.limit(limit) if limit > 0 else df
|
|
79
|
-
table = limited_df.toArrow()
|
|
80
|
-
spark_ms = int((time.perf_counter() - start) * 1000)
|
|
81
|
-
total_rows = table.num_rows
|
|
82
|
-
|
|
83
|
-
arrow_bytes, ipc_ms = _serialize_arrow_ipc(table)
|
|
84
|
-
# 24-byte header: spark_ms, ipc_ms, total_rows (all little-endian u64)
|
|
85
|
-
payload = struct.pack("<QQQ", spark_ms, ipc_ms, total_rows) + arrow_bytes
|
|
86
|
-
|
|
87
|
-
with _arrow_cache_lock:
|
|
88
|
-
_arrow_cache[name] = (limit, payload)
|
|
89
|
-
|
|
90
|
-
return True
|
|
91
|
-
except Exception:
|
|
92
|
-
return False
|
|
93
|
-
|
|
94
|
-
|
|
95
67
|
def encode_response(status: int, payload: bytes) -> bytes:
|
|
96
68
|
"""Encode response with status and length prefix."""
|
|
97
69
|
return struct.pack(">II", status, len(payload)) + payload
|
|
@@ -888,8 +860,8 @@ def handle_join_analyze(
|
|
|
888
860
|
|
|
889
861
|
# Matched counts via inner join
|
|
890
862
|
joined = left_df.join(right_df, join_cond, "inner")
|
|
891
|
-
left_matched = joined.select(left_keys).distinct().count()
|
|
892
|
-
right_matched = joined.select(right_keys).distinct().count()
|
|
863
|
+
left_matched = joined.select([left_df[k] for k in left_keys]).distinct().count()
|
|
864
|
+
right_matched = joined.select([right_df[k] for k in right_keys]).distinct().count()
|
|
893
865
|
total_pairs = joined.count()
|
|
894
866
|
|
|
895
867
|
# Cardinality detection
|
|
@@ -41,11 +41,6 @@ pub async fn serve_css() -> impl IntoResponse {
|
|
|
41
41
|
.unwrap()
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
pub async fn get_status(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
|
45
|
-
let ready = *state.preload_complete.read().await;
|
|
46
|
-
Json(json!({"ready": ready}))
|
|
47
|
-
}
|
|
48
|
-
|
|
49
44
|
pub async fn list_frames(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
|
50
45
|
match state.client.list_frames_async().await {
|
|
51
46
|
Ok(names) => Json(json!({"frames": names})).into_response(),
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
//! MangleFrames Viewer - Web-based PySpark DataFrame viewer.
|
|
2
|
+
|
|
3
|
+
mod arrow_reader;
|
|
4
|
+
mod dashboard;
|
|
5
|
+
mod dq_handlers;
|
|
6
|
+
mod export;
|
|
7
|
+
mod handlers;
|
|
8
|
+
mod history_analysis;
|
|
9
|
+
mod history_handlers;
|
|
10
|
+
mod join_handlers;
|
|
11
|
+
mod perf;
|
|
12
|
+
mod reconcile_handlers;
|
|
13
|
+
mod socket_client;
|
|
14
|
+
mod stats;
|
|
15
|
+
mod web_server;
|
|
16
|
+
mod websocket;
|
|
17
|
+
|
|
18
|
+
use std::path::PathBuf;
|
|
19
|
+
use std::sync::Arc;
|
|
20
|
+
|
|
21
|
+
use clap::Parser;
|
|
22
|
+
use tracing::info;
|
|
23
|
+
use tracing_subscriber::EnvFilter;
|
|
24
|
+
|
|
25
|
+
use crate::socket_client::SocketClient;
|
|
26
|
+
use crate::web_server::AppState;
|
|
27
|
+
|
|
28
|
+
#[derive(Parser)]
|
|
29
|
+
#[command(name = "mangleframes-viewer")]
|
|
30
|
+
#[command(about = "Web-based PySpark DataFrame viewer")]
|
|
31
|
+
struct Args {
|
|
32
|
+
#[arg(short, long)]
|
|
33
|
+
socket: PathBuf,
|
|
34
|
+
|
|
35
|
+
#[arg(short, long, default_value = "8765")]
|
|
36
|
+
port: u16,
|
|
37
|
+
|
|
38
|
+
#[arg(long)]
|
|
39
|
+
no_browser: bool,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
#[tokio::main]
|
|
43
|
+
async fn main() -> anyhow::Result<()> {
|
|
44
|
+
tracing_subscriber::fmt()
|
|
45
|
+
.with_env_filter(EnvFilter::from_default_env())
|
|
46
|
+
.init();
|
|
47
|
+
|
|
48
|
+
let args = Args::parse();
|
|
49
|
+
|
|
50
|
+
info!("Connecting to Python server at {:?}", args.socket);
|
|
51
|
+
let client = Arc::new(SocketClient::new(&args.socket));
|
|
52
|
+
let state = AppState::new(client.clone());
|
|
53
|
+
|
|
54
|
+
if !args.no_browser {
|
|
55
|
+
let url = format!("http://localhost:{}", args.port);
|
|
56
|
+
info!("Opening browser at {}", url);
|
|
57
|
+
let _ = webbrowser::open(&url);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
info!("Starting web server on port {}", args.port);
|
|
61
|
+
web_server::run(state, args.port).await
|
|
62
|
+
}
|
|
@@ -47,7 +47,6 @@ pub struct AppState {
|
|
|
47
47
|
pub json_cache: RwLock<HashMap<JsonCacheKey, JsonCacheEntry>>,
|
|
48
48
|
pub broadcast_tx: broadcast::Sender<String>,
|
|
49
49
|
pub perf: Arc<PerfCollector>,
|
|
50
|
-
pub preload_complete: RwLock<bool>,
|
|
51
50
|
}
|
|
52
51
|
|
|
53
52
|
impl AppState {
|
|
@@ -59,7 +58,6 @@ impl AppState {
|
|
|
59
58
|
json_cache: RwLock::new(HashMap::new()),
|
|
60
59
|
broadcast_tx: tx,
|
|
61
60
|
perf: Arc::new(PerfCollector::new()),
|
|
62
|
-
preload_complete: RwLock::new(false),
|
|
63
61
|
})
|
|
64
62
|
}
|
|
65
63
|
|
|
@@ -115,7 +113,6 @@ pub async fn run(state: Arc<AppState>, port: u16) -> anyhow::Result<()> {
|
|
|
115
113
|
.route("/api/perf", get(handlers::get_perf_stats))
|
|
116
114
|
.route("/api/perf/benchmark", post(handlers::run_benchmark))
|
|
117
115
|
.route("/api/perf/stream", post(handlers::stream_benchmark))
|
|
118
|
-
.route("/api/status", get(handlers::get_status))
|
|
119
116
|
.route("/api/join/analyze", post(join_handlers::analyze_join))
|
|
120
117
|
.route("/api/join/unmatched/{side}", get(join_handlers::get_unmatched))
|
|
121
118
|
.route("/api/join/export", post(join_handlers::export_unmatched))
|
|
@@ -9,45 +9,19 @@ const state = {
|
|
|
9
9
|
sortDir: 'asc',
|
|
10
10
|
searchTerm: '',
|
|
11
11
|
loading: false,
|
|
12
|
-
preloadComplete: false,
|
|
13
12
|
tableHeight: null,
|
|
14
13
|
sectionOrder: ['section-stats', 'section-quality', 'section-sql', 'section-join'],
|
|
15
14
|
dqxAvailable: false,
|
|
16
15
|
qualityRules: {},
|
|
17
16
|
qualityResults: null,
|
|
18
|
-
qualitySuggestions: null
|
|
17
|
+
qualitySuggestions: null,
|
|
18
|
+
visibleColumns: [],
|
|
19
|
+
columnOrder: [],
|
|
20
|
+
columnSearchTerm: ''
|
|
19
21
|
};
|
|
20
22
|
|
|
21
23
|
const $ = id => document.getElementById(id);
|
|
22
24
|
|
|
23
|
-
function showLoadingOverlay() {
|
|
24
|
-
const overlay = document.createElement('div');
|
|
25
|
-
overlay.id = 'loading-overlay';
|
|
26
|
-
overlay.innerHTML = `
|
|
27
|
-
<div class="spinner"></div>
|
|
28
|
-
<p>Connecting to Spark...</p>
|
|
29
|
-
`;
|
|
30
|
-
document.body.appendChild(overlay);
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
function hideLoadingOverlay() {
|
|
34
|
-
const overlay = $('loading-overlay');
|
|
35
|
-
if (overlay) overlay.remove();
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
async function checkPreloadStatus() {
|
|
39
|
-
try {
|
|
40
|
-
const data = await fetchJSON('/api/status');
|
|
41
|
-
state.preloadComplete = data.ready;
|
|
42
|
-
if (!data.ready) {
|
|
43
|
-
showLoadingOverlay();
|
|
44
|
-
}
|
|
45
|
-
} catch (e) {
|
|
46
|
-
// Status endpoint unavailable, proceed normally
|
|
47
|
-
state.preloadComplete = true;
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
|
|
51
25
|
async function fetchJSON(url, options = {}) {
|
|
52
26
|
const res = await fetch(url, options);
|
|
53
27
|
if (!res.ok) {
|
|
@@ -76,7 +50,9 @@ async function loadFrames() {
|
|
|
76
50
|
async function loadSchema(name) {
|
|
77
51
|
const data = await fetchJSON(`/api/frames/${name}/schema`);
|
|
78
52
|
state.columns = data.columns || [];
|
|
53
|
+
initColumnState();
|
|
79
54
|
renderHeader();
|
|
55
|
+
renderColumnDropdown();
|
|
80
56
|
}
|
|
81
57
|
|
|
82
58
|
async function loadData(name, offset = 0) {
|
|
@@ -149,7 +125,13 @@ function renderHeader() {
|
|
|
149
125
|
thead.innerHTML = '';
|
|
150
126
|
const tr = document.createElement('tr');
|
|
151
127
|
|
|
152
|
-
state.columns
|
|
128
|
+
// Fall back to state.columns if visibleColumns not initialized
|
|
129
|
+
const colsToRender = state.visibleColumns.length > 0 ? state.visibleColumns : state.columns.map(c => c.name);
|
|
130
|
+
const visibleCols = colsToRender
|
|
131
|
+
.map(name => state.columns.find(c => c.name === name))
|
|
132
|
+
.filter(Boolean);
|
|
133
|
+
|
|
134
|
+
visibleCols.forEach(col => {
|
|
153
135
|
const th = document.createElement('th');
|
|
154
136
|
th.textContent = col.name;
|
|
155
137
|
th.dataset.col = col.name;
|
|
@@ -192,9 +174,15 @@ function renderRows() {
|
|
|
192
174
|
});
|
|
193
175
|
}
|
|
194
176
|
|
|
177
|
+
// Fall back to state.columns if visibleColumns not initialized
|
|
178
|
+
const colsToRender = state.visibleColumns.length > 0 ? state.visibleColumns : state.columns.map(c => c.name);
|
|
179
|
+
const visibleCols = colsToRender
|
|
180
|
+
.map(name => state.columns.find(c => c.name === name))
|
|
181
|
+
.filter(Boolean);
|
|
182
|
+
|
|
195
183
|
rows.forEach(row => {
|
|
196
184
|
const tr = document.createElement('tr');
|
|
197
|
-
|
|
185
|
+
visibleCols.forEach(col => {
|
|
198
186
|
const td = document.createElement('td');
|
|
199
187
|
const val = row[col.name];
|
|
200
188
|
if (val === null) {
|
|
@@ -245,6 +233,187 @@ function toggleSort(colName) {
|
|
|
245
233
|
renderRows();
|
|
246
234
|
}
|
|
247
235
|
|
|
236
|
+
function initColumnState() {
|
|
237
|
+
state.columnOrder = state.columns.map(c => c.name);
|
|
238
|
+
state.visibleColumns = [...state.columnOrder];
|
|
239
|
+
state.columnSearchTerm = '';
|
|
240
|
+
$('column-dropdown-btn').disabled = state.columns.length === 0;
|
|
241
|
+
updateVisibleColCount();
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function updateVisibleColCount() {
|
|
245
|
+
const count = $('visible-col-count');
|
|
246
|
+
const total = state.columnOrder.length;
|
|
247
|
+
const visible = state.visibleColumns.length;
|
|
248
|
+
count.textContent = visible === total ? total : `${visible}/${total}`;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function toggleColumnDropdown() {
|
|
252
|
+
const dropdown = $('column-dropdown');
|
|
253
|
+
dropdown.classList.toggle('open');
|
|
254
|
+
if (dropdown.classList.contains('open')) {
|
|
255
|
+
$('column-search').focus();
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function renderColumnDropdown() {
|
|
260
|
+
const list = $('column-list');
|
|
261
|
+
list.innerHTML = '';
|
|
262
|
+
|
|
263
|
+
const searchTerm = state.columnSearchTerm.toLowerCase();
|
|
264
|
+
|
|
265
|
+
state.columnOrder.forEach(colName => {
|
|
266
|
+
const col = state.columns.find(c => c.name === colName);
|
|
267
|
+
if (!col) return;
|
|
268
|
+
|
|
269
|
+
if (searchTerm && !colName.toLowerCase().includes(searchTerm)) {
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const isVisible = state.visibleColumns.includes(colName);
|
|
274
|
+
|
|
275
|
+
const item = document.createElement('div');
|
|
276
|
+
item.className = 'column-list-item' + (isVisible ? '' : ' hidden-col');
|
|
277
|
+
item.dataset.column = colName;
|
|
278
|
+
item.draggable = true;
|
|
279
|
+
|
|
280
|
+
item.innerHTML = `
|
|
281
|
+
<span class="drag-handle">⋮⋮</span>
|
|
282
|
+
<input type="checkbox" ${isVisible ? 'checked' : ''}>
|
|
283
|
+
<span class="col-name" title="${colName}">${colName}</span>
|
|
284
|
+
<span class="col-type">${col.type.replace('Type', '')}</span>
|
|
285
|
+
`;
|
|
286
|
+
|
|
287
|
+
const checkbox = item.querySelector('input[type="checkbox"]');
|
|
288
|
+
checkbox.onclick = (e) => {
|
|
289
|
+
e.stopPropagation();
|
|
290
|
+
toggleColumnVisibility(colName);
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
item.addEventListener('dragstart', handleColumnDragStart);
|
|
294
|
+
item.addEventListener('dragover', handleColumnDragOver);
|
|
295
|
+
item.addEventListener('dragleave', handleColumnDragLeave);
|
|
296
|
+
item.addEventListener('drop', handleColumnDrop);
|
|
297
|
+
item.addEventListener('dragend', handleColumnDragEnd);
|
|
298
|
+
|
|
299
|
+
list.appendChild(item);
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
let draggedColumn = null;
|
|
304
|
+
|
|
305
|
+
function handleColumnDragStart(e) {
|
|
306
|
+
draggedColumn = e.currentTarget.dataset.column;
|
|
307
|
+
e.currentTarget.classList.add('dragging');
|
|
308
|
+
e.dataTransfer.effectAllowed = 'move';
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function handleColumnDragOver(e) {
|
|
312
|
+
e.preventDefault();
|
|
313
|
+
const item = e.currentTarget;
|
|
314
|
+
if (item.dataset.column !== draggedColumn) {
|
|
315
|
+
item.classList.add('drag-over');
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function handleColumnDragLeave(e) {
|
|
320
|
+
e.currentTarget.classList.remove('drag-over');
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function handleColumnDrop(e) {
|
|
324
|
+
e.preventDefault();
|
|
325
|
+
const targetCol = e.currentTarget.dataset.column;
|
|
326
|
+
e.currentTarget.classList.remove('drag-over');
|
|
327
|
+
|
|
328
|
+
if (targetCol === draggedColumn || !draggedColumn) return;
|
|
329
|
+
|
|
330
|
+
const fromIdx = state.columnOrder.indexOf(draggedColumn);
|
|
331
|
+
const toIdx = state.columnOrder.indexOf(targetCol);
|
|
332
|
+
|
|
333
|
+
state.columnOrder.splice(fromIdx, 1);
|
|
334
|
+
state.columnOrder.splice(toIdx, 0, draggedColumn);
|
|
335
|
+
|
|
336
|
+
const visFromIdx = state.visibleColumns.indexOf(draggedColumn);
|
|
337
|
+
if (visFromIdx >= 0) {
|
|
338
|
+
state.visibleColumns.splice(visFromIdx, 1);
|
|
339
|
+
const visToIdx = state.visibleColumns.indexOf(targetCol);
|
|
340
|
+
if (visToIdx >= 0) {
|
|
341
|
+
state.visibleColumns.splice(visToIdx, 0, draggedColumn);
|
|
342
|
+
} else {
|
|
343
|
+
state.visibleColumns = state.columnOrder.filter(
|
|
344
|
+
c => state.visibleColumns.includes(c) || c === draggedColumn
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
renderColumnDropdown();
|
|
350
|
+
renderHeader();
|
|
351
|
+
renderRows();
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function handleColumnDragEnd(e) {
|
|
355
|
+
e.currentTarget.classList.remove('dragging');
|
|
356
|
+
draggedColumn = null;
|
|
357
|
+
document.querySelectorAll('.column-list-item.drag-over')
|
|
358
|
+
.forEach(el => el.classList.remove('drag-over'));
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function toggleColumnVisibility(colName) {
|
|
362
|
+
const idx = state.visibleColumns.indexOf(colName);
|
|
363
|
+
if (idx >= 0) {
|
|
364
|
+
if (state.visibleColumns.length <= 1) return;
|
|
365
|
+
state.visibleColumns.splice(idx, 1);
|
|
366
|
+
} else {
|
|
367
|
+
const orderIdx = state.columnOrder.indexOf(colName);
|
|
368
|
+
let insertIdx = 0;
|
|
369
|
+
for (let i = 0; i < orderIdx; i++) {
|
|
370
|
+
if (state.visibleColumns.includes(state.columnOrder[i])) {
|
|
371
|
+
insertIdx++;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
state.visibleColumns.splice(insertIdx, 0, colName);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
updateVisibleColCount();
|
|
378
|
+
renderColumnDropdown();
|
|
379
|
+
renderHeader();
|
|
380
|
+
renderRows();
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function selectAllColumns() {
|
|
384
|
+
state.visibleColumns = [...state.columnOrder];
|
|
385
|
+
updateVisibleColCount();
|
|
386
|
+
renderColumnDropdown();
|
|
387
|
+
renderHeader();
|
|
388
|
+
renderRows();
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function clearAllColumns() {
|
|
392
|
+
if (state.columnOrder.length > 0) {
|
|
393
|
+
state.visibleColumns = [state.columnOrder[0]];
|
|
394
|
+
}
|
|
395
|
+
updateVisibleColCount();
|
|
396
|
+
renderColumnDropdown();
|
|
397
|
+
renderHeader();
|
|
398
|
+
renderRows();
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function resetColumns() {
|
|
402
|
+
state.columnOrder = state.columns.map(c => c.name);
|
|
403
|
+
state.visibleColumns = [...state.columnOrder];
|
|
404
|
+
state.columnSearchTerm = '';
|
|
405
|
+
$('column-search').value = '';
|
|
406
|
+
updateVisibleColCount();
|
|
407
|
+
renderColumnDropdown();
|
|
408
|
+
renderHeader();
|
|
409
|
+
renderRows();
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function filterColumnList(searchTerm) {
|
|
413
|
+
state.columnSearchTerm = searchTerm;
|
|
414
|
+
renderColumnDropdown();
|
|
415
|
+
}
|
|
416
|
+
|
|
248
417
|
function updateStatus() {
|
|
249
418
|
const showing = state.searchTerm ? 'filtered' : state.rows.length;
|
|
250
419
|
$('row-count').textContent = `Showing ${showing} of ${state.total.toLocaleString()} rows`;
|
|
@@ -252,7 +421,8 @@ function updateStatus() {
|
|
|
252
421
|
|
|
253
422
|
function showError(msg) {
|
|
254
423
|
const tbody = $('table-body');
|
|
255
|
-
|
|
424
|
+
const colCount = state.visibleColumns.length || state.columns.length || 1;
|
|
425
|
+
tbody.innerHTML = `<tr><td colspan="${colCount}" class="error">${msg}</td></tr>`;
|
|
256
426
|
}
|
|
257
427
|
|
|
258
428
|
async function exportData(format) {
|
|
@@ -487,15 +657,6 @@ function setupWebSocket() {
|
|
|
487
657
|
const msg = JSON.parse(event.data);
|
|
488
658
|
if (msg.type === 'refresh') {
|
|
489
659
|
loadFrames();
|
|
490
|
-
} else if (msg.type === 'preload_complete') {
|
|
491
|
-
state.preloadComplete = true;
|
|
492
|
-
hideLoadingOverlay();
|
|
493
|
-
loadFrames().then(() => {
|
|
494
|
-
if (msg.frame) {
|
|
495
|
-
$('frame-select').value = msg.frame;
|
|
496
|
-
$('frame-select').dispatchEvent(new Event('change'));
|
|
497
|
-
}
|
|
498
|
-
});
|
|
499
660
|
}
|
|
500
661
|
};
|
|
501
662
|
|
|
@@ -507,7 +668,6 @@ function init() {
|
|
|
507
668
|
loadSectionOrder();
|
|
508
669
|
setupResizeHandle();
|
|
509
670
|
setupSectionDrag();
|
|
510
|
-
checkPreloadStatus();
|
|
511
671
|
loadFrames();
|
|
512
672
|
setupInfiniteScroll();
|
|
513
673
|
setupWebSocket();
|
|
@@ -552,6 +712,22 @@ function init() {
|
|
|
552
712
|
btn.onclick = () => exportData(btn.dataset.format);
|
|
553
713
|
});
|
|
554
714
|
|
|
715
|
+
// Column dropdown controls
|
|
716
|
+
$('column-dropdown-btn').onclick = toggleColumnDropdown;
|
|
717
|
+
$('column-search').oninput = (e) => filterColumnList(e.target.value);
|
|
718
|
+
$('select-all-cols').onclick = selectAllColumns;
|
|
719
|
+
$('clear-all-cols').onclick = clearAllColumns;
|
|
720
|
+
$('reset-cols').onclick = resetColumns;
|
|
721
|
+
|
|
722
|
+
// Close dropdown when clicking outside
|
|
723
|
+
document.addEventListener('click', (e) => {
|
|
724
|
+
const dropdown = $('column-dropdown');
|
|
725
|
+
const btn = $('column-dropdown-btn');
|
|
726
|
+
if (!dropdown.contains(e.target) && e.target !== btn && !btn.contains(e.target)) {
|
|
727
|
+
dropdown.classList.remove('open');
|
|
728
|
+
}
|
|
729
|
+
});
|
|
730
|
+
|
|
555
731
|
$('run-sql').onclick = runSQL;
|
|
556
732
|
$('sql-input').onkeydown = (e) => {
|
|
557
733
|
if (e.ctrlKey && e.key === 'Enter') runSQL();
|
|
@@ -20,6 +20,20 @@
|
|
|
20
20
|
<main>
|
|
21
21
|
<div class="toolbar">
|
|
22
22
|
<input type="text" id="search-input" placeholder="Search...">
|
|
23
|
+
<div class="column-dropdown-container">
|
|
24
|
+
<button id="column-dropdown-btn" class="column-dropdown-btn" disabled>
|
|
25
|
+
Columns <span id="visible-col-count"></span>
|
|
26
|
+
</button>
|
|
27
|
+
<div id="column-dropdown" class="column-dropdown">
|
|
28
|
+
<input type="text" id="column-search" placeholder="Search columns...">
|
|
29
|
+
<div class="column-dropdown-actions">
|
|
30
|
+
<button id="select-all-cols">All</button>
|
|
31
|
+
<button id="clear-all-cols">None</button>
|
|
32
|
+
<button id="reset-cols">Reset</button>
|
|
33
|
+
</div>
|
|
34
|
+
<div id="column-list" class="column-list"></div>
|
|
35
|
+
</div>
|
|
36
|
+
</div>
|
|
23
37
|
<div class="export-buttons">
|
|
24
38
|
<button data-format="csv">CSV</button>
|
|
25
39
|
<button data-format="json">JSON</button>
|
|
@@ -72,8 +72,8 @@ main {
|
|
|
72
72
|
|
|
73
73
|
.toolbar {
|
|
74
74
|
display: flex;
|
|
75
|
-
justify-content: space-between;
|
|
76
75
|
align-items: center;
|
|
76
|
+
gap: 12px;
|
|
77
77
|
margin-bottom: 12px;
|
|
78
78
|
}
|
|
79
79
|
|
|
@@ -81,6 +81,10 @@ main {
|
|
|
81
81
|
width: 300px;
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
.toolbar .export-buttons {
|
|
85
|
+
margin-left: auto;
|
|
86
|
+
}
|
|
87
|
+
|
|
84
88
|
.export-buttons {
|
|
85
89
|
display: flex;
|
|
86
90
|
gap: 6px;
|
|
@@ -91,6 +95,140 @@ main {
|
|
|
91
95
|
padding: 4px 10px;
|
|
92
96
|
}
|
|
93
97
|
|
|
98
|
+
.column-dropdown-container {
|
|
99
|
+
position: relative;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
.column-dropdown-btn {
|
|
103
|
+
display: flex;
|
|
104
|
+
align-items: center;
|
|
105
|
+
gap: 6px;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
.column-dropdown-btn:disabled {
|
|
109
|
+
opacity: 0.5;
|
|
110
|
+
cursor: not-allowed;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
#visible-col-count {
|
|
114
|
+
font-size: 11px;
|
|
115
|
+
background: var(--accent);
|
|
116
|
+
color: white;
|
|
117
|
+
padding: 1px 6px;
|
|
118
|
+
border-radius: 10px;
|
|
119
|
+
min-width: 20px;
|
|
120
|
+
text-align: center;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
.column-dropdown {
|
|
124
|
+
display: none;
|
|
125
|
+
position: absolute;
|
|
126
|
+
top: 100%;
|
|
127
|
+
left: 0;
|
|
128
|
+
margin-top: 4px;
|
|
129
|
+
background: var(--bg-secondary);
|
|
130
|
+
border: 1px solid var(--border);
|
|
131
|
+
border-radius: 4px;
|
|
132
|
+
width: 280px;
|
|
133
|
+
max-height: 400px;
|
|
134
|
+
z-index: 100;
|
|
135
|
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
.column-dropdown.open {
|
|
139
|
+
display: block;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
.column-dropdown #column-search {
|
|
143
|
+
width: calc(100% - 16px);
|
|
144
|
+
margin: 8px;
|
|
145
|
+
font-size: 13px;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
.column-dropdown-actions {
|
|
149
|
+
display: flex;
|
|
150
|
+
gap: 4px;
|
|
151
|
+
padding: 0 8px 8px;
|
|
152
|
+
border-bottom: 1px solid var(--border);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
.column-dropdown-actions button {
|
|
156
|
+
flex: 1;
|
|
157
|
+
font-size: 11px;
|
|
158
|
+
padding: 4px 8px;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
.column-list {
|
|
162
|
+
max-height: 300px;
|
|
163
|
+
overflow-y: auto;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
.column-list-item {
|
|
167
|
+
display: flex;
|
|
168
|
+
align-items: center;
|
|
169
|
+
gap: 8px;
|
|
170
|
+
padding: 6px 8px;
|
|
171
|
+
cursor: pointer;
|
|
172
|
+
border-bottom: 1px solid var(--border);
|
|
173
|
+
transition: background 0.15s;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
.column-list-item:last-child {
|
|
177
|
+
border-bottom: none;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
.column-list-item:hover {
|
|
181
|
+
background: var(--bg);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
.column-list-item.dragging {
|
|
185
|
+
opacity: 0.5;
|
|
186
|
+
background: var(--accent);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
.column-list-item.drag-over {
|
|
190
|
+
border-top: 2px solid var(--accent);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
.column-list-item .drag-handle {
|
|
194
|
+
cursor: grab;
|
|
195
|
+
color: var(--text-muted);
|
|
196
|
+
font-size: 12px;
|
|
197
|
+
user-select: none;
|
|
198
|
+
padding: 2px;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
.column-list-item .drag-handle:active {
|
|
202
|
+
cursor: grabbing;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
.column-list-item input[type="checkbox"] {
|
|
206
|
+
width: 14px;
|
|
207
|
+
height: 14px;
|
|
208
|
+
cursor: pointer;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
.column-list-item .col-name {
|
|
212
|
+
flex: 1;
|
|
213
|
+
font-size: 13px;
|
|
214
|
+
overflow: hidden;
|
|
215
|
+
text-overflow: ellipsis;
|
|
216
|
+
white-space: nowrap;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
.column-list-item .col-type {
|
|
220
|
+
font-size: 10px;
|
|
221
|
+
color: var(--text-muted);
|
|
222
|
+
background: var(--bg);
|
|
223
|
+
padding: 2px 6px;
|
|
224
|
+
border-radius: 3px;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
.column-list-item.hidden-col .col-name {
|
|
228
|
+
color: var(--text-muted);
|
|
229
|
+
text-decoration: line-through;
|
|
230
|
+
}
|
|
231
|
+
|
|
94
232
|
.table-container {
|
|
95
233
|
height: var(--table-height, calc(100vh - 320px));
|
|
96
234
|
min-height: 150px;
|
|
@@ -287,31 +425,6 @@ details.drag-over {
|
|
|
287
425
|
margin-right: 8px;
|
|
288
426
|
}
|
|
289
427
|
|
|
290
|
-
#loading-overlay {
|
|
291
|
-
position: fixed;
|
|
292
|
-
inset: 0;
|
|
293
|
-
background: var(--bg);
|
|
294
|
-
display: flex;
|
|
295
|
-
flex-direction: column;
|
|
296
|
-
align-items: center;
|
|
297
|
-
justify-content: center;
|
|
298
|
-
z-index: 1000;
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
#loading-overlay .spinner {
|
|
302
|
-
width: 48px;
|
|
303
|
-
height: 48px;
|
|
304
|
-
border: 4px solid var(--border);
|
|
305
|
-
border-top-color: var(--accent);
|
|
306
|
-
border-radius: 50%;
|
|
307
|
-
animation: spin 1s linear infinite;
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
#loading-overlay p {
|
|
311
|
-
margin-top: 16px;
|
|
312
|
-
color: var(--text-muted);
|
|
313
|
-
}
|
|
314
|
-
|
|
315
428
|
/* Join Analysis Panel */
|
|
316
429
|
.join-panel {
|
|
317
430
|
margin-top: 16px;
|
|
@@ -1,143 +0,0 @@
|
|
|
1
|
-
//! MangleFrames Viewer - Web-based PySpark DataFrame viewer.
|
|
2
|
-
|
|
3
|
-
mod arrow_reader;
|
|
4
|
-
mod dashboard;
|
|
5
|
-
mod dq_handlers;
|
|
6
|
-
mod export;
|
|
7
|
-
mod handlers;
|
|
8
|
-
mod history_analysis;
|
|
9
|
-
mod history_handlers;
|
|
10
|
-
mod join_handlers;
|
|
11
|
-
mod perf;
|
|
12
|
-
mod reconcile_handlers;
|
|
13
|
-
mod socket_client;
|
|
14
|
-
mod stats;
|
|
15
|
-
mod web_server;
|
|
16
|
-
mod websocket;
|
|
17
|
-
|
|
18
|
-
use std::path::PathBuf;
|
|
19
|
-
use std::sync::Arc;
|
|
20
|
-
|
|
21
|
-
use clap::Parser;
|
|
22
|
-
use tracing::info;
|
|
23
|
-
use tracing_subscriber::EnvFilter;
|
|
24
|
-
|
|
25
|
-
use std::time::Instant;
|
|
26
|
-
|
|
27
|
-
use crate::arrow_reader::batches_to_json_bytes;
|
|
28
|
-
use crate::socket_client::SocketClient;
|
|
29
|
-
use crate::web_server::{AppState, CachedFrame, JsonCacheEntry, JsonCacheKey};
|
|
30
|
-
|
|
31
|
-
#[derive(Parser)]
|
|
32
|
-
#[command(name = "mangleframes-viewer")]
|
|
33
|
-
#[command(about = "Web-based PySpark DataFrame viewer")]
|
|
34
|
-
struct Args {
|
|
35
|
-
#[arg(short, long)]
|
|
36
|
-
socket: PathBuf,
|
|
37
|
-
|
|
38
|
-
#[arg(short, long, default_value = "8765")]
|
|
39
|
-
port: u16,
|
|
40
|
-
|
|
41
|
-
#[arg(long)]
|
|
42
|
-
no_browser: bool,
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
#[tokio::main]
|
|
46
|
-
async fn main() -> anyhow::Result<()> {
|
|
47
|
-
tracing_subscriber::fmt()
|
|
48
|
-
.with_env_filter(EnvFilter::from_default_env())
|
|
49
|
-
.init();
|
|
50
|
-
|
|
51
|
-
let args = Args::parse();
|
|
52
|
-
|
|
53
|
-
info!("Connecting to Python server at {:?}", args.socket);
|
|
54
|
-
let client = Arc::new(SocketClient::new(&args.socket));
|
|
55
|
-
let state = AppState::new(client.clone());
|
|
56
|
-
|
|
57
|
-
// Spawn background preload task (non-blocking)
|
|
58
|
-
let preload_state = state.clone();
|
|
59
|
-
tokio::spawn(async move {
|
|
60
|
-
preload_first_frame(preload_state).await;
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
if !args.no_browser {
|
|
64
|
-
let url = format!("http://localhost:{}", args.port);
|
|
65
|
-
info!("Opening browser at {}", url);
|
|
66
|
-
let _ = webbrowser::open(&url);
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
info!("Starting web server on port {}", args.port);
|
|
70
|
-
web_server::run(state, args.port).await
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/// Preload first frame into cache in background for faster initial display.
|
|
74
|
-
async fn preload_first_frame(state: Arc<AppState>) {
|
|
75
|
-
let frames = match state.client.list_frames_async().await {
|
|
76
|
-
Ok(f) => f,
|
|
77
|
-
Err(e) => {
|
|
78
|
-
info!("Failed to list frames during preload: {}", e);
|
|
79
|
-
mark_preload_complete(&state, None).await;
|
|
80
|
-
return;
|
|
81
|
-
}
|
|
82
|
-
};
|
|
83
|
-
|
|
84
|
-
let first = match frames.first() {
|
|
85
|
-
Some(f) => f.clone(),
|
|
86
|
-
None => {
|
|
87
|
-
info!("No frames to preload");
|
|
88
|
-
mark_preload_complete(&state, None).await;
|
|
89
|
-
return;
|
|
90
|
-
}
|
|
91
|
-
};
|
|
92
|
-
|
|
93
|
-
info!("Preloading frame: {}", first);
|
|
94
|
-
if let Ok(response) = state.client.get_frame_async(first.clone(), 10000).await {
|
|
95
|
-
if let Ok(batches) = arrow_reader::parse_arrow_stream(&response.data) {
|
|
96
|
-
let common_limits = [100, 500, 1000, 5000, 10000];
|
|
97
|
-
{
|
|
98
|
-
let mut json_cache = state.json_cache.write().await;
|
|
99
|
-
for &limit in &common_limits {
|
|
100
|
-
let (rows_bytes, _) = batches_to_json_bytes(&batches, 0, limit);
|
|
101
|
-
let total = batches.iter().map(|b| b.num_rows()).sum::<usize>();
|
|
102
|
-
let body = build_prewarm_response(&rows_bytes, total, 0, limit);
|
|
103
|
-
let key = JsonCacheKey { frame: first.clone(), offset: 0, limit };
|
|
104
|
-
json_cache.insert(key, JsonCacheEntry { data: body, created: Instant::now() });
|
|
105
|
-
}
|
|
106
|
-
info!("Pre-warmed JSON cache for {} limits", common_limits.len());
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
let mut cache = state.cache.write().await;
|
|
110
|
-
cache.insert(first.clone(), CachedFrame { batches, stats: None, last_access: Instant::now() });
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
mark_preload_complete(&state, Some(&first)).await;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
/// Mark preload as complete and notify frontend via WebSocket.
|
|
118
|
-
async fn mark_preload_complete(state: &Arc<AppState>, frame: Option<&str>) {
|
|
119
|
-
*state.preload_complete.write().await = true;
|
|
120
|
-
|
|
121
|
-
let msg = match frame {
|
|
122
|
-
Some(f) => format!(r#"{{"type":"preload_complete","frame":"{}"}}"#, f),
|
|
123
|
-
None => r#"{"type":"preload_complete","frame":null}"#.to_string(),
|
|
124
|
-
};
|
|
125
|
-
let _ = state.broadcast_tx.send(msg);
|
|
126
|
-
info!("Preload complete");
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/// Build pre-warm response JSON (cached timing values are zeros)
|
|
130
|
-
fn build_prewarm_response(rows_bytes: &[u8], total: usize, offset: usize, limit: usize) -> Vec<u8> {
|
|
131
|
-
let timing = r#"{"spark_ms":0,"ipc_ms":0,"socket_ms":0,"parse_ms":0,"json_ms":0,"total_ms":0,"rows_fetched":0,"bytes_transferred":0,"cached":true}"#;
|
|
132
|
-
let mut result = Vec::with_capacity(rows_bytes.len() + 200);
|
|
133
|
-
result.extend_from_slice(b"{\"rows\":");
|
|
134
|
-
result.extend_from_slice(rows_bytes);
|
|
135
|
-
result.extend_from_slice(b",\"total\":");
|
|
136
|
-
result.extend_from_slice(total.to_string().as_bytes());
|
|
137
|
-
result.extend_from_slice(b",\"offset\":");
|
|
138
|
-
result.extend_from_slice(offset.to_string().as_bytes());
|
|
139
|
-
result.extend_from_slice(b",\"timing\":");
|
|
140
|
-
result.extend_from_slice(timing.as_bytes());
|
|
141
|
-
result.extend_from_slice(b"}");
|
|
142
|
-
result
|
|
143
|
-
}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|