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.
Files changed (28) hide show
  1. {mangleframes-0.2.3 → mangleframes-0.2.4}/PKG-INFO +1 -1
  2. {mangleframes-0.2.3 → mangleframes-0.2.4}/pyproject.toml +1 -1
  3. {mangleframes-0.2.3 → mangleframes-0.2.4}/python/mangleframes/__init__.py +2 -8
  4. {mangleframes-0.2.3 → mangleframes-0.2.4}/python/mangleframes/protocol.py +2 -30
  5. {mangleframes-0.2.3 → mangleframes-0.2.4}/viewer/src/handlers.rs +0 -5
  6. mangleframes-0.2.4/viewer/src/main.rs +62 -0
  7. {mangleframes-0.2.3 → mangleframes-0.2.4}/viewer/src/web_server.rs +0 -3
  8. {mangleframes-0.2.3 → mangleframes-0.2.4}/viewer/static/app.js +219 -43
  9. {mangleframes-0.2.3 → mangleframes-0.2.4}/viewer/static/index.html +14 -0
  10. {mangleframes-0.2.3 → mangleframes-0.2.4}/viewer/static/style.css +139 -26
  11. mangleframes-0.2.3/viewer/src/main.rs +0 -143
  12. {mangleframes-0.2.3 → mangleframes-0.2.4}/Cargo.lock +0 -0
  13. {mangleframes-0.2.3 → mangleframes-0.2.4}/Cargo.toml +0 -0
  14. {mangleframes-0.2.3 → mangleframes-0.2.4}/python/mangleframes/launcher.py +0 -0
  15. {mangleframes-0.2.3 → mangleframes-0.2.4}/python/mangleframes/server.py +0 -0
  16. {mangleframes-0.2.3 → mangleframes-0.2.4}/viewer/Cargo.toml +0 -0
  17. {mangleframes-0.2.3 → mangleframes-0.2.4}/viewer/src/arrow_reader.rs +0 -0
  18. {mangleframes-0.2.3 → mangleframes-0.2.4}/viewer/src/dashboard.rs +0 -0
  19. {mangleframes-0.2.3 → mangleframes-0.2.4}/viewer/src/dq_handlers.rs +0 -0
  20. {mangleframes-0.2.3 → mangleframes-0.2.4}/viewer/src/export.rs +0 -0
  21. {mangleframes-0.2.3 → mangleframes-0.2.4}/viewer/src/history_analysis.rs +0 -0
  22. {mangleframes-0.2.3 → mangleframes-0.2.4}/viewer/src/history_handlers.rs +0 -0
  23. {mangleframes-0.2.3 → mangleframes-0.2.4}/viewer/src/join_handlers.rs +0 -0
  24. {mangleframes-0.2.3 → mangleframes-0.2.4}/viewer/src/perf.rs +0 -0
  25. {mangleframes-0.2.3 → mangleframes-0.2.4}/viewer/src/reconcile_handlers.rs +0 -0
  26. {mangleframes-0.2.3 → mangleframes-0.2.4}/viewer/src/socket_client.rs +0 -0
  27. {mangleframes-0.2.3 → mangleframes-0.2.4}/viewer/src/stats.rs +0 -0
  28. {mangleframes-0.2.3 → mangleframes-0.2.4}/viewer/src/websocket.rs +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mangleframes
3
- Version: 0.2.3
3
+ Version: 0.2.4
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.2.3"
7
+ version = "0.2.4"
8
8
  description = "PySpark DataFrame viewer with modern web UI"
9
9
  requires-python = ">=3.11"
10
10
  license = { text = "MIT" }
@@ -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, prefetch_frame
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.3"
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.forEach(col => {
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
- state.columns.forEach(col => {
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
- tbody.innerHTML = `<tr><td colspan="${state.columns.length || 1}" class="error">${msg}</td></tr>`;
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