|
@@ -15,7 +15,7 @@ from .services import MLogService
|
|
|
QT_IMPORT_ERROR: Exception | None = None
|
|
QT_IMPORT_ERROR: Exception | None = None
|
|
|
|
|
|
|
|
try:
|
|
try:
|
|
|
- from PySide6.QtCore import QAbstractTableModel, QModelIndex, Qt
|
|
|
|
|
|
|
+ from PySide6.QtCore import QAbstractTableModel, QModelIndex, Qt, QThread, Signal
|
|
|
from PySide6.QtGui import QAction, QColor, QBrush, QFont
|
|
from PySide6.QtGui import QAction, QColor, QBrush, QFont
|
|
|
from PySide6.QtWidgets import (
|
|
from PySide6.QtWidgets import (
|
|
|
QApplication,
|
|
QApplication,
|
|
@@ -46,6 +46,25 @@ if QT_IMPORT_ERROR is None:
|
|
|
ITEM_VALUE_ROLE = Qt.ItemDataRole.UserRole + 21
|
|
ITEM_VALUE_ROLE = Qt.ItemDataRole.UserRole + 21
|
|
|
ITEM_PARENT_ROLE = Qt.ItemDataRole.UserRole + 22
|
|
ITEM_PARENT_ROLE = Qt.ItemDataRole.UserRole + 22
|
|
|
|
|
|
|
|
|
|
+ class ParseThread(QThread):
|
|
|
|
|
+ """Parse a log file off the UI thread."""
|
|
|
|
|
+
|
|
|
|
|
+ parse_succeeded = Signal(object)
|
|
|
|
|
+ parse_failed = Signal(str)
|
|
|
|
|
+
|
|
|
|
|
+ def __init__(self, log_path: Path) -> None:
|
|
|
|
|
+ super().__init__()
|
|
|
|
|
+ self.log_path = Path(log_path)
|
|
|
|
|
+
|
|
|
|
|
+ def run(self) -> None:
|
|
|
|
|
+ try:
|
|
|
|
|
+ parsed_log = MLogService().parse(self.log_path)
|
|
|
|
|
+ except Exception as exc:
|
|
|
|
|
+ self.parse_failed.emit(str(exc))
|
|
|
|
|
+ return
|
|
|
|
|
+
|
|
|
|
|
+ self.parse_succeeded.emit(parsed_log)
|
|
|
|
|
+
|
|
|
class DataFrameTableModel(QAbstractTableModel):
|
|
class DataFrameTableModel(QAbstractTableModel):
|
|
|
"""Lightweight table model for large pandas DataFrames."""
|
|
"""Lightweight table model for large pandas DataFrames."""
|
|
|
|
|
|
|
@@ -121,26 +140,28 @@ if QT_IMPORT_ERROR is None:
|
|
|
self.subplot_count = 2
|
|
self.subplot_count = 2
|
|
|
self.subplot_traces: list[list[PlotTraceSpec]] = [[], []]
|
|
self.subplot_traces: list[list[PlotTraceSpec]] = [[], []]
|
|
|
self._updating_tree = False
|
|
self._updating_tree = False
|
|
|
|
|
+ self._parse_thread: ParseThread | None = None
|
|
|
self.preview_model = DataFrameTableModel()
|
|
self.preview_model = DataFrameTableModel()
|
|
|
|
|
|
|
|
self._build_ui()
|
|
self._build_ui()
|
|
|
|
|
+ self._update_action_states()
|
|
|
|
|
|
|
|
def _build_ui(self) -> None:
|
|
def _build_ui(self) -> None:
|
|
|
toolbar = QToolBar("Main")
|
|
toolbar = QToolBar("Main")
|
|
|
toolbar.setMovable(False)
|
|
toolbar.setMovable(False)
|
|
|
self.addToolBar(toolbar)
|
|
self.addToolBar(toolbar)
|
|
|
|
|
|
|
|
- open_action = QAction("Open Log", self)
|
|
|
|
|
- open_action.triggered.connect(self.open_log_file)
|
|
|
|
|
- toolbar.addAction(open_action)
|
|
|
|
|
|
|
+ self.open_action = QAction("Open Log", self)
|
|
|
|
|
+ self.open_action.triggered.connect(self.open_log_file)
|
|
|
|
|
+ toolbar.addAction(self.open_action)
|
|
|
|
|
|
|
|
- export_action = QAction("Export CSV", self)
|
|
|
|
|
- export_action.triggered.connect(self.export_csv)
|
|
|
|
|
- toolbar.addAction(export_action)
|
|
|
|
|
|
|
+ self.export_action = QAction("Export CSV", self)
|
|
|
|
|
+ self.export_action.triggered.connect(self.export_csv)
|
|
|
|
|
+ toolbar.addAction(self.export_action)
|
|
|
|
|
|
|
|
- refresh_action = QAction("Refresh Plot", self)
|
|
|
|
|
- refresh_action.triggered.connect(self.refresh_plot)
|
|
|
|
|
- toolbar.addAction(refresh_action)
|
|
|
|
|
|
|
+ self.refresh_action = QAction("Refresh Plot", self)
|
|
|
|
|
+ self.refresh_action.triggered.connect(self.refresh_plot)
|
|
|
|
|
+ toolbar.addAction(self.refresh_action)
|
|
|
|
|
|
|
|
cursor_action = QAction("Cursor", self)
|
|
cursor_action = QAction("Cursor", self)
|
|
|
cursor_action.setCheckable(True)
|
|
cursor_action.setCheckable(True)
|
|
@@ -259,12 +280,21 @@ if QT_IMPORT_ERROR is None:
|
|
|
QMessageBox.information(self, "No file", "Select a log file first.")
|
|
QMessageBox.information(self, "No file", "Select a log file first.")
|
|
|
return
|
|
return
|
|
|
|
|
|
|
|
- try:
|
|
|
|
|
- self.parsed_log = self.service.parse(self.selected_log)
|
|
|
|
|
- except Exception as exc:
|
|
|
|
|
- QMessageBox.critical(self, "Parse failed", str(exc))
|
|
|
|
|
|
|
+ if self._parse_thread is not None and self._parse_thread.isRunning():
|
|
|
return
|
|
return
|
|
|
|
|
|
|
|
|
|
+ self._clear_loaded_log()
|
|
|
|
|
+ self.summary_label.setText("Parsing in background...")
|
|
|
|
|
+ self._parse_thread = ParseThread(self.selected_log)
|
|
|
|
|
+ self._parse_thread.parse_succeeded.connect(self._on_parse_succeeded)
|
|
|
|
|
+ self._parse_thread.parse_failed.connect(self._on_parse_failed)
|
|
|
|
|
+ self._parse_thread.finished.connect(self._on_parse_finished)
|
|
|
|
|
+ self._parse_thread.finished.connect(self._parse_thread.deleteLater)
|
|
|
|
|
+ self._parse_thread.start()
|
|
|
|
|
+ self._update_action_states()
|
|
|
|
|
+
|
|
|
|
|
+ def _on_parse_succeeded(self, parsed_log: ParsedLog) -> None:
|
|
|
|
|
+ self.parsed_log = parsed_log
|
|
|
self.subplot_traces = [[], []]
|
|
self.subplot_traces = [[], []]
|
|
|
self._populate_tree()
|
|
self._populate_tree()
|
|
|
self.summary_label.setText(
|
|
self.summary_label.setText(
|
|
@@ -285,6 +315,14 @@ if QT_IMPORT_ERROR is None:
|
|
|
self.signal_tree.setCurrentItem(first_item)
|
|
self.signal_tree.setCurrentItem(first_item)
|
|
|
self.on_tree_selection_changed()
|
|
self.on_tree_selection_changed()
|
|
|
|
|
|
|
|
|
|
+ def _on_parse_failed(self, error_message: str) -> None:
|
|
|
|
|
+ self.summary_label.setText("Parse failed.")
|
|
|
|
|
+ QMessageBox.critical(self, "Parse failed", error_message)
|
|
|
|
|
+
|
|
|
|
|
+ def _on_parse_finished(self) -> None:
|
|
|
|
|
+ self._parse_thread = None
|
|
|
|
|
+ self._update_action_states()
|
|
|
|
|
+
|
|
|
def export_csv(self) -> None:
|
|
def export_csv(self) -> None:
|
|
|
if self.parsed_log is None:
|
|
if self.parsed_log is None:
|
|
|
QMessageBox.information(self, "No data", "Parse a log file first.")
|
|
QMessageBox.information(self, "No data", "Parse a log file first.")
|
|
@@ -321,6 +359,24 @@ if QT_IMPORT_ERROR is None:
|
|
|
self._refresh_tree_state()
|
|
self._refresh_tree_state()
|
|
|
self.refresh_plot()
|
|
self.refresh_plot()
|
|
|
|
|
|
|
|
|
|
+ def _clear_loaded_log(self) -> None:
|
|
|
|
|
+ self.parsed_log = None
|
|
|
|
|
+ self.subplot_traces = [[], []]
|
|
|
|
|
+ self.signal_tree.clear()
|
|
|
|
|
+ self.preview.setPlainText("")
|
|
|
|
|
+ self._clear_preview_table()
|
|
|
|
|
+ self.refresh_plot()
|
|
|
|
|
+
|
|
|
|
|
+ def _update_action_states(self) -> None:
|
|
|
|
|
+ is_parsing = self._parse_thread is not None and self._parse_thread.isRunning()
|
|
|
|
|
+ has_data = self.parsed_log is not None
|
|
|
|
|
+ self.open_action.setEnabled(not is_parsing)
|
|
|
|
|
+ self.export_action.setEnabled(has_data and not is_parsing)
|
|
|
|
|
+ self.refresh_action.setEnabled(has_data and not is_parsing)
|
|
|
|
|
+ self.signal_tree.setEnabled(not is_parsing)
|
|
|
|
|
+ self.preview_table.setEnabled(not is_parsing)
|
|
|
|
|
+ self.preview.setEnabled(not is_parsing)
|
|
|
|
|
+
|
|
|
def _toggle_cursor(self, enabled: bool) -> None:
|
|
def _toggle_cursor(self, enabled: bool) -> None:
|
|
|
self.plot_widget.set_cursor_enabled(enabled)
|
|
self.plot_widget.set_cursor_enabled(enabled)
|
|
|
|
|
|
|
@@ -669,6 +725,12 @@ if QT_IMPORT_ERROR is None:
|
|
|
self.preview_table.resizeColumnToContents(column_index)
|
|
self.preview_table.resizeColumnToContents(column_index)
|
|
|
header.setSectionResizeMode(column_index, QHeaderView.ResizeMode.Interactive)
|
|
header.setSectionResizeMode(column_index, QHeaderView.ResizeMode.Interactive)
|
|
|
|
|
|
|
|
|
|
+ def closeEvent(self, event: Any) -> None: # noqa: N802
|
|
|
|
|
+ if self._parse_thread is not None and self._parse_thread.isRunning():
|
|
|
|
|
+ self.summary_label.setText("Waiting for parsing to finish before closing...")
|
|
|
|
|
+ self._parse_thread.wait()
|
|
|
|
|
+ super().closeEvent(event)
|
|
|
|
|
+
|
|
|
|
|
|
|
|
def main() -> None:
|
|
def main() -> None:
|
|
|
"""Run the Qt GUI entry point."""
|
|
"""Run the Qt GUI entry point."""
|