From fab448785b3607f32f41d7a0955a9952fe0b92a5 Mon Sep 17 00:00:00 2001 From: Erick Ahmed Date: Wed, 22 Jul 2026 23:09:47 +0200 Subject: [PATCH] Replace infinite scroll with paginated log view --- logs/view.py | 15 +++-- main.py | 155 +++++++++++++++++++++++++++++++-------------------- 2 files changed, 105 insertions(+), 65 deletions(-) diff --git a/logs/view.py b/logs/view.py index 7bbf4d3..88b377f 100644 --- a/logs/view.py +++ b/logs/view.py @@ -3,7 +3,10 @@ # SPDX-License-Identifier: AGPL-3.0-or-later import pandas as pd -from dash import html, dash_table +from dash import html, dash_table, dcc +import dash_bootstrap_components as dbc + +PAGE_SIZE = 25000 def prepare_logs_data(df: pd.DataFrame) -> pd.DataFrame: if df is None or df.empty: @@ -44,7 +47,7 @@ def get_logs_table_component(): id='logs-table', virtualization=True, page_action='none', - style_table={'overflowX': 'auto', 'height': '75vh', 'overflowY': 'auto'}, + style_table={'overflowX': 'auto', 'height': '70vh', 'overflowY': 'auto'}, style_header={ 'backgroundColor': '#1a1a1a', 'color': 'white', @@ -71,6 +74,10 @@ def get_logs_table_component(): 'fontFamily': 'Segoe UI, Arial, sans-serif' } ), - html.Button("Load More", id="load-more-logs-btn", n_clicks=0, style={'display': 'none'}), - html.Div(id='dummy-output', style={'display': 'none'}) + html.Div([ + dbc.Button("Prev", id='logs-prev-btn', color="secondary", outline=True, size="sm", className="me-2"), + html.Div(id='logs-page-nav', className="d-inline-block", style={'verticalAlign': 'middle'}), + dbc.Button("Next", id='logs-next-btn', color="secondary", outline=True, size="sm", className="ms-2"), + ], className="d-flex justify-content-center align-items-center mt-3"), + dcc.Store(id='logs-current-page', data=0), ]) diff --git a/main.py b/main.py index afab781..8b01a27 100644 --- a/main.py +++ b/main.py @@ -22,6 +22,7 @@ from stats.entropy import calculate_byte_entropy, plot_entropy_heatmap from logs.view import get_logs_table_component, prepare_logs_data RAW_LOG_DIR = "data/logs" +PAGE_SIZE = 25000 def parse_vehicle_from_filename(filename: str): stem = Path(filename).stem @@ -86,6 +87,7 @@ for log_file in Path(RAW_LOG_DIR).glob("*.txt"): PRECOMPUTED_FIGURES = {} DATA_BY_ID = {} CORR_CACHE = {} +PREPARED_LOGS_CACHE = {} def process_bus_data(vehicle, bus, df): precomp = {} @@ -176,85 +178,116 @@ app.layout = dbc.Container([ ], id="main-tabs", active_tab="statistics") ], fluid=True) -app.clientside_callback( - """ - function(data) { - if (!data) return ''; - requestAnimationFrame(() => { - const btn = document.getElementById('load-more-logs-btn'); - const info = document.getElementById('logs-info-text'); - if (!btn || !info || info.innerText.toLowerCase().includes('all')) { - return; - } +def get_prepared_logs(vehicle, bus): + cache_key = (vehicle, bus) + if cache_key not in PREPARED_LOGS_CACHE: + df = DATA[vehicle][bus] + PREPARED_LOGS_CACHE[cache_key] = prepare_logs_data(df) + return PREPARED_LOGS_CACHE[cache_key] - btn.dataset.loading = "false"; +def build_page_buttons(current_page: int, total_pages: int, max_buttons: int = 15) -> list: + buttons: list = [] + if total_pages <= 1: + return buttons - const containers = document.querySelectorAll('.dash-spreadsheet-container, .dash-spreadsheet-inner'); - if (containers.length === 0) return; + half = max_buttons // 2 + start = max(0, current_page - half) + end = min(total_pages, start + max_buttons) + if end - start < max_buttons: + start = max(0, end - max_buttons) - containers.forEach(container => { - container.onscroll = function() { - if (container.scrollHeight - container.scrollTop - container.clientHeight < 1500) { - if (btn && btn.dataset.loading === "false") { - btn.dataset.loading = "true"; - btn.click(); - } - } - }; + if start > 0: + buttons.append( + dbc.Button("1", id={'type': 'page-btn', 'index': 0}, color="secondary", outline=True, size="sm", className="me-1") + ) + if start > 1: + buttons.append(html.Span("…", className="mx-1 align-middle")) - if (container.scrollHeight - container.scrollTop - container.clientHeight < 1500) { - if (btn && btn.dataset.loading === "false") { - btn.dataset.loading = "true"; - btn.click(); - } - } - }); - }); - return ''; - } - """, - Output('dummy-output', 'children'), - Input('logs-table', 'data') -) + for i in range(start, end): + is_current = (i == current_page) + buttons.append( + dbc.Button( + str(i + 1), + id={'type': 'page-btn', 'index': i}, + size="sm", + color="primary" if is_current else "secondary", + outline=not is_current, + className="me-1", + disabled=is_current, + ) + ) + + if end < total_pages: + if end < total_pages - 1: + buttons.append(html.Span("…", className="mx-1 align-middle")) + buttons.append( + dbc.Button( + str(total_pages), + id={'type': 'page-btn', 'index': total_pages - 1}, + color="secondary", outline=True, size="sm", className="me-1", + ) + ) + + return buttons @app.callback( Output('logs-table', 'data'), Output('logs-table', 'columns'), Output('logs-info-text', 'children'), + Output('logs-page-nav', 'children'), + Output('logs-current-page', 'data'), Input('logs-vehicle-selector', 'value'), Input('logs-bus-selector', 'value'), - Input('load-more-logs-btn', 'n_clicks'), - State('logs-table', 'data'), + Input('logs-prev-btn', 'n_clicks'), + Input('logs-next-btn', 'n_clicks'), + Input({'type': 'page-btn', 'index': dash.ALL}, 'n_clicks'), + State('logs-current-page', 'data'), ) -def update_logs_table(vehicle, bus, n_clicks, current_data): - ctx = dash.callback_context - trigger_id = ctx.triggered[0]['prop_id'].split('.')[0] if ctx.triggered else '' - - if not vehicle or not bus or vehicle not in DATA or bus not in DATA[vehicle]: - return [], [], "No data available" - - df = DATA[vehicle][bus] - prepared_df = prepare_logs_data(df) +def update_logs_table(vehicle, bus, prev_clicks, next_clicks, page_btn_clicks, current_page): + if (not vehicle or not bus or vehicle not in DATA or bus not in DATA[vehicle]): + return [], [], "No data available", [], 0 + prepared_df = get_prepared_logs(vehicle, bus) total_rows = len(prepared_df) + + if total_rows == 0: + return [], [], "No data available", [], 0 + + total_pages = max(1, (total_rows + PAGE_SIZE - 1) // PAGE_SIZE) + + ctx = dash.callback_context + triggered_id = ctx.triggered_id + + current_page = current_page if current_page is not None else 0 + + if triggered_id in ('logs-vehicle-selector', 'logs-bus-selector'): + current_page = 0 + + elif triggered_id == 'logs-prev-btn': + current_page = max(0, current_page - 1) + + elif triggered_id == 'logs-next-btn': + current_page = current_page + 1 + + elif isinstance(triggered_id, dict) and triggered_id.get('type') == 'page-btn': + if ctx.triggered and ctx.triggered[0]['value']: + current_page = triggered_id['index'] + + current_page = max(0, min(current_page, total_pages - 1)) + + start_idx = current_page * PAGE_SIZE + end_idx = min(start_idx + PAGE_SIZE, total_rows) + page_data = prepared_df.iloc[start_idx:end_idx].to_dict('records') + columns = [{"name": i, "id": i} for i in prepared_df.columns] - if trigger_id in ['logs-vehicle-selector', 'logs-bus-selector', '']: - current_data = [] + info_text = (f"Page {current_page + 1} of {total_pages} | " + f"Showing rows {start_idx + 1:,}–{end_idx:,} " + f"of {total_rows:,} total frames") - offset = len(current_data) if current_data else 0 - chunk_size = 50000 + page_buttons = build_page_buttons(current_page, total_pages) - if offset >= total_rows: - return current_data, columns, f"Displaying all {total_rows} total frames." - - next_chunk = prepared_df.iloc[offset:offset + chunk_size].to_dict('records') - new_data = current_data + next_chunk - new_offset = len(new_data) - - info_text = f"Displaying {new_offset} of {total_rows} total frames." - - return new_data, columns, info_text + return page_data, columns, info_text, page_buttons, current_page @app.callback( Output('tab-content', 'children'),