Replace infinite scroll with paginated log view

This commit is contained in:
2026-07-22 23:09:47 +02:00
parent 3b703e845f
commit fab448785b
2 changed files with 105 additions and 65 deletions
+11 -4
View File
@@ -3,7 +3,10 @@
# SPDX-License-Identifier: AGPL-3.0-or-later # SPDX-License-Identifier: AGPL-3.0-or-later
import pandas as pd 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: def prepare_logs_data(df: pd.DataFrame) -> pd.DataFrame:
if df is None or df.empty: if df is None or df.empty:
@@ -44,7 +47,7 @@ def get_logs_table_component():
id='logs-table', id='logs-table',
virtualization=True, virtualization=True,
page_action='none', page_action='none',
style_table={'overflowX': 'auto', 'height': '75vh', 'overflowY': 'auto'}, style_table={'overflowX': 'auto', 'height': '70vh', 'overflowY': 'auto'},
style_header={ style_header={
'backgroundColor': '#1a1a1a', 'backgroundColor': '#1a1a1a',
'color': 'white', 'color': 'white',
@@ -71,6 +74,10 @@ def get_logs_table_component():
'fontFamily': 'Segoe UI, Arial, sans-serif' 'fontFamily': 'Segoe UI, Arial, sans-serif'
} }
), ),
html.Button("Load More", id="load-more-logs-btn", n_clicks=0, style={'display': 'none'}), html.Div([
html.Div(id='dummy-output', style={'display': 'none'}) 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),
]) ])
+94 -61
View File
@@ -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 from logs.view import get_logs_table_component, prepare_logs_data
RAW_LOG_DIR = "data/logs" RAW_LOG_DIR = "data/logs"
PAGE_SIZE = 25000
def parse_vehicle_from_filename(filename: str): def parse_vehicle_from_filename(filename: str):
stem = Path(filename).stem stem = Path(filename).stem
@@ -86,6 +87,7 @@ for log_file in Path(RAW_LOG_DIR).glob("*.txt"):
PRECOMPUTED_FIGURES = {} PRECOMPUTED_FIGURES = {}
DATA_BY_ID = {} DATA_BY_ID = {}
CORR_CACHE = {} CORR_CACHE = {}
PREPARED_LOGS_CACHE = {}
def process_bus_data(vehicle, bus, df): def process_bus_data(vehicle, bus, df):
precomp = {} precomp = {}
@@ -176,85 +178,116 @@ app.layout = dbc.Container([
], id="main-tabs", active_tab="statistics") ], id="main-tabs", active_tab="statistics")
], fluid=True) ], fluid=True)
app.clientside_callback( def get_prepared_logs(vehicle, bus):
""" cache_key = (vehicle, bus)
function(data) { if cache_key not in PREPARED_LOGS_CACHE:
if (!data) return ''; df = DATA[vehicle][bus]
requestAnimationFrame(() => { PREPARED_LOGS_CACHE[cache_key] = prepare_logs_data(df)
const btn = document.getElementById('load-more-logs-btn'); return PREPARED_LOGS_CACHE[cache_key]
const info = document.getElementById('logs-info-text');
if (!btn || !info || info.innerText.toLowerCase().includes('all')) {
return;
}
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'); half = max_buttons // 2
if (containers.length === 0) return; 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 => { if start > 0:
container.onscroll = function() { buttons.append(
if (container.scrollHeight - container.scrollTop - container.clientHeight < 1500) { dbc.Button("1", id={'type': 'page-btn', 'index': 0}, color="secondary", outline=True, size="sm", className="me-1")
if (btn && btn.dataset.loading === "false") { )
btn.dataset.loading = "true"; if start > 1:
btn.click(); buttons.append(html.Span("", className="mx-1 align-middle"))
}
}
};
if (container.scrollHeight - container.scrollTop - container.clientHeight < 1500) { for i in range(start, end):
if (btn && btn.dataset.loading === "false") { is_current = (i == current_page)
btn.dataset.loading = "true"; buttons.append(
btn.click(); dbc.Button(
} str(i + 1),
} id={'type': 'page-btn', 'index': i},
}); size="sm",
}); color="primary" if is_current else "secondary",
return ''; outline=not is_current,
} className="me-1",
""", disabled=is_current,
Output('dummy-output', 'children'), )
Input('logs-table', 'data') )
)
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( @app.callback(
Output('logs-table', 'data'), Output('logs-table', 'data'),
Output('logs-table', 'columns'), Output('logs-table', 'columns'),
Output('logs-info-text', 'children'), Output('logs-info-text', 'children'),
Output('logs-page-nav', 'children'),
Output('logs-current-page', 'data'),
Input('logs-vehicle-selector', 'value'), Input('logs-vehicle-selector', 'value'),
Input('logs-bus-selector', 'value'), Input('logs-bus-selector', 'value'),
Input('load-more-logs-btn', 'n_clicks'), Input('logs-prev-btn', 'n_clicks'),
State('logs-table', 'data'), 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): def update_logs_table(vehicle, bus, prev_clicks, next_clicks, page_btn_clicks, current_page):
ctx = dash.callback_context if (not vehicle or not bus or vehicle not in DATA or bus not in DATA[vehicle]):
trigger_id = ctx.triggered[0]['prop_id'].split('.')[0] if ctx.triggered else '' return [], [], "No data available", [], 0
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)
prepared_df = get_prepared_logs(vehicle, bus)
total_rows = len(prepared_df) 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] columns = [{"name": i, "id": i} for i in prepared_df.columns]
if trigger_id in ['logs-vehicle-selector', 'logs-bus-selector', '']: info_text = (f"Page {current_page + 1} of {total_pages} | "
current_data = [] f"Showing rows {start_idx + 1:,}{end_idx:,} "
f"of {total_rows:,} total frames")
offset = len(current_data) if current_data else 0 page_buttons = build_page_buttons(current_page, total_pages)
chunk_size = 50000
if offset >= total_rows: return page_data, columns, info_text, page_buttons, current_page
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
@app.callback( @app.callback(
Output('tab-content', 'children'), Output('tab-content', 'children'),