1
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2026-04-16 21:45:35 +02:00

Compare commits

..

3 Commits

16 changed files with 655 additions and 658 deletions

View File

@@ -7,7 +7,6 @@ from bec_widgets.applications.views.developer_view.developer_view import Develop
from bec_widgets.applications.views.device_manager_view.device_manager_widget import (
DeviceManagerWidget,
)
from bec_widgets.applications.views.diagnostic_view.diagnostic_view import DiagnosticView
from bec_widgets.applications.views.view import ViewBase, WaveformViewInline, WaveformViewPopup
from bec_widgets.utils.colors import apply_theme
from bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area import AdvancedDockArea
@@ -54,7 +53,6 @@ class BECMainApp(BECMainWindow):
self.ads.setObjectName("MainWorkspace")
self.device_manager = DeviceManagerWidget(self)
self.developer_view = DeveloperView(self)
self.diagnostics = DiagnosticView(self)
self.add_view(
icon="widgets", title="Dock Area", id="dock_area", widget=self.ads, mini_text="Docks"
@@ -73,13 +71,6 @@ class BECMainApp(BECMainWindow):
id="developer_view",
exclusive=True,
)
self.add_view(
icon="code_blocks",
title="Diagnostics",
widget=self.diagnostics,
id="diagnostic_view",
exclusive=True,
)
if self._show_examples:
self.add_section("Examples", "examples")

View File

@@ -1,3 +1,5 @@
from __future__ import annotations
import re
import markdown
@@ -13,6 +15,7 @@ from bec_widgets.utils.toolbars.bundles import ToolbarBundle
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
from bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area import AdvancedDockArea
from bec_widgets.widgets.containers.advanced_dock_area.basic_dock_area import DockAreaWidget
from bec_widgets.widgets.containers.qt_ads import CDockWidget
from bec_widgets.widgets.editors.monaco.monaco_dock import MonacoDock
from bec_widgets.widgets.editors.monaco.monaco_widget import MonacoWidget
from bec_widgets.widgets.editors.web_console.web_console import WebConsole
@@ -124,6 +127,7 @@ class DeveloperWidget(DockAreaWidget):
# Connect editor signals
self.explorer.file_open_requested.connect(self._open_new_file)
self.monaco.macro_file_updated.connect(self.explorer.refresh_macro_file)
self.monaco.focused_editor.connect(self._on_focused_editor_changed)
self.toolbar.show_bundles(["save", "execution", "settings"])
@@ -280,14 +284,17 @@ class DeveloperWidget(DockAreaWidget):
@SafeSlot()
def on_save(self):
"""Save the currently focused file in the Monaco editor."""
self.monaco.save_file()
@SafeSlot()
def on_save_as(self):
"""Save the currently focused file in the Monaco editor with a 'Save As' dialog."""
self.monaco.save_file(force_save_as=True)
@SafeSlot()
def on_vim_triggered(self):
"""Toggle Vim mode in the Monaco editor."""
self.monaco.set_vim_mode(self.toolbar.components.get_action("vim").action.isChecked())
@SafeSlot(bool)
@@ -310,16 +317,26 @@ class DeveloperWidget(DockAreaWidget):
@SafeSlot()
def on_stop(self):
"""Stop the execution of the currently running script"""
if not self.current_script_id:
return
self.console.send_ctrl_c()
@property
def current_script_id(self):
"""Get the ID of the currently running script."""
return self._current_script_id
@current_script_id.setter
def current_script_id(self, value: str | None):
"""
Set the ID of the currently running script.
Args:
value (str | None): The script ID to set.
Raises:
ValueError: If the provided value is not a string or None.
"""
if value is not None and not isinstance(value, str):
raise ValueError("Script ID must be a string.")
old_script_id = self._current_script_id
@@ -336,6 +353,28 @@ class DeveloperWidget(DockAreaWidget):
self.on_script_execution_info, MessageEndpoints.script_execution_info(new_script_id)
)
@SafeSlot(CDockWidget)
def _on_focused_editor_changed(self, tab_widget: CDockWidget):
"""
Disable the run button if the focused editor is a macro file.
Args:
tab_widget: The currently focused tab widget in the Monaco editor.
"""
if not isinstance(tab_widget, CDockWidget):
return
widget = tab_widget.widget()
if not isinstance(widget, MonacoWidget):
return
file_scope = widget.metadata.get("scope", "")
run_action = self.toolbar.components.get_action("run")
stop_action = self.toolbar.components.get_action("stop")
if "macro" in file_scope:
run_action.action.setEnabled(False)
stop_action.action.setEnabled(False)
else:
run_action.action.setEnabled(True)
stop_action.action.setEnabled(True)
@SafeSlot(dict, dict)
def on_script_execution_info(self, content: dict, metadata: dict):
"""
@@ -359,6 +398,7 @@ class DeveloperWidget(DockAreaWidget):
widget.set_highlighted_lines(line_number, line_number)
def cleanup(self):
"""Clean up resources used by the developer widget."""
self.delete_all()
return super().cleanup()

View File

@@ -1,88 +0,0 @@
from qtpy.QtWidgets import QWidget
from bec_widgets.applications.views.view import ViewBase
from bec_widgets.widgets.containers.advanced_dock_area.basic_dock_area import DockAreaWidget
from bec_widgets.widgets.services.bec_status_box.bec_status_box import BECStatusBox
from bec_widgets.widgets.utility.logpanel.logpanel import LogPanel
class DiagnosticWidget(DockAreaWidget):
def __init__(self, parent=None, **kwargs):
super().__init__(parent=parent, variant="compact", **kwargs)
logs_dock_kwargs = {
"closable": False,
"floatable": False,
"movable": False,
"return_dock": True,
"show_settings_action": False,
"title_buttons": {"float": False, "close": False, "menu": False},
}
self.device_logs = LogPanel(show_toolbar=False, service_filter={"DeviceServer"})
self.device_logs.setObjectName("Device Server logs")
self.device_logs_dock = self.new(self.device_logs, **logs_dock_kwargs)
self.scihub_logs = LogPanel(show_toolbar=False, service_filter={"SciHub"})
self.scihub_logs.setObjectName("SciHub logs")
self.scihub_logs_dock = self.new(
self.scihub_logs, relative_to=self.device_logs_dock, where="right", **logs_dock_kwargs
)
self.service_status = BECStatusBox()
self.service_status.setObjectName("Service Status")
self.service_status_dock = self.new(
self.service_status,
relative_to=self.scihub_logs_dock,
where="right",
**logs_dock_kwargs,
)
self.scan_logs = LogPanel(show_toolbar=False, service_filter={"ScanServer"})
self.scan_logs.setObjectName("Scan Server logs")
self.scan_logs_dock = self.new(
self.scan_logs, relative_to=self.device_logs_dock, where="bottom", **logs_dock_kwargs
)
self.dap_logs = LogPanel(show_toolbar=False, service_filter={"DAPServer"})
self.dap_logs.setObjectName("DAP Server logs")
self.dap_logs_dock = self.new(
self.dap_logs, relative_to=self.scan_logs_dock, where="bottom", **logs_dock_kwargs
)
self.scanbundler_logs = LogPanel(show_toolbar=False, service_filter={"ScanBundler"})
self.scanbundler_logs.setObjectName("ScanBundler logs")
self.scanbundler_logs_dock = self.new(
self.scanbundler_logs,
relative_to=self.scihub_logs_dock,
where="bottom",
**logs_dock_kwargs,
)
self.filewriter_logs = LogPanel(show_toolbar=False, service_filter={"FileWriter"})
self.filewriter_logs.setObjectName("FileWriter logs")
self.filewriter_logs_dock = self.new(
self.filewriter_logs,
relative_to=self.scanbundler_logs_dock,
where="bottom",
**logs_dock_kwargs,
)
class DiagnosticView(ViewBase):
"""
A view for users to write scripts and macros and execute them within the application.
"""
def __init__(
self,
parent: QWidget | None = None,
content: QWidget | None = None,
*,
id: str | None = None,
title: str | None = None,
):
super().__init__(parent=parent, content=content, id=id, title=title)
self.developer_widget = DiagnosticWidget(parent=self)
self.set_content(self.developer_widget)

View File

@@ -2867,24 +2867,24 @@ class ImageItem(RPCBase):
class LogPanel(RPCBase):
"""Live display of the BEC logs in a table view."""
"""Displays a log panel"""
@rpc_call
def remove(self):
def set_plain_text(self, text: str) -> None:
"""
Cleanup the BECConnector
Set the plain text of the widget.
Args:
text (str): The text to set.
"""
@rpc_call
def attach(self):
"""
None
def set_html_text(self, text: str) -> None:
"""
Set the HTML text of the widget.
@rpc_call
def detach(self):
"""
Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget.
Args:
text (str): The text to set.
"""

View File

@@ -21,17 +21,7 @@ from bec_widgets.utils.widget_io import WidgetHierarchy
logger = bec_logger.logger
PROPERTY_TO_SKIP = [
"palette",
"font",
"windowIcon",
"windowIconText",
"locale",
"styleSheet",
"updatesEnabled",
"objectName",
"visible",
]
PROPERTY_TO_SKIP = ["palette", "font", "windowIcon", "windowIconText", "locale", "styleSheet"]
class WidgetStateManager:
@@ -120,8 +110,16 @@ class WidgetStateManager:
prop = meta.property(i)
name = prop.name()
# Skip persisting QWidget visibility because container widgets (e.g. tab
# stacks, dock managers) manage that state themselves. Restoring a saved
# False can permanently hide a widget, while forcing True makes hidden
# tabs show on top. Leave the property to the parent widget instead.
if name == "visible":
continue
if (
name in PROPERTY_TO_SKIP
name == "objectName"
or name in PROPERTY_TO_SKIP
or not prop.isReadable()
or not prop.isWritable()
or not prop.isStored() # can be extended to fine filter
@@ -178,7 +176,7 @@ class WidgetStateManager:
for i in range(meta.propertyCount()):
prop = meta.property(i)
name = prop.name()
if name in PROPERTY_TO_SKIP:
if name == "visible":
continue
if settings.contains(name):
value = settings.value(name)

View File

@@ -78,6 +78,7 @@ from bec_widgets.widgets.plots.waveform.waveform import Waveform
from bec_widgets.widgets.progress.ring_progress_bar import RingProgressBar
from bec_widgets.widgets.services.bec_queue.bec_queue import BECQueue
from bec_widgets.widgets.services.bec_status_box.bec_status_box import BECStatusBox
from bec_widgets.widgets.utility.logpanel import LogPanel
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
logger = bec_logger.logger
@@ -309,6 +310,7 @@ class AdvancedDockArea(DockAreaWidget):
),
"terminal": (WebConsole.ICON_NAME, "Add Terminal", "WebConsole"),
"bec_shell": (WebConsole.ICON_NAME, "Add BEC Shell", "WebConsole"),
"log_panel": (LogPanel.ICON_NAME, "Add LogPanel - Disabled", "LogPanel"),
"sbb_monitor": ("train", "Add SBB Monitor", "SBBMonitor"),
}
@@ -422,7 +424,9 @@ class AdvancedDockArea(DockAreaWidget):
# first two items not needed for this part
for key, (_, _, widget_type) in mapping.items():
act = menu.actions[key].action
if key == "terminal":
if widget_type == "LogPanel":
act.setEnabled(False) # keep disabled per issue #644
elif key == "terminal":
act.triggered.connect(
lambda _, t=widget_type: self.new(widget=t, closable=True, startup_cmd=None)
)
@@ -446,7 +450,10 @@ class AdvancedDockArea(DockAreaWidget):
for action_id, (_, _, widget_type) in mapping.items():
flat_action_id = f"flat_{action_id}"
flat_action = self.toolbar.components.get_action(flat_action_id).action
flat_action.triggered.connect(lambda _, t=widget_type: self.new(t))
if widget_type == "LogPanel":
flat_action.setEnabled(False) # keep disabled per issue #644
else:
flat_action.triggered.connect(lambda _, t=widget_type: self.new(t))
_connect_flat_actions(self._ACTION_MAPPINGS["menu_plots"])
_connect_flat_actions(self._ACTION_MAPPINGS["menu_devices"])

View File

@@ -37,6 +37,7 @@ from bec_widgets.widgets.plots.waveform.waveform import Waveform
from bec_widgets.widgets.progress.ring_progress_bar.ring_progress_bar import RingProgressBar
from bec_widgets.widgets.services.bec_queue.bec_queue import BECQueue
from bec_widgets.widgets.services.bec_status_box.bec_status_box import BECStatusBox
from bec_widgets.widgets.utility.logpanel.logpanel import LogPanel
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
logger = bec_logger.logger
@@ -221,6 +222,13 @@ class BECDockArea(BECWidget, QWidget):
filled=True,
parent=self,
),
# FIXME temporarily disabled -> issue #644
"log_panel": MaterialIconAction(
icon_name=LogPanel.ICON_NAME,
tooltip="Add LogPanel - Disabled",
filled=True,
parent=self,
),
"sbb_monitor": MaterialIconAction(
icon_name="train", tooltip="Add SBB Monitor", filled=True, parent=self
),
@@ -318,6 +326,8 @@ class BECDockArea(BECWidget, QWidget):
menu_utils.actions["progress_bar"].action.triggered.connect(
lambda: self._create_widget_from_toolbar(widget_name="RingProgressBar")
)
# FIXME temporarily disabled -> issue #644
menu_utils.actions["log_panel"].action.setEnabled(False)
menu_utils.actions["sbb_monitor"].action.triggered.connect(
lambda: self._create_widget_from_toolbar(widget_name="SBBMonitor")

View File

@@ -142,9 +142,12 @@ class MonacoDock(DockAreaWidget):
# Temporarily disable read-only mode if the editor is read-only
# so we can clear the content for reuse
monaco_widget.set_readonly(False)
monaco_widget.set_text("")
monaco_widget.set_text("", reset=True)
dock.setWindowTitle("Untitled")
dock.setTabToolTip("Untitled")
monaco_widget.metadata["scope"] = ""
icon = self._resolve_dock_icon(monaco_widget, dock_icon=None, apply_widget_icon=True)
dock.setIcon(icon)
return
# Otherwise, proceed to close and delete the dock
@@ -249,10 +252,15 @@ class MonacoDock(DockAreaWidget):
self.last_focused_editor = dock
return dock
def open_file(self, file_name: str, scope: str | None = None) -> None:
def open_file(self, file_name: str, scope: str = "") -> None:
"""
Open a file in the specified area. If the file is already open, activate it.
Args:
file_name (str): The path to the file to open.
scope (str): The scope to set for the editor metadata.
"""
open_files = self._get_open_files()
if file_name in open_files:
dock = self._get_editor_dock(file_name)
@@ -281,8 +289,7 @@ class MonacoDock(DockAreaWidget):
editor_dock.setWindowTitle(file)
editor_dock.setTabToolTip(file_name)
editor_widget.open_file(file_name)
if scope is not None:
editor_widget.metadata["scope"] = scope
editor_widget.metadata["scope"] = scope
self.last_focused_editor = editor_dock
return
@@ -290,8 +297,7 @@ class MonacoDock(DockAreaWidget):
editor_dock = self.add_editor(title=file, tooltip=file_name)
widget = cast(MonacoWidget, editor_dock.widget())
widget.open_file(file_name)
if scope is not None:
widget.metadata["scope"] = scope
widget.metadata["scope"] = scope
editor_dock.setAsCurrentTab()
self.last_focused_editor = editor_dock

View File

@@ -0,0 +1,58 @@
"""Utilities for filtering and formatting in the LogPanel"""
from __future__ import annotations
import re
from collections import deque
from typing import Callable, Iterator
from bec_lib.logger import LogLevel
from bec_lib.messages import LogMessage
from qtpy.QtCore import QDateTime
LinesHtmlFormatter = Callable[[deque[LogMessage]], Iterator[str]]
LineFormatter = Callable[[LogMessage], str]
LineFilter = Callable[[LogMessage], bool] | None
ANSI_ESCAPE_REGEX = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
def replace_escapes(s: str):
s = ANSI_ESCAPE_REGEX.sub("", s)
return s.replace(" ", "&nbsp;").replace("\n", "<br />").replace("\t", " ")
def level_filter(msg: LogMessage, thresh: int):
return LogLevel[msg.content["log_type"].upper()].value >= thresh
def noop_format(line: LogMessage):
_textline = line.log_msg if isinstance(line.log_msg, str) else line.log_msg["text"]
return replace_escapes(_textline.strip()) + "<br />"
def simple_color_format(line: LogMessage, colors: dict[LogLevel, str]):
color = colors.get(LogLevel[line.content["log_type"].upper()]) or colors[LogLevel.INFO]
return f'<font color="{color}">{noop_format(line)}</font>'
def create_formatter(line_format: LineFormatter, line_filter: LineFilter) -> LinesHtmlFormatter:
def _formatter(data: deque[LogMessage]):
if line_filter is not None:
return (line_format(line) for line in data if line_filter(line))
else:
return (line_format(line) for line in data)
return _formatter
def log_txt(line):
return line.log_msg if isinstance(line.log_msg, str) else line.log_msg["text"]
def log_time(line):
return QDateTime.fromMSecsSinceEpoch(int(line.log_msg["record"]["time"]["timestamp"] * 1000))
def log_svc(line):
return line.log_msg["service_name"]

View File

@@ -30,7 +30,7 @@ class LogPanelPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
return DOM_XML
def group(self):
return ""
return "BEC Services"
def icon(self):
return designer_material_icon(LogPanel.ICON_NAME)
@@ -51,7 +51,7 @@ class LogPanelPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
return "LogPanel"
def toolTip(self):
return "LogPanel"
return "Displays a log panel"
def whatsThis(self):
return self.toolTip()

View File

@@ -2,30 +2,21 @@
from __future__ import annotations
import operator
import os
import re
from collections import deque
from dataclasses import dataclass
from functools import partial
from typing import Iterable, Literal
from functools import partial, reduce
from re import Pattern
from typing import TYPE_CHECKING, Literal
from bec_lib.client import BECClient
from bec_lib.endpoints import MessageEndpoints
from bec_lib.logger import LogLevel, bec_logger
from bec_lib.messages import LogMessage, StatusMessage
from qtpy.QtCore import Signal # type: ignore
from qtpy.QtCore import (
QAbstractTableModel,
QCoreApplication,
QDateTime,
QModelIndex,
QObject,
QPersistentModelIndex,
QSize,
QSortFilterProxyModel,
Qt,
QTimer,
)
from qtpy.QtGui import QColor
from pyqtgraph import SignalProxy
from qtpy.QtCore import QDateTime, QObject, Qt, Signal
from qtpy.QtGui import QFont
from qtpy.QtWidgets import (
QApplication,
QCheckBox,
@@ -34,407 +25,204 @@ from qtpy.QtWidgets import (
QDialog,
QGridLayout,
QHBoxLayout,
QHeaderView,
QLabel,
QLineEdit,
QPushButton,
QSizePolicy,
QTableView,
QScrollArea,
QTextEdit,
QVBoxLayout,
QWidget,
)
from thefuzz import fuzz
from bec_widgets.utils.bec_connector import BECConnector
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import apply_theme
from bec_widgets.utils.colors import apply_theme, get_theme_palette
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.widgets.editors.text_box.text_box import TextBox
from bec_widgets.widgets.services.bec_status_box.bec_status_box import BECServiceStatusMixin
from bec_widgets.widgets.utility.logpanel._util import (
LineFilter,
LineFormatter,
LinesHtmlFormatter,
create_formatter,
level_filter,
log_svc,
log_time,
log_txt,
noop_format,
simple_color_format,
)
if TYPE_CHECKING: # pragma: no cover
from qtpy.QtCore import SignalInstance
logger = bec_logger.logger
MODULE_PATH = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
_DEFAULT_LOG_COLORS = {
LogLevel.INFO.name: QColor("#FFFFFF"),
LogLevel.SUCCESS.name: QColor("#00FF00"),
LogLevel.WARNING.name: QColor("#FFCC00"),
LogLevel.ERROR.name: QColor("#FF0000"),
LogLevel.DEBUG.name: QColor("#0000CC"),
# TODO: improve log color handling
DEFAULT_LOG_COLORS = {
LogLevel.INFO: "#FFFFFF",
LogLevel.SUCCESS: "#00FF00",
LogLevel.WARNING: "#FFCC00",
LogLevel.ERROR: "#FF0000",
LogLevel.DEBUG: "#0000CC",
}
@dataclass(frozen=True)
class _Constants:
FUZZ_THRESHOLD = 80
UPDATE_INTERVAL_MS = 200
headers = ["level", "timestamp", "service_name", "message", "function"]
_CONST = _Constants()
class TimestampUpdate:
def __init__(self, value: QDateTime | None, update_type: Literal["start", "end"]) -> None:
self.value = value
self.update_type = update_type
class BecLogsQueue(BECConnector, QObject):
"""Manages getting logs from BEC Redis and formatting them for display"""
RPC = False
new_messages = Signal()
_instance: BecLogsQueue | None = None
new_message = Signal()
@classmethod
def instance(cls):
if cls._instance is None:
cls._instance = cls(QCoreApplication.instance())
return cls._instance
def __init__(self, parent: QObject | None, maxlen: int = 1000, **kwargs) -> None:
if BecLogsQueue._instance:
raise RuntimeError("Create no more than one BecLogsQueue - use BecLogsQueue.instance()")
def __init__(
self,
parent: QObject | None,
maxlen: int = 1000,
line_formatter: LineFormatter = noop_format,
**kwargs,
) -> None:
super().__init__(parent=parent, **kwargs)
self._timestamp_start: QDateTime | None = None
self._timestamp_end: QDateTime | None = None
self._max_length = maxlen
self._data = deque(
(
item["data"]
for item in self.bec_dispatcher.client.connector.xread(
MessageEndpoints.log(), count=self._max_length, id="0"
)
),
maxlen=self._max_length,
)
self._incoming: deque[LogMessage] = deque([], maxlen=self._max_length)
self._data: deque[LogMessage] = deque([], self._max_length)
self._display_queue: deque[str] = deque([], self._max_length)
self._log_level: str | None = None
self._search_query: Pattern | str | None = None
self._selected_services: set[str] | None = None
self._set_formatter_and_update_filter(line_formatter)
# instance attribute still accessible after c++ object is deleted, so the callback can be unregistered
self.bec_dispatcher.connect_slot(self._process_incoming_log_msg, MessageEndpoints.log())
self._update_timer = QTimer(self, interval=_CONST.UPDATE_INTERVAL_MS)
self._update_timer.timeout.connect(self._proc_update)
QCoreApplication.instance().aboutToQuit.connect(self.cleanup) # type: ignore
self._update_timer.start()
def __len__(self):
return len(self._data)
def row_data(self, index: int) -> LogMessage | None:
if index < 0 or index > (len(self._data) - 1):
return None
return self._data[index]
def cell_data(self, row: int, key: str):
if key == "level":
return self._data[row].log_type.upper()
msg_item = self._data[row].log_msg
if isinstance(msg_item, str):
return msg_item
if key == "service_name":
return msg_item.get(key)
elif key in ["service_name", "function", "message"]:
return msg_item.get("record", {}).get(key)
elif key == "timestamp":
return msg_item.get("record", {}).get("time", {}).get("repr")
def log_timestamp(self, row: int) -> float:
msg_item = self._data[row].log_msg
if isinstance(msg_item, str):
return 0
return msg_item.get("record", {}).get("time", {}).get("timestamp")
def cleanup(self, *_):
"""Stop listening to the Redis log stream"""
self.bec_dispatcher.disconnect_slot(
self._process_incoming_log_msg, [MessageEndpoints.log()]
)
self._update_timer.stop()
BecLogsQueue._instance = None
@SafeSlot(verify_sender=True)
def _process_incoming_log_msg(self, msg: dict, _metadata: dict):
try:
_msg = LogMessage(**msg)
self._incoming.append(_msg)
self._data.append(_msg)
if self.filter is None or self.filter(_msg):
self._display_queue.append(self._line_formatter(_msg))
self.new_message.emit()
except Exception as e:
if "Internal C++ object (BecLogsQueue) already deleted." in e.args:
return
logger.warning(f"Error in LogPanel incoming message callback: {e}")
@SafeSlot(verify_sender=True)
def _proc_update(self):
if len(self._incoming) == 0:
return
self._data.extend(self._incoming)
self._incoming.clear()
self.new_messages.emit()
def _set_formatter_and_update_filter(self, line_formatter: LineFormatter = noop_format):
self._line_formatter: LineFormatter = line_formatter
self._queue_formatter: LinesHtmlFormatter = create_formatter(
self._line_formatter, self.filter
)
def _combine_filters(self, *args: LineFilter):
return lambda msg: reduce(operator.and_, [filt(msg) for filt in args if filt is not None])
class BecLogsTableModel(QAbstractTableModel):
def __init__(self, parent: QWidget | None = None):
super().__init__(parent)
self.log_queue = BecLogsQueue.instance()
self.log_queue.new_messages.connect(self.handle_new_messages)
self._headers = _CONST.headers
def rowCount(self, parent: QModelIndex | QPersistentModelIndex = QModelIndex()) -> int:
return len(self.log_queue)
def columnCount(self, parent: QModelIndex | QPersistentModelIndex = QModelIndex()) -> int:
return len(self._headers)
def headerData(self, section, orientation, role=int(Qt.ItemDataRole.DisplayRole)):
if role == Qt.ItemDataRole.DisplayRole and orientation == Qt.Orientation.Horizontal:
return self._headers[section]
return None
def get_row_data(self, index: QModelIndex) -> LogMessage | None:
"""Return the row data for the given index."""
if not index.isValid():
def _create_re_filter(self) -> LineFilter:
if self._search_query is None:
return None
return self.log_queue.row_data(index.row())
elif isinstance(self._search_query, str):
return lambda line: self._search_query in log_txt(line)
return lambda line: self._search_query.match(log_txt(line)) is not None
def timestamp(self, row: int):
return QDateTime.fromMSecsSinceEpoch(int(self.log_queue.log_timestamp(row) * 1000))
def _create_service_filter(self):
return (
lambda line: self._selected_services is None or log_svc(line) in self._selected_services
)
def data(self, index, role=int(Qt.ItemDataRole.DisplayRole)):
"""Return data for the given index and role."""
if not index.isValid():
def _create_timestamp_filter(self) -> LineFilter:
s, e = self._timestamp_start, self._timestamp_end
if s is e is None:
return lambda msg: True
def _time_filter(msg):
msg_time = log_time(msg)
if s is None:
return msg_time <= e
if e is None:
return s <= msg_time
return s <= msg_time <= e
return _time_filter
@property
def filter(self) -> LineFilter:
"""A function which filters a log message based on all applied criteria"""
thresh = LogLevel[self._log_level].value if self._log_level is not None else 0
return self._combine_filters(
partial(level_filter, thresh=thresh),
self._create_re_filter(),
self._create_timestamp_filter(),
self._create_service_filter(),
)
def update_level_filter(self, level: str):
"""Change the log-level of the level filter"""
if level not in [l.name for l in LogLevel]:
logger.error(f"Logging level {level} unrecognized for filter!")
return
if role in [Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.ToolTipRole]:
return self.log_queue.cell_data(index.row(), self._headers[index.column()])
if role in [Qt.ItemDataRole.ForegroundRole]:
return self._map_log_level_color(self.log_queue.cell_data(index.row(), "level"))
self._log_level = level
self._set_formatter_and_update_filter(self._line_formatter)
def _map_log_level_color(self, data):
return _DEFAULT_LOG_COLORS.get(data)
def update_search_filter(self, search_query: Pattern | str | None = None):
"""Change the string or regex to filter against"""
self._search_query = search_query
self._set_formatter_and_update_filter(self._line_formatter)
def handle_new_messages(self):
self.dataChanged.emit(
self.index(0, 0), self.index(self.rowCount() - 1, self.columnCount() - 1)
def update_time_filter(self, start: QDateTime | None, end: QDateTime | None):
"""Change the start and/or end times to filter against"""
self._timestamp_start = start
self._timestamp_end = end
self._set_formatter_and_update_filter(self._line_formatter)
def update_service_filter(self, services: set[str]):
"""Change the selected services to display"""
self._selected_services = services
self._set_formatter_and_update_filter(self._line_formatter)
def update_line_formatter(self, line_formatter: LineFormatter):
"""Update the formatter"""
self._set_formatter_and_update_filter(line_formatter)
def display_all(self) -> str:
"""Return formatted output for all log messages"""
return "\n".join(self._queue_formatter(self._data.copy()))
def format_new(self):
"""Return formatted output for the display queue"""
res = "\n".join(self._display_queue)
self._display_queue = deque([], self._max_length)
return res
def clear_logs(self):
"""Clear the cache and display queue"""
self._data = deque([])
self._display_queue = deque([])
def fetch_history(self):
"""Fetch all available messages from Redis"""
self._data = deque(
item["data"]
for item in self.bec_dispatcher.client.connector.xread(
MessageEndpoints.log().endpoint, from_start=True, count=self._max_length
)
)
class LogMsgProxyModel(QSortFilterProxyModel):
show_service_column = Signal(bool)
def __init__(
self,
parent=None,
service_filter: set[str] | None = None,
level_filter: LogLevel | None = None,
):
super().__init__(parent)
self._service_filter = service_filter or set()
self._level_filter: LogLevel | None = level_filter
self._filter_text: str = ""
self._fuzzy_search: bool = False
self._time_filter_start: QDateTime | None = None
self._time_filter_end: QDateTime | None = None
def get_row_data(self, rows: Iterable[QModelIndex]) -> Iterable[LogMessage | None]:
return (self.sourceModel().get_row_data(self.mapToSource(idx)) for idx in rows)
def sourceModel(self) -> BecLogsTableModel:
return super().sourceModel() # type: ignore
@SafeSlot(int, int)
def refresh(self, *_):
self.invalidateRowsFilter()
@SafeSlot(None)
@SafeSlot(set)
def update_service_filter(self, filter: set[str]):
"""Filter to the selected services (show any service in the provided set)
Args:
filter (set[str] | None): set of services for which to show logs"""
self._service_filter = filter
self.show_service_column.emit(len(filter) != 1)
self.invalidateRowsFilter()
@SafeSlot(None)
@SafeSlot(LogLevel)
def update_level_filter(self, filter: LogLevel | None):
"""Filter to the selected log level
Args:
filter (str | None): lowest log level to show"""
self._level_filter = filter
self.invalidateRowsFilter()
@SafeSlot(str)
def update_filter_text(self, filter: str):
"""Filter messages based on text
Args:
filter (str | None): set of services for which to show logs"""
self._filter_text = filter
self.invalidateRowsFilter()
@SafeSlot(bool)
def update_fuzzy(self, state: bool):
"""Set text filter to fuzzy search or not
Args:
state (bool): fuzzy search on"""
self._fuzzy_search = state
self.invalidateRowsFilter()
@SafeSlot(TimestampUpdate)
def update_timestamp(self, update: TimestampUpdate):
if update.update_type == "start":
self._time_filter_start = update.value
else:
self._time_filter_end = update.value
self.invalidateRowsFilter()
def filterAcceptsRow(self, source_row: int, source_parent) -> bool:
# No service filter, and no filter text, display everything
possible_filters = [
self._service_filter,
self._level_filter,
self._filter_text,
self._time_filter_start,
self._time_filter_end,
]
if not any(map(bool, possible_filters)):
return True
model = self.sourceModel()
# Filter out services
if self._service_filter:
col = _CONST.headers.index("service_name")
if model.data(model.index(source_row, col, source_parent)) not in self._service_filter:
return False
# Filter out levels
if self._level_filter:
col = _CONST.headers.index("level")
level: str = model.data(model.index(source_row, col, source_parent)) # type: ignore
if LogLevel[level] < self._level_filter:
return False
# Filter time
if self._time_filter_start:
if model.timestamp(source_row) < self._time_filter_start:
return False
if self._time_filter_end:
if model.timestamp(source_row) > self._time_filter_end:
return False
# Filter message text - must go last because this can return True
if self._filter_text:
col = _CONST.headers.index("message")
msg: str = model.data(model.index(source_row, col, source_parent)).lower() # type: ignore
if self._fuzzy_search:
if fuzz.partial_ratio(self._filter_text.lower(), msg) >= _CONST.FUZZ_THRESHOLD:
return True
else:
return self._filter_text.lower() in msg.lower()
return True
class BecLogTableView(QTableView):
def __init__(self, *args, max_message_width: int = 1000, **kwargs) -> None:
super().__init__(*args, **kwargs)
header = QHeaderView(Qt.Orientation.Horizontal, parent=self)
header.setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents)
header.setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch)
header.setStretchLastSection(True)
header.setMaximumSectionSize(max_message_width)
self.setHorizontalHeader(header)
def model(self) -> LogMsgProxyModel:
return super().model() # type: ignore
class LogPanel(BECWidget, QWidget):
"""Live display of the BEC logs in a table view."""
PLUGIN = True
def __init__(
self,
parent: QWidget | None = None,
max_message_width: int = 1000,
show_toolbar: bool = True,
service_filter: set[str] | None = None,
level_filter: LogLevel | None = None,
**kwargs,
) -> None:
super().__init__(parent=parent, **kwargs)
self._setup_models(service_filter=service_filter, level_filter=level_filter)
self._layout = QVBoxLayout()
self.setLayout(self._layout)
if show_toolbar:
self._setup_toolbar(client=self.client)
self._setup_table_view(max_message_width=max_message_width)
self._update_service_filter(service_filter or set())
if show_toolbar:
self._connect_toolbar()
self._proxy.show_service_column.connect(self._show_service_column)
colors = QApplication.instance().theme.accent_colors # type: ignore
dict_colors = QApplication.instance().theme.colors # type: ignore
_DEFAULT_LOG_COLORS.update(
{
LogLevel.INFO.name: dict_colors["FG"],
LogLevel.SUCCESS.name: colors.success,
LogLevel.WARNING.name: colors.warning,
LogLevel.ERROR.name: colors.emergency,
LogLevel.DEBUG.name: dict_colors["BORDER"],
}
)
self._table.scrollToBottom()
def _setup_models(self, service_filter: set[str] | None, level_filter: LogLevel | None):
self._model = BecLogsTableModel(parent=self)
self._proxy = LogMsgProxyModel(
parent=self, service_filter=service_filter, level_filter=level_filter
)
self._proxy.setSourceModel(self._model)
self._model.log_queue.new_messages.connect(self._proxy.refresh)
def _setup_table_view(self, max_message_width: int) -> None:
"""Setup the table view."""
self._table = BecLogTableView(self, max_message_width=max_message_width)
self._table.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
self._layout.addWidget(self._table)
self._table.setModel(self._proxy)
self._table.setHorizontalScrollMode(QTableView.ScrollMode.ScrollPerPixel)
self._table.setTextElideMode(Qt.TextElideMode.ElideRight)
self._table.resizeColumnsToContents()
def _setup_toolbar(self, client: BECClient):
self._toolbar = LogPanelToolbar(self, client)
self._layout.addWidget(self._toolbar)
def _connect_toolbar(self):
self._toolbar.services_selected.connect(self._proxy.update_service_filter)
self._toolbar.search_textbox.textChanged.connect(self._proxy.update_filter_text)
self._toolbar.level_changed.connect(self._proxy.update_level_filter)
self._toolbar.fuzzy_changed.connect(self._proxy.update_fuzzy)
self._toolbar.timestamp_update.connect(self._proxy.update_timestamp)
def _update_service_filter(self, filter: set[str]):
self._service_filter = filter
self._proxy.update_service_filter(filter)
self._table.setColumnHidden(
_CONST.headers.index("service_name"), len(self._service_filter) == 1
)
@SafeSlot(bool)
def _show_service_column(self, show: bool):
self._table.setColumnHidden(_CONST.headers.index("service_name"), not show)
def sizeHint(self) -> QSize:
return QSize(600, 300)
def unique_service_names_from_history(self) -> set[str]:
"""Go through the log history to determine active service names"""
return set(msg.log_msg["service_name"] for msg in self._data)
class LogPanelToolbar(QWidget):
services_selected = Signal(set)
level_changed = Signal(LogLevel)
fuzzy_changed = Signal(bool)
timestamp_update = Signal(TimestampUpdate)
services_selected: SignalInstance = Signal(set)
def __init__(self, parent: QWidget | None = None, client: BECClient | None = None) -> None:
def __init__(self, parent: QWidget | None = None) -> None:
"""A toolbar for the logpanel, mainly used for managing the states of filters"""
super().__init__(parent)
@@ -443,34 +231,44 @@ class LogPanelToolbar(QWidget):
self._timestamp_end: QDateTime | None = None
self._unique_service_names: set[str] = set()
self._services_selected: set[str] = set()
self._services_selected: set[str] | None = None
self._layout = QHBoxLayout(self)
self.layout = QHBoxLayout(self) # type: ignore
if client is not None:
self.client = client
self.service_choice_button = QPushButton("Select services", self)
self._layout.addWidget(self.service_choice_button)
self.service_choice_button.clicked.connect(self._open_service_filter_dialog)
self.service_choice_button = QPushButton("Select services", self)
self.layout.addWidget(self.service_choice_button)
self.service_choice_button.clicked.connect(self._open_service_filter_dialog)
self.filter_level_dropdown = self._log_level_box()
self._layout.addWidget(self.filter_level_dropdown)
self.filter_level_dropdown.currentTextChanged.connect(self._emit_level)
self.layout.addWidget(self.filter_level_dropdown)
self.clear_button = QPushButton("Clear all", self)
self.layout.addWidget(self.clear_button)
self.fetch_button = QPushButton("Fetch history", self)
self.layout.addWidget(self.fetch_button)
self._string_search_box()
self.timerange_button = QPushButton("Set time range", self)
self._layout.addWidget(self.timerange_button)
self.timerange_button.clicked.connect(self._open_datetime_dialog)
self.layout.addWidget(self.timerange_button)
@property
def time_start(self):
return self._timestamp_start
@property
def time_end(self):
return self._timestamp_end
def _string_search_box(self):
self._layout.addWidget(QLabel("Search: "))
self.layout.addWidget(QLabel("Search: "))
self.search_textbox = QLineEdit()
self._layout.addWidget(self.search_textbox)
self._layout.addWidget(QLabel("Fuzzy: "))
self.fuzzy = QCheckBox()
self._layout.addWidget(self.fuzzy)
self.fuzzy.checkStateChanged.connect(self._emit_fuzzy)
self.layout.addWidget(self.search_textbox)
self.layout.addWidget(QLabel("Use regex: "))
self.regex_enabled = QCheckBox()
self.layout.addWidget(self.regex_enabled)
self.update_re_button = QPushButton("Update search", self)
self.layout.addWidget(self.update_re_button)
def _log_level_box(self):
box = QComboBox()
@@ -478,14 +276,6 @@ class LogPanelToolbar(QWidget):
[box.addItem(l.name) for l in LogLevel]
return box
@SafeSlot(str)
def _emit_level(self, level: str):
self.level_changed.emit(LogLevel[level])
@SafeSlot(Qt.CheckState)
def _emit_fuzzy(self, state: Qt.CheckState):
self.fuzzy_changed.emit(state == Qt.CheckState.Checked)
def _current_ts(self, selection_type: Literal["start", "end"]):
if selection_type == "start":
return self._timestamp_start
@@ -494,7 +284,6 @@ class LogPanelToolbar(QWidget):
else:
raise ValueError(f"timestamps can only be for the start or end, not {selection_type}")
@SafeSlot()
def _open_datetime_dialog(self):
"""Open dialog window for timestamp filter selection"""
self._dt_dialog = QDialog(self)
@@ -523,8 +312,8 @@ class LogPanelToolbar(QWidget):
)
_layout.addWidget(date_clear_button)
date_button_set("start", label_start)
date_button_set("end", label_end)
for v in [("start", label_start), ("end", label_end)]:
date_button_set(*v)
close_button = QPushButton("Close", parent=self._dt_dialog)
close_button.clicked.connect(self._dt_dialog.accept)
@@ -563,15 +352,19 @@ class LogPanelToolbar(QWidget):
self._timestamp_start = dt
else:
self._timestamp_end = dt
self.timestamp_update.emit(TimestampUpdate(value=dt, update_type=selection_type))
def service_list_update(self, services_info: dict[str, StatusMessage]):
@SafeSlot(dict, set)
def service_list_update(
self, services_info: dict[str, StatusMessage], services_from_history: set[str], *_, **__
):
"""Change the list of services which can be selected"""
self._unique_service_names = set([s.split("/")[0] for s in services_info.keys()])
self._unique_service_names |= services_from_history
if self._services_selected is None:
self._services_selected = self._unique_service_names
@SafeSlot()
def _open_service_filter_dialog(self):
self.service_list_update(self.client.service_status)
if len(self._unique_service_names) == 0 or self._services_selected is None:
return
self._svc_dialog = QDialog(self)
@@ -579,7 +372,7 @@ class LogPanelToolbar(QWidget):
layout = QVBoxLayout()
self._svc_dialog.setLayout(layout)
service_cb_grid = QGridLayout()
service_cb_grid = QGridLayout(parent=self._svc_dialog)
layout.addLayout(service_cb_grid)
def check_box(name: str, checked: Qt.CheckState):
@@ -605,6 +398,146 @@ class LogPanelToolbar(QWidget):
self._svc_dialog.deleteLater()
class LogPanel(TextBox):
"""Displays a log panel"""
ICON_NAME = "terminal"
service_list_update = Signal(dict, set)
def __init__(
self,
parent=None,
client: BECClient | None = None,
service_status: BECServiceStatusMixin | None = None,
**kwargs,
):
"""Initialize the LogPanel widget."""
super().__init__(parent=parent, client=client, config={"text": ""}, **kwargs)
self._update_colors()
self._service_status = service_status or BECServiceStatusMixin(self, client=self.client) # type: ignore
self._log_manager = BecLogsQueue(
parent=self, line_formatter=partial(simple_color_format, colors=self._colors)
)
self._proxy_update = SignalProxy(
self._log_manager.new_message, rateLimit=1, slot=self._on_append
)
self.toolbar = LogPanelToolbar(parent=self)
self.toolbar_area = QScrollArea()
self.toolbar_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.toolbar_area.setSizeAdjustPolicy(QScrollArea.SizeAdjustPolicy.AdjustToContents)
self.toolbar_area.setFixedHeight(int(self.toolbar.clear_button.height() * 2))
self.toolbar_area.setWidget(self.toolbar)
self.layout.addWidget(self.toolbar_area)
self.toolbar.clear_button.clicked.connect(self._on_clear)
self.toolbar.fetch_button.clicked.connect(self._on_fetch)
self.toolbar.update_re_button.clicked.connect(self._on_re_update)
self.toolbar.search_textbox.returnPressed.connect(self._on_re_update)
self.toolbar.regex_enabled.checkStateChanged.connect(self._on_re_update)
self.toolbar.filter_level_dropdown.currentTextChanged.connect(self._set_level_filter)
self.toolbar.timerange_button.clicked.connect(self._choose_datetime)
self._service_status.services_update.connect(self._update_service_list)
self.service_list_update.connect(self.toolbar.service_list_update)
self.toolbar.services_selected.connect(self._update_service_filter)
self.text_box_text_edit.setFont(QFont("monospace", 12))
self.text_box_text_edit.setHtml("")
self.text_box_text_edit.setLineWrapMode(QTextEdit.LineWrapMode.NoWrap)
self._connect_to_theme_change()
@SafeSlot(set)
def _update_service_filter(self, services: set[str]):
self._log_manager.update_service_filter(services)
self._on_redraw()
@SafeSlot(dict, dict)
def _update_service_list(self, services_info: dict[str, StatusMessage], *_, **__):
self.service_list_update.emit(
services_info, self._log_manager.unique_service_names_from_history()
)
@SafeSlot()
def _choose_datetime(self):
self.toolbar._open_datetime_dialog()
self._set_time_filter()
def _connect_to_theme_change(self):
"""Connect to the theme change signal."""
qapp = QApplication.instance()
if hasattr(qapp, "theme_signal"):
qapp.theme_signal.theme_updated.connect(self._on_redraw) # type: ignore
def _update_colors(self):
self._colors = DEFAULT_LOG_COLORS.copy()
self._colors.update({LogLevel.INFO: get_theme_palette().text().color().name()})
def _cursor_to_end(self):
c = self.text_box_text_edit.textCursor()
c.movePosition(c.MoveOperation.End)
self.text_box_text_edit.setTextCursor(c)
@SafeSlot()
@SafeSlot(str)
def _on_redraw(self, *_):
self._update_colors()
self._log_manager.update_line_formatter(partial(simple_color_format, colors=self._colors))
self.set_html_text(self._log_manager.display_all())
self._cursor_to_end()
@SafeSlot(verify_sender=True)
def _on_append(self, *_):
self.text_box_text_edit.insertHtml(self._log_manager.format_new())
self._cursor_to_end()
@SafeSlot()
def _on_clear(self):
self._log_manager.clear_logs()
self.set_html_text(self._log_manager.display_all())
self._cursor_to_end()
@SafeSlot()
@SafeSlot(Qt.CheckState)
def _on_re_update(self, *_):
if self.toolbar.regex_enabled.isChecked():
try:
search_query = re.compile(self.toolbar.search_textbox.text())
except Exception as e:
logger.warning(f"Failed to compile search regex with error {e}")
search_query = None
logger.info(f"Setting LogPanel search regex to {search_query}")
else:
search_query = self.toolbar.search_textbox.text()
logger.info(f'Setting LogPanel search string to "{search_query}"')
self._log_manager.update_search_filter(search_query)
self.set_html_text(self._log_manager.display_all())
self._cursor_to_end()
@SafeSlot()
def _on_fetch(self):
self._log_manager.fetch_history()
self.set_html_text(self._log_manager.display_all())
self._cursor_to_end()
@SafeSlot(str)
def _set_level_filter(self, level: str):
self._log_manager.update_level_filter(level)
self._on_redraw()
@SafeSlot()
def _set_time_filter(self):
self._log_manager.update_time_filter(self.toolbar.time_start, self.toolbar.time_end)
self._on_redraw()
def cleanup(self):
self._service_status.cleanup()
self._log_manager.cleanup()
self._log_manager.deleteLater()
super().cleanup()
if __name__ == "__main__": # pragma: no cover
import sys
@@ -612,15 +545,7 @@ if __name__ == "__main__": # pragma: no cover
app = QApplication(sys.argv)
apply_theme("dark")
panel = QWidget()
queue = BecLogsQueue(panel)
layout = QVBoxLayout(panel)
layout.addWidget(QLabel("All logs, no filters:"))
layout.addWidget(LogPanel())
layout.addWidget(QLabel("All services, level filter WARNING preapplied:"))
layout.addWidget(LogPanel(level_filter=LogLevel.WARNING))
layout.addWidget(QLabel('All services, service filter {"DeviceServer"} preapplied:'))
layout.addWidget(LogPanel(service_filter={"DeviceServer"}))
widget = LogPanel()
panel.show()
widget.show()
sys.exit(app.exec())

View File

@@ -1,5 +1,3 @@
import traceback
import pytest
import qtpy.QtCore
from pytestqt.exceptions import TimeoutError as QtBotTimeoutError
@@ -7,14 +5,12 @@ from qtpy.QtCore import QTimer
class TestableQTimer(QTimer):
_instances: list[tuple[QTimer, str, str]] = []
_instances: list[tuple[QTimer, str]] = []
_current_test_name: str = ""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
tb = traceback.format_stack()
init_line = list(filter(lambda msg: "QTimer(" in msg, tb))[-1]
TestableQTimer._instances.append((self, TestableQTimer._current_test_name, init_line))
TestableQTimer._instances.append((self, TestableQTimer._current_test_name))
@classmethod
def check_all_stopped(cls, qtbot):
@@ -24,21 +20,12 @@ class TestableQTimer(QTimer):
except RuntimeError as e:
return "already deleted" in e.args[0]
def _format_timers(timers: list[tuple[QTimer, str, str]]):
return "\n".join(
f"Timer: {t[0]}\n in test: {t[1]}\n created at:{t[2]}" for t in timers
)
try:
qtbot.waitUntil(
lambda: all(_is_done_or_deleted(timer) for timer, _, _ in cls._instances)
)
qtbot.waitUntil(lambda: all(_is_done_or_deleted(timer) for timer, _ in cls._instances))
except QtBotTimeoutError as exc:
active_timers = list(filter(lambda t: t[0].isActive(), cls._instances))
(t.stop() for t, _, _ in cls._instances)
raise TimeoutError(
f"Failed to stop all timers:\n{_format_timers(active_timers)}"
) from exc
(t.stop() for t, _ in cls._instances)
raise TimeoutError(f"Failed to stop all timers: {active_timers}") from exc
cls._instances = []

View File

@@ -363,6 +363,23 @@ def test_widgets_e2e_image(qtbot, connected_client_gui_obj, random_generator_fro
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
# TODO re-enable when issue is resolved #560
# @pytest.mark.timeout(PYTEST_TIMEOUT)
# def test_widgets_e2e_log_panel(qtbot, connected_client_gui_obj, random_generator_from_seed):
# """Test the LogPanel widget."""
# gui = connected_client_gui_obj
# bec = gui._client
# # Create dock_area, dock, widget
# dock, widget = create_widget(qtbot, gui, gui.available_widgets.LogPanel)
# dock: client.BECDock
# widget: client.LogPanel
# # No rpc calls to check so far
# # Test removing the widget, or leaving it open for the next test
# maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
@pytest.mark.timeout(PYTEST_TIMEOUT)
def test_widgets_e2e_minesweeper(qtbot, connected_client_gui_obj, random_generator_from_seed):
"""Test the MineSweeper widget."""

View File

@@ -2095,6 +2095,7 @@ class TestFlatToolbarActions:
"flat_progress_bar",
"flat_terminal",
"flat_bec_shell",
"flat_log_panel",
"flat_sbb_monitor",
]
@@ -2154,6 +2155,11 @@ class TestFlatToolbarActions:
action.trigger()
mock_new.assert_called_once_with(widget_type)
def test_flat_log_panel_action_disabled(self, advanced_dock_area):
"""Test that flat log panel action is disabled."""
action = advanced_dock_area.toolbar.components.get_action("flat_log_panel").action
assert not action.isEnabled()
class TestModeTransitions:
"""Test mode transitions and state consistency."""

View File

@@ -7,123 +7,163 @@ from collections import deque
from unittest.mock import MagicMock, patch
import pytest
from bec_lib.logger import LogLevel
from bec_lib.messages import LogMessage
from bec_lib.redis_connector import StreamMessage
from qtpy.QtCore import QDateTime
from bec_widgets.widgets.utility.logpanel.logpanel import LogPanel, TimestampUpdate
from bec_widgets.widgets.utility.logpanel._util import (
log_time,
replace_escapes,
simple_color_format,
)
from bec_widgets.widgets.utility.logpanel.logpanel import DEFAULT_LOG_COLORS, LogPanel
from .client_mocks import mocked_client
TEST_TABLE_STRING = "2025-01-15 15:57:18 | bec_server.scan_server.scan_queue | [INFO] | \n \x1b[3m primary queue / ScanQueueStatus.RUNNING \x1b[0m\n┏━━━━━━━━━━━━━┳━━━━━━━━━┳━━━━━━━━━┳━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━━━━┓\n\x1b[1m \x1b[0m\x1b[1m queue_id \x1b[0m\x1b[1m \x1b[0m┃\x1b[1m \x1b[0m\x1b[1mscan_id\x1b[0m\x1b[1m \x1b[0m┃\x1b[1m \x1b[0m\x1b[1mis_scan\x1b[0m\x1b[1m \x1b[0m┃\x1b[1m \x1b[0m\x1b[1mtype\x1b[0m\x1b[1m \x1b[0m┃\x1b[1m \x1b[0m\x1b[1mscan_numb…\x1b[0m\x1b[1m \x1b[0m┃\x1b[1m \x1b[0m\x1b[1mIQ status\x1b[0m\x1b[1m \x1b[0m┃\n┡━━━━━━━━━━━━━╇━━━━━━━━━╇━━━━━━━━━╇━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━━━━┩\n│ bbe50c82-6… │ None │ False │ mv │ None │ PENDING │\n└─────────────┴─────────┴─────────┴──────┴────────────┴───────────┘\n\n"
TEST_LOG_MESSAGES = [
{"data": msg}
for msg in [
LogMessage(
LogMessage(
metadata={},
log_type="debug",
log_msg={
"text": "datetime | debug | test log message",
"record": {"time": {"timestamp": 123456789.000}},
"service_name": "ScanServer",
},
),
LogMessage(
metadata={},
log_type="info",
log_msg={
"text": "datetime | info | test log message",
"record": {"time": {"timestamp": 123456789.007}},
"service_name": "ScanServer",
},
),
LogMessage(
metadata={},
log_type="success",
log_msg={
"text": "datetime | success | test log message",
"record": {"time": {"timestamp": 123456789.012}},
"service_name": "ScanServer",
},
),
]
TEST_COMBINED_PLAINTEXT = "datetime | debug | test log message\ndatetime | info | test log message\ndatetime | success | test log message\n"
@pytest.fixture
def raw_queue():
yield deque(TEST_LOG_MESSAGES, maxlen=100)
@pytest.fixture
def log_panel(qtbot, mocked_client: MagicMock):
widget = LogPanel(client=mocked_client, service_status=MagicMock())
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
def test_log_panel_init(log_panel: LogPanel):
assert log_panel.plain_text == ""
def test_table_string_processing():
assert "\x1b" in TEST_TABLE_STRING
sanitized = replace_escapes(TEST_TABLE_STRING)
assert "\x1b" not in sanitized
assert " " not in sanitized
assert "\n" not in sanitized
@pytest.mark.parametrize(
["msg", "color"], zip(TEST_LOG_MESSAGES, ["#0000CC", "#FFFFFF", "#00FF00"])
)
def test_color_format(msg: LogMessage, color: str):
assert color in simple_color_format(msg, DEFAULT_LOG_COLORS)
def test_logpanel_output(qtbot, log_panel: LogPanel):
log_panel._log_manager._data = deque(TEST_LOG_MESSAGES)
log_panel._on_redraw()
assert log_panel.plain_text == TEST_COMBINED_PLAINTEXT
def display_queue_empty():
print(log_panel._log_manager._display_queue)
return len(log_panel._log_manager._display_queue) == 0
next_text = "datetime | error | test log message"
msg = LogMessage(
metadata={},
log_type="error",
log_msg={"text": next_text, "record": {}, "service_name": "ScanServer"},
)
log_panel._log_manager._process_incoming_log_msg(
msg.content, msg.metadata, _override_slot_params={"verify_sender": False}
)
qtbot.waitUntil(display_queue_empty, timeout=5000)
assert log_panel.plain_text == TEST_COMBINED_PLAINTEXT + next_text + "\n"
def test_level_filter(log_panel: LogPanel):
log_panel._log_manager._data = deque(TEST_LOG_MESSAGES)
log_panel._log_manager.update_level_filter("INFO")
log_panel._on_redraw()
assert (
log_panel.plain_text
== "datetime | info | test log message\ndatetime | success | test log message\n"
)
def test_clear_button(log_panel: LogPanel):
log_panel._log_manager._data = deque(TEST_LOG_MESSAGES)
log_panel.toolbar.clear_button.click()
assert log_panel._log_manager._data == deque([])
def test_timestamp_filter(log_panel: LogPanel):
log_panel._log_manager._timestamp_start = QDateTime(1973, 11, 29, 21, 33, 9, 5, 1)
pytest.approx(log_panel._log_manager._timestamp_start.toMSecsSinceEpoch() / 1000, 123456789.005)
log_panel._log_manager._timestamp_end = QDateTime(1973, 11, 29, 21, 33, 9, 10, 1)
pytest.approx(log_panel._log_manager._timestamp_end.toMSecsSinceEpoch() / 1000, 123456789.010)
filter_ = log_panel._log_manager._create_timestamp_filter()
assert not filter_(TEST_LOG_MESSAGES[0])
assert filter_(TEST_LOG_MESSAGES[1])
assert not filter_(TEST_LOG_MESSAGES[2])
def test_error_handling_in_callback(log_panel: LogPanel):
log_panel._log_manager.new_message = MagicMock()
with patch("bec_widgets.widgets.utility.logpanel.logpanel.logger") as logger:
# generally errors should be logged
log_panel._log_manager.new_message.emit = MagicMock(
side_effect=ValueError("Something went wrong")
)
msg = LogMessage(
metadata={},
log_type="debug",
log_msg={
"text": "datetime | debug | test log message",
"record": {
"time": {"timestamp": 123456789.000, "repr": "2025-01-01 00:00:01"},
"message": "test debug message abcd",
"function": "_debug",
},
"service_name": "ScanServer",
},
),
LogMessage(
metadata={},
log_type="info",
log_msg={
"text": "datetime | info | test info log message",
"record": {
"time": {"timestamp": 123456789.007, "repr": "2025-01-01 00:00:02"},
"message": "test info message efgh",
"function": "_info",
},
"service_name": "DeviceServer",
},
),
LogMessage(
metadata={},
log_type="success",
log_msg={
"text": "datetime | success | test log message",
"record": {
"time": {"timestamp": 123456789.012, "repr": "2025-01-01 00:00:03"},
"message": "test success message ijkl",
"function": "_success",
},
"service_name": "ScanServer",
},
),
]
]
@pytest.fixture
def log_panel(qtbot, mocked_client):
mocked_client.connector.xread = lambda *_, **__: TEST_LOG_MESSAGES
widget = LogPanel()
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
widget._model.log_queue.cleanup()
widget.close()
widget.deleteLater()
qtbot.wait(100)
def test_log_panel_init(qtbot, log_panel: LogPanel):
assert log_panel
def test_log_panel_filters(qtbot, log_panel: LogPanel):
assert log_panel._proxy.rowCount() == 3
# Service filter
log_panel._update_service_filter({"DeviceServer"})
qtbot.waitUntil(lambda: log_panel._proxy.rowCount() == 1, timeout=200)
log_panel._update_service_filter(set())
qtbot.waitUntil(lambda: log_panel._proxy.rowCount() == 3, timeout=200)
# Text filter
log_panel._proxy.update_filter_text("efgh")
qtbot.waitUntil(lambda: log_panel._proxy.rowCount() == 1, timeout=200)
log_panel._proxy.update_filter_text("")
qtbot.waitUntil(lambda: log_panel._proxy.rowCount() == 3, timeout=200)
# Time filter
log_panel._proxy.update_timestamp(
TimestampUpdate(value=QDateTime.fromMSecsSinceEpoch(123456789004), update_type="start")
)
qtbot.waitUntil(lambda: log_panel._proxy.rowCount() == 2, timeout=200)
log_panel._proxy.update_timestamp(
TimestampUpdate(value=QDateTime.fromMSecsSinceEpoch(123456789009), update_type="end")
)
qtbot.waitUntil(lambda: log_panel._proxy.rowCount() == 1, timeout=200)
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)
qtbot.waitUntil(lambda: log_panel._proxy.rowCount() == 1, timeout=200)
log_panel._proxy.update_level_filter(None)
qtbot.waitUntil(lambda: log_panel._proxy.rowCount() == 3, timeout=200)
def test_log_panel_update(qtbot, log_panel: LogPanel):
log_panel._model.log_queue._incoming.append(
LogMessage(
metadata={},
log_type="error",
log_msg={
"text": "datetime | error | test log message",
"record": {
"time": {"timestamp": 123456789.015, "repr": "2025-01-01 00:00:03"},
"message": "test error message xyz",
"function": "_error",
},
"record": {"time": {"timestamp": 123456789.000}},
"service_name": "ScanServer",
},
)
)
log_panel._model.log_queue._proc_update()
qtbot.waitUntil(lambda: log_panel._model.rowCount() == 4, timeout=500)
log_panel._log_manager._process_incoming_log_msg(
msg.content, msg.metadata, _override_slot_params={"verify_sender": False}
)
logger.warning.assert_called_once()
# this specific error should be ignored and not relogged
log_panel._log_manager.new_message.emit = MagicMock(
side_effect=RuntimeError("Internal C++ object (BecLogsQueue) already deleted.")
)
log_panel._log_manager._process_incoming_log_msg(
msg.content, msg.metadata, _override_slot_params={"verify_sender": False}
)
logger.warning.assert_called_once()