feat(logpanel): replace the level threshold dropdown with a checkable include-list

This commit is contained in:
2026-08-04 12:11:00 +02:00
committed by Jan Wyzula
parent c7269f5dba
commit 532a8858cd
2 changed files with 150 additions and 71 deletions
+104 -55
View File
@@ -39,7 +39,6 @@ from qtpy.QtGui import (
from qtpy.QtWidgets import (
QApplication,
QCheckBox,
QComboBox,
QDateTimeEdit,
QDialog,
QDialogButtonBox,
@@ -98,13 +97,17 @@ class _LogRec(NamedTuple):
service_name: str | None
message: str
function: str | None
level_num: int | None
ts: float
message_lower: str
raw: LogMessage
timestamp_full: str
def _levels_at_or_above(level: LogLevel) -> set[str]:
"""Expand a threshold level into the include-set of level names at or above it."""
return {l.name for l in LogLevel if l.value >= level.value}
def _to_record(msg: LogMessage) -> _LogRec:
"""Flatten a LogMessage once at ingest so paint and filter passes never re-traverse it.
@@ -115,13 +118,9 @@ def _to_record(msg: LogMessage) -> _LogRec:
override (filterAcceptsRow, paint) is crash-class on PySide 6.10+.
"""
level = msg.log_type.upper()
try:
level_num = LogLevel[level].value
except KeyError:
level_num = None
log_msg = msg.log_msg
if isinstance(log_msg, str):
return _LogRec(level, "", None, log_msg, None, level_num, 0.0, log_msg.lower(), msg, "")
return _LogRec(level, "", None, log_msg, None, 0.0, log_msg.lower(), msg, "")
record = log_msg.get("record")
if not isinstance(record, dict):
record = {}
@@ -149,9 +148,7 @@ def _to_record(msg: LogMessage) -> _LogRec:
ts_short = f"{moment:%H:%M:%S}.{moment.microsecond // 1000:03d}"
else:
ts_short = ts_repr
return _LogRec(
level, ts_short, service, message, function, level_num, ts, message.lower(), msg, ts_repr
)
return _LogRec(level, ts_short, service, message, function, ts, message.lower(), msg, ts_repr)
class BecLogsQueue(BECConnector, QObject):
@@ -350,9 +347,13 @@ class LogMsgProxyModel(QSortFilterProxyModel):
level_filter: LogLevel | None = None,
):
super().__init__(parent)
# None means "no service filter"; an explicit set (possibly empty) is an include-list
# None means "no filter"; an explicit set (possibly empty) is an include-list.
# The constructor's level_filter is a threshold for backward compatibility and
# expands to "that level and above".
self._service_filter: set[str] | None = service_filter
self._level_num: int | None = level_filter.value if level_filter is not None else None
self._level_filter: set[str] | None = (
_levels_at_or_above(level_filter) if level_filter is not None else None
)
self._filter_text: str = ""
self._fuzzy_search: bool = False
self._ts_start: float | None = None
@@ -380,14 +381,16 @@ class LogMsgProxyModel(QSortFilterProxyModel):
self.show_service_column.emit(filter is None or len(filter) != 1)
@SafeSlot(None)
@SafeSlot(LogLevel)
def update_level_filter(self, filter: LogLevel | None):
"""Filter to the selected log level.
@SafeSlot(None)
@SafeSlot(set)
def update_level_filter(self, filter: set[str] | None):
"""Filter to the selected log levels.
Args:
filter (LogLevel | None): lowest log level to show. None shows all levels."""
filter (set[str] | None): include-list of level names to show. None shows
every level; an empty set shows nothing."""
self.beginFilterChange()
self._level_num = filter.value if filter is not None else None
self._level_filter = filter
self.endFilterChange(QSortFilterProxyModel.Direction.Rows)
@SafeSlot(str)
@@ -425,11 +428,7 @@ class LogMsgProxyModel(QSortFilterProxyModel):
rec = self.sourceModel().record(source_row)
if self._service_filter is not None and rec.service_name not in self._service_filter:
return False
if (
self._level_num is not None
and rec.level_num is not None
and rec.level_num < self._level_num
):
if self._level_filter is not None and rec.level not in self._level_filter:
return False
if self._ts_start is not None and rec.ts < self._ts_start:
return False
@@ -666,7 +665,7 @@ class LogPanel(BECWidget, QWidget):
if show_toolbar:
self._connect_toolbar()
if level_filter is not None:
self._toolbar.set_level(level_filter)
self._toolbar.set_level_selection(_levels_at_or_above(level_filter))
if service_filter is not None:
self._toolbar.set_service_selection(service_filter)
self._proxy.show_service_column.connect(self._show_service_column)
@@ -745,7 +744,7 @@ class LogPanel(BECWidget, QWidget):
def _connect_toolbar(self):
self._toolbar.services_selected.connect(self._on_services_selected)
self._toolbar.text_filter_changed.connect(self._proxy.update_filter_text)
self._toolbar.level_changed.connect(self._on_level_changed)
self._toolbar.levels_selected.connect(self._on_levels_selected)
self._toolbar.fuzzy_changed.connect(self._proxy.update_fuzzy)
self._toolbar.timestamp_update.connect(self._proxy.update_timestamp)
self._toolbar.pause_button.clicked.connect(self._model.log_queue.toggle_pause)
@@ -787,8 +786,8 @@ class LogPanel(BECWidget, QWidget):
self._proxy.update_service_filter(services)
@SafeSlot(object)
def _on_level_changed(self, level: LogLevel | None):
self._proxy.update_level_filter(level)
def _on_levels_selected(self, levels: set[str] | None):
self._proxy.update_level_filter(levels)
@SafeSlot()
def _refresh_match_label(self, *_):
@@ -996,7 +995,7 @@ class _StayOpenMenu(QMenu):
class LogPanelToolbar(QWidget):
services_selected = Signal(object) # set[str] | None (None = all services)
level_changed = Signal(object) # LogLevel | None (None = all levels)
levels_selected = Signal(object) # set[str] | None (None = all levels)
fuzzy_changed = Signal(bool)
timestamp_update = Signal(TimestampUpdate)
text_filter_changed = Signal(str)
@@ -1007,6 +1006,17 @@ class LogPanelToolbar(QWidget):
("Last 15 min", 900),
("Last 1 h", 3600),
]
# the levels offered in the filter menu; CONSOLE_LOG* are internal plumbing levels
# and stay unlisted (visible while unfiltered, hidden by any narrowed selection)
_FILTER_LEVELS = [
LogLevel.TRACE,
LogLevel.DEBUG,
LogLevel.INFO,
LogLevel.SUCCESS,
LogLevel.WARNING,
LogLevel.ERROR,
LogLevel.CRITICAL,
]
def __init__(self, parent: QWidget | None = None, client: BECClient | None = None) -> None:
"""A toolbar for the logpanel, managing the states of all log filters."""
@@ -1035,9 +1045,30 @@ class LogPanelToolbar(QWidget):
self.service_button.setMenu(self._service_menu)
self._layout.addWidget(self.service_button)
self.filter_level_dropdown = self._log_level_box()
self._layout.addWidget(self.filter_level_dropdown)
self.filter_level_dropdown.currentIndexChanged.connect(self._emit_level)
self.level_button = QToolButton(self)
self.level_button.setText("All levels")
self.level_button.setIcon(
material_icon("filter_alt", size=(20, 20), convert_to_pixmap=False)
)
self.level_button.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon)
self.level_button.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
self.level_button.setToolTip("Choose which log levels to show")
self._level_menu = _StayOpenMenu(self.level_button)
self._level_menu.aboutToShow.connect(self._sync_level_menu)
self.level_button.setMenu(self._level_menu)
self._level_actions: dict[str, QAction] = {}
self._level_menu.addAction("All levels", self._select_all_levels)
self._level_menu.addSeparator()
for level in self._FILTER_LEVELS:
action = QAction(level.name, self._level_menu)
action.setCheckable(True)
action.setChecked(True)
action.toggled.connect(partial(self._on_level_toggled, level.name))
self._level_menu.addAction(action)
self._level_actions[level.name] = action
# None means "all levels" - the include-list has not been narrowed
self._checked_levels: set[str] | None = None
self._layout.addWidget(self.level_button)
self.timerange_button = QToolButton(self)
self.timerange_button.setText("All time")
@@ -1097,33 +1128,51 @@ class LogPanelToolbar(QWidget):
# ---------------------------------------------------------------- level filter
def _log_level_box(self) -> QComboBox:
box = QComboBox(self)
box.setToolTip("Show logs at or above the selected level.")
box.addItem("All levels", None)
for level in [
LogLevel.TRACE,
LogLevel.DEBUG,
LogLevel.INFO,
LogLevel.SUCCESS,
LogLevel.WARNING,
LogLevel.ERROR,
LogLevel.CRITICAL,
]:
box.addItem(level.name, level)
return box
def _sync_level_menu(self):
"""Reflect the current selection on the static menu without re-emitting."""
checked = (
{l.name for l in self._FILTER_LEVELS}
if self._checked_levels is None
else self._checked_levels
)
for name, action in self._level_actions.items():
blocked = action.blockSignals(True)
action.setChecked(name in checked)
action.blockSignals(blocked)
@SafeSlot(int)
def _emit_level(self, index: int):
self.level_changed.emit(self.filter_level_dropdown.itemData(index))
def _on_level_toggled(self, level: str, checked: bool):
current = (
{l.name for l in self._FILTER_LEVELS}
if self._checked_levels is None
else set(self._checked_levels)
)
if checked:
current.add(level)
else:
current.discard(level)
# everything re-checked = no filter (unlisted CONSOLE_LOG* levels stay visible)
if current >= {l.name for l in self._FILTER_LEVELS}:
current = None
self._set_checked_levels(current)
def set_level(self, level: LogLevel | None):
"""Set the level dropdown to the given threshold (None = all levels). Levels not
listed in the dropdown (CONSOLE_LOG*) leave the dropdown untouched so a
preapplied proxy filter is not wiped."""
index = self.filter_level_dropdown.findData(level)
if index != -1:
self.filter_level_dropdown.setCurrentIndex(index)
def _select_all_levels(self):
self._set_checked_levels(None)
def set_level_selection(self, levels: set[str] | None):
"""Select exactly the given level names (None = all levels)."""
self._set_checked_levels(None if levels is None else set(levels))
def _set_checked_levels(self, levels: set[str] | None):
self._checked_levels = levels
if levels is None:
self.level_button.setText("All levels")
elif len(levels) == 0:
self.level_button.setText("No levels")
elif len(levels) == 1:
self.level_button.setText(next(iter(levels)))
else:
self.level_button.setText(f"{len(levels)} levels")
self.levels_selected.emit(levels)
# ---------------------------------------------------------------- service filter
+46 -16
View File
@@ -106,9 +106,13 @@ def test_log_panel_filters(qtbot, log_panel: LogPanel):
log_panel._proxy.update_timestamp(TimestampUpdate(value=None, update_type="start"))
log_panel._proxy.update_timestamp(TimestampUpdate(value=None, update_type="end"))
qtbot.waitUntil(lambda: log_panel._proxy.rowCount() == 3, timeout=200)
# Level filter
log_panel._proxy.update_level_filter(LogLevel.SUCCESS)
# Level filter (include-list of level names)
log_panel._proxy.update_level_filter({"SUCCESS"})
qtbot.waitUntil(lambda: log_panel._proxy.rowCount() == 1, timeout=200)
log_panel._proxy.update_level_filter({"SUCCESS", "DEBUG"})
qtbot.waitUntil(lambda: log_panel._proxy.rowCount() == 2, timeout=200)
log_panel._proxy.update_level_filter(set()) # empty include-list shows nothing
qtbot.waitUntil(lambda: log_panel._proxy.rowCount() == 0, timeout=200)
log_panel._proxy.update_level_filter(None)
qtbot.waitUntil(lambda: log_panel._proxy.rowCount() == 3, timeout=200)
@@ -172,7 +176,7 @@ def _patched_const(monkeypatch, **overrides):
def test_log_panel_appends_incrementally_and_filters_new_rows(qtbot, log_panel: LogPanel):
log_panel._proxy.update_level_filter(LogLevel.WARNING)
log_panel._proxy.update_level_filter({"WARNING"})
qtbot.waitUntil(lambda: log_panel._proxy.rowCount() == 0, timeout=200)
with qtbot.waitSignal(log_panel._model.rowsInserted, timeout=500) as blocker:
_feed(log_panel, [make_log_msg(0, "warning"), make_log_msg(1, "debug")])
@@ -344,16 +348,38 @@ def test_log_panel_toolbar_service_selection(qtbot, log_panel: LogPanel):
assert toolbar._checked_services is None
def test_log_panel_toolbar_level_default_and_preset(qtbot, log_panel: LogPanel):
box = log_panel._toolbar.filter_level_dropdown
assert box.currentIndex() == 0
assert box.currentText() == "All levels"
assert box.itemData(0) is None
assert "CONSOLE_LOG" not in [box.itemText(i) for i in range(box.count())]
log_panel._toolbar.set_level(LogLevel.SUCCESS)
def test_log_panel_toolbar_level_selection(qtbot, log_panel: LogPanel):
toolbar = log_panel._toolbar
assert toolbar._checked_levels is None
assert toolbar.level_button.text() == "All levels"
assert "CONSOLE_LOG" not in toolbar._level_actions # internal levels stay unlisted
toolbar.set_level_selection({"SUCCESS"})
qtbot.waitUntil(lambda: log_panel._proxy.rowCount() == 1, timeout=200)
log_panel._toolbar.set_level(None)
assert toolbar.level_button.text() == "SUCCESS"
# unchecking a level through the menu removes it from the include-list
toolbar._sync_level_menu()
toolbar._level_actions["SUCCESS"].setChecked(False)
qtbot.waitUntil(lambda: log_panel._proxy.rowCount() == 0, timeout=200)
assert toolbar.level_button.text() == "No levels"
# re-checking every listed level collapses to the unfiltered state
for action in toolbar._level_actions.values():
action.setChecked(True)
qtbot.waitUntil(lambda: log_panel._proxy.rowCount() == 3, timeout=200)
assert toolbar._checked_levels is None
assert toolbar.level_button.text() == "All levels"
def test_log_panel_constructor_level_filter_expands_to_threshold(qtbot, mocked_client, monkeypatch):
monkeypatch.setattr(mocked_client.connector, "xread", lambda *_, **__: TEST_LOG_MESSAGES)
widget = LogPanel(level_filter=LogLevel.SUCCESS)
qtbot.addWidget(widget)
try:
# threshold semantics preserved: SUCCESS and above (the debug/info rows hide)
assert widget._proxy.rowCount() == 1
assert widget._toolbar._checked_levels == {"SUCCESS", "WARNING", "ERROR", "CRITICAL"}
assert widget._toolbar.level_button.text() == "4 levels"
finally:
widget._model.log_queue.cleanup()
def test_log_panel_search_debounce_and_match_count(qtbot, log_panel: LogPanel):
@@ -455,11 +481,15 @@ def test_log_panel_detail_pane_freezes_when_record_trimmed(qtbot, log_panel, mon
assert "(no longer in buffer)" not in log_panel._detail_header.text()
def test_log_panel_set_level_with_unlisted_level_keeps_filter(qtbot, log_panel: LogPanel):
log_panel._proxy.update_level_filter(LogLevel.CONSOLE_LOG)
log_panel._toolbar.set_level(LogLevel.CONSOLE_LOG) # not in the dropdown
assert log_panel._proxy._level_num == LogLevel.CONSOLE_LOG.value # filter not wiped
assert log_panel._toolbar.filter_level_dropdown.currentIndex() == 0
def test_log_panel_unlisted_levels_follow_selection(qtbot, log_panel: LogPanel):
# CONSOLE_LOG rows are visible while unfiltered, hidden by any narrowed selection,
# and still selectable explicitly through the proxy API
_feed(log_panel, [make_log_msg(0, "console_log")])
assert log_panel._proxy.rowCount() == 4
log_panel._toolbar.set_level_selection({"DEBUG", "INFO", "SUCCESS"})
qtbot.waitUntil(lambda: log_panel._proxy.rowCount() == 3, timeout=200)
log_panel._proxy.update_level_filter({"CONSOLE_LOG"})
qtbot.waitUntil(lambda: log_panel._proxy.rowCount() == 1, timeout=200)
def test_log_panel_font_size_property(qtbot, log_panel: LogPanel):