diff --git a/bec_widgets/widgets/utility/logpanel/log_panel_demo.py b/bec_widgets/widgets/utility/logpanel/log_panel_demo.py new file mode 100644 index 00000000..ae14291b --- /dev/null +++ b/bec_widgets/widgets/utility/logpanel/log_panel_demo.py @@ -0,0 +1,155 @@ +"""Realistic sample BEC logs for the log panel demo, screenshots and benchmarks. + +Run ``python -m bec_widgets.widgets.utility.logpanel.log_panel_demo`` to compare the legacy panel, +the QWidget version and the QML version side by side with a live feed. No BEC server is needed. +""" + +from __future__ import annotations + +import random +import sys +import time + +from bec_widgets.widgets.utility.logpanel.log_ux_common import LogEntry, LogFeedModel + +SERVICES = [ + "ScanServer", + "DeviceServer", + "ScanBundler", + "FileWriterManager", + "SciHub", + "DAPServer", + "BECIPythonClient", +] + +TRACEBACK = """Traceback (most recent call last): + File "/opt/bec/bec_server/device_server/device_server.py", line 412, in _set_device + status = obj.set(value) + File "/opt/bec/ophyd_devices/sim/sim_positioner.py", line 188, in set + self._check_limits(value) + File "/opt/bec/ophyd_devices/sim/sim_positioner.py", line 131, in _check_limits + raise LimitError(f"position={value} not within limits {self.limits}") +ophyd.utils.errors.LimitError: position=12.5 not within limits (-10, 10)""" + +TRACEBACK_TIMEOUT = """Error while reading bpm4i: device did not answer +Traceback (most recent call last): + File "/opt/bec/bec_server/device_server/devices/device_serializer.py", line 77, in read + return self.obj.read() + File "/opt/epics/ophyd/signal.py", line 1204, in read + raise TimeoutError(f"{self.name} did not respond within {timeout} s") +TimeoutError: bpm4i did not respond within 2.0 s""" + +_TEMPLATES = [ + ("INFO", "ScanServer", "Scan {scan} started: line_scan(samx, -5, 5, steps=50, exp_time=0.1)"), + ("INFO", "ScanServer", "Scan {scan} finished in {dur:.1f} s"), + ("INFO", "ScanBundler", "Emitting scan segment {point} of scan {scan}"), + ("INFO", "DeviceServer", "samx moved to {pos:.3f} mm"), + ("INFO", "DeviceServer", "samy moved to {pos:.3f} mm"), + ("DEBUG", "DeviceServer", "Readback of bpm4i: {val:.4e}"), + ("DEBUG", "ScanBundler", "Bundling point {point} for queue primary"), + ("INFO", "FileWriterManager", "Writing /sls/x01da/data/S{scan:05d}.h5 ({point} points)"), + ("SUCCESS", "FileWriterManager", "File /sls/x01da/data/S{scan:05d}.h5 written"), + ("WARNING", "DeviceServer", "Readback of bpm4i is {lag} ms late"), + ("WARNING", "SciHub", "SciLog is not reachable, retrying in 30 s"), + ("INFO", "DAPServer", "Fit gaussian on bpm4i: center={pos:.3f}, sigma=0.41"), + ("INFO", "BECIPythonClient", "User jan started a scan from the console"), + ("DEBUG", "SciHub", "Heartbeat sent"), +] + + +def demo_specs(count: int = 400, end: float | None = None, seed: int = 4) -> list[tuple]: + """Return ``(level, ts, service, message, function)`` tuples ending at ``end``.""" + rng = random.Random(seed) + end = time.time() if end is None else end + ts = end - count * 0.9 + scan = 1040 + specs: list[tuple] = [] + for i in range(count): + ts += rng.uniform(0.2, 1.6) + roll = rng.random() + if i % 97 == 60: + specs.append(("ERROR", ts, "DeviceServer", TRACEBACK, "_set_device")) + continue + if i % 131 == 90: + specs.append(("ERROR", ts, "DeviceServer", TRACEBACK_TIMEOUT, "read")) + continue + if i % 173 == 120: + specs.append( + ( + "CRITICAL", + ts, + "ScanServer", + "Scan queue halted: beam lost (ring current 0.0 mA)", + "halt", + ) + ) + continue + if i % 59 == 30: + # a burst of identical messages, folded into one row with a counter + for _ in range(rng.randint(3, 12)): + ts += 0.2 + specs.append(("WARNING", ts, "DeviceServer", "Waiting for samy to settle", "wait")) + continue + if roll < 0.04: + scan += 1 + level, service, template = _TEMPLATES[int(rng.random() * len(_TEMPLATES))] + message = template.format( + scan=scan, + dur=rng.uniform(20, 300), + point=rng.randint(0, 50), + pos=rng.uniform(-5, 5), + val=rng.uniform(1e3, 9e4), + lag=rng.randint(150, 900), + ) + specs.append((level, ts, service, message, "run")) + return specs + + +def demo_entries(model: LogFeedModel, count: int = 400, end: float | None = None, seed: int = 4): + """Build :class:`LogEntry` objects for ``model`` from :func:`demo_specs`.""" + return [ + LogEntry(model.next_seq(), level, ts, service, message, function) + for level, ts, service, message, function in demo_specs(count, end, seed) + ] + + +def main() -> None: # pragma: no cover + """Show the QWidget and QML versions side by side with a live feed.""" + # pylint: disable=import-outside-toplevel + from qtpy.QtCore import QTimer + from qtpy.QtWidgets import QApplication, QHBoxLayout, QWidget + + from bec_widgets.utils.colors import apply_theme + from bec_widgets.widgets.utility.logpanel.log_panel_qml import LogPanelQml + from bec_widgets.widgets.utility.logpanel.log_panel_qwidget import LogPanelQWidget + from bec_widgets.widgets.utility.logpanel.log_ux_common import LogPanelBackend + + app = QApplication(sys.argv) + apply_theme(sys.argv[1] if len(sys.argv) > 1 else "dark") + window = QWidget() + layout = QHBoxLayout(window) + panels = [] + for cls in (LogPanelQWidget, LogPanelQml): + backend = LogPanelBackend(use_queue=False) + backend.ingest(demo_entries(backend.model, 600)) + panel = cls(backend=backend) + backend.setParent(panel) + layout.addWidget(panel) + panels.append(panel) + rng = random.Random(1) + + def feed(): + for panel in panels: + spec = demo_specs(rng.randint(1, 4), seed=rng.randint(0, 9999)) + panel.backend.ingest([LogEntry(panel.backend.model.next_seq(), *s) for s in spec]) + + timer = QTimer(window, interval=700) + timer.timeout.connect(feed) + timer.start() + window.resize(1800, 700) + window.show() + sys.exit(app.exec()) + + +if __name__ == "__main__": # pragma: no cover + main() diff --git a/bec_widgets/widgets/utility/logpanel/log_panel_qml.py b/bec_widgets/widgets/utility/logpanel/log_panel_qml.py new file mode 100644 index 00000000..c63b686c --- /dev/null +++ b/bec_widgets/widgets/utility/logpanel/log_panel_qml.py @@ -0,0 +1,81 @@ +"""Reworked BEC log panel rendered with Qt Quick (QML). + +Same UX as :mod:`log_panel_qwidget`; all state and actions live in +:class:`~.log_ux_common.LogPanelBackend` and the rows come from its ``LogFeedModel``. Only the +drawing is QML (``qml/LogPanelView.qml``). +""" + +from __future__ import annotations + +from pathlib import Path + +from qtpy.QtCore import QSize, Qt +from qtpy.QtGui import QFontDatabase, QKeySequence, QShortcut +from qtpy.QtWidgets import QVBoxLayout, QWidget + +from bec_widgets.utils.bec_widget import BECWidget +from bec_widgets.utils.error_popups import SafeSlot +from bec_widgets.utils.quick import create_quick_widget, release_quick_widget +from bec_widgets.widgets.utility.logpanel.log_ux_common import LogPanelBackend + +QML_FILE = Path(__file__).parent / "qml" / "LogPanelView.qml" + + +class LogPanelQml(BECWidget, QWidget): + """Live BEC log viewer with level chips, search with highlight, follow-tail and folding. + + Args: + parent: Parent widget. + backend(LogPanelBackend | None): Use an existing backend (tests, demos). By default the + panel creates one fed by the live BEC log stream. + """ + + PLUGIN = False + RPC = False + ICON_NAME = "browse_activity" + + def __init__( + self, parent: QWidget | None = None, backend: LogPanelBackend | None = None, **kwargs + ): + super().__init__(parent=parent, **kwargs) + self.backend = backend or LogPanelBackend(self) + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + mono = QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont).family() + self.view = create_quick_widget( + self, QML_FILE, {"backend": self.backend, "monoFamily": mono} + ) + self.view.setFocusPolicy(Qt.FocusPolicy.StrongFocus) + layout.addWidget(self.view) + for keys, slot in ( + (QKeySequence.StandardKey.Find, self.backend.focus_search), + (QKeySequence.StandardKey.FindNext, self.backend.nextMatch), + (QKeySequence.StandardKey.FindPrevious, self.backend.prevMatch), + (QKeySequence.StandardKey.Copy, self._copy_current), + (QKeySequence(Qt.Key.Key_End), self.backend.jumpToLatest), + ): + shortcut = QShortcut(keys, self) + shortcut.setContext(Qt.ShortcutContext.WidgetWithChildrenShortcut) + shortcut.activated.connect(slot) + self.backend.scroll_to_end.emit() + + @SafeSlot() + def _copy_current(self) -> None: + row = self.backend.model.current_row() + if row >= 0: + self.backend.copyRow(row) + + @SafeSlot(str) + def apply_theme(self, theme: str): + """Re-read the theme colours used by the rows (the QML theme bridge follows itself).""" + self.backend.refresh_theme() + self.view.setClearColor(self.palette().window().color()) + + def sizeHint(self) -> QSize: # pylint: disable=invalid-name + return QSize(900, 420) + + def cleanup(self): + """Unload the scene and detach from the BEC log stream.""" + release_quick_widget(self.view) + self.backend.cleanup() + super().cleanup() diff --git a/bec_widgets/widgets/utility/logpanel/log_panel_qwidget.py b/bec_widgets/widgets/utility/logpanel/log_panel_qwidget.py new file mode 100644 index 00000000..8a1c5f73 --- /dev/null +++ b/bec_widgets/widgets/utility/logpanel/log_panel_qwidget.py @@ -0,0 +1,1022 @@ +"""Reworked BEC log panel drawn with QWidgets. + +Same UX as :mod:`log_panel_qml`; all state and actions live in +:class:`~.log_ux_common.LogPanelBackend`. The rows are painted by one delegate in a single +column ``QTreeView``, which computes row heights lazily, so expanded tracebacks stay cheap even +with tens of thousands of entries. +""" + +from __future__ import annotations + +from functools import partial + +from bec_qthemes import material_icon +from qtpy.QtCore import QEvent, QModelIndex, QPointF, QRect, QRectF, QSize, Qt, QTimer, Signal +from qtpy.QtGui import ( + QAction, + QColor, + QFont, + QFontDatabase, + QFontMetrics, + QKeySequence, + QPainter, + QShortcut, +) +from qtpy.QtWidgets import ( + QAbstractItemView, + QApplication, + QButtonGroup, + QHBoxLayout, + QLabel, + QLineEdit, + QMenu, + QPushButton, + QStyle, + QStyledItemDelegate, + QToolButton, + QTreeView, + QVBoxLayout, + QWidget, +) + +from bec_widgets.utils.bec_widget import BECWidget +from bec_widgets.utils.error_popups import SafeSlot +from bec_widgets.utils.quick.host import ThemeTokens +from bec_widgets.utils.ux_kit import IconButton, StatusPill, field_qss +from bec_widgets.widgets.utility.logpanel.log_ux_common import LogEntry, LogPanelBackend + +# geometry shared with qml/LogPanelView.qml +ROW_H = 26 +LINE_H = 18 +X_TIME = 12 +X_BADGE = 112 +X_SERVICE = 166 +X_CHEVRON = 294 +X_MESSAGE = 312 +SERVICE_W = 120 + + +def _mono(pixel_size: int = 12) -> QFont: + font = QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont) + font.setPixelSize(pixel_size) + return font + + +def _ui(pixel_size: int, weight: QFont.Weight = QFont.Weight.Normal) -> QFont: + font = QFont(QApplication.font()) + font.setPixelSize(pixel_size) + font.setWeight(weight) + return font + + +class LogRowDelegate(QStyledItemDelegate): + """Paints one log row: level stripe, time, level badge, service, message and extras.""" + + # pylint: disable=invalid-name,missing-function-docstring,unused-argument + + def __init__(self, view: LogTreeView, backend: LogPanelBackend): + super().__init__(view) + self._view = view + self._backend = backend + self.mono = _mono(12) + self.mono_bold = _mono(12) + self.mono_bold.setBold(True) + self.badge_font = _ui(10, QFont.Weight.Bold) + self.service_font = _ui(12) + self.small_font = _ui(11, QFont.Weight.DemiBold) + self._mono_metrics = QFontMetrics(self.mono) + self._icons: dict[tuple[str, str], object] = {} + self.tokens = ThemeTokens() + + def refresh_theme(self) -> None: + """Drop cached colours and icons.""" + self.tokens = ThemeTokens() + self._icons.clear() + + def _icon(self, name: str, color: QColor): + key = (name, color.name()) + icon = self._icons.get(key) + if icon is None: + icon = self._icons[key] = material_icon( + name, size=(32, 32), color=color, convert_to_pixmap=False + ) + return icon + + # pylint: disable=invalid-name + def sizeHint(self, option, index: QModelIndex) -> QSize: + entry = self._backend.model.entry(index.row()) + if entry is None or entry.seq not in self._backend.model.expanded: + return QSize(option.rect.width(), ROW_H) + return QSize(option.rect.width(), ROW_H + len(entry.body_lines()) * LINE_H + 20) + + @staticmethod + def chevron_rect(rect: QRect) -> QRect: + """Clickable expand area of a row.""" + return QRect(rect.left() + X_CHEVRON - 4, rect.top(), 22, ROW_H) + + @staticmethod + def copy_rect(rect: QRect) -> QRect: + """Clickable copy button of a hovered row.""" + return QRect(rect.right() - 30, rect.top() + 2, 24, ROW_H - 4) + + def editorEvent(self, event, model, option, index) -> bool: + if ( + event.type() != QEvent.Type.MouseButtonRelease + or event.button() != Qt.MouseButton.LeftButton + ): + return False + pos = event.position().toPoint() + entry = self._backend.model.entry(index.row()) + if entry is None: + return False + if self.copy_rect(option.rect).contains(pos): + self._backend.copyRow(index.row()) + return True + if entry.line_count > 1 and self.chevron_rect(option.rect).contains(pos): + self._view.toggle_row(index.row()) + return True + return False + + def paint(self, painter: QPainter, option, index: QModelIndex) -> None: + # pylint: disable=too-many-locals,too-many-statements + model = self._backend.model + entry: LogEntry | None = model.entry(index.row()) + if entry is None: + return + tokens = self.tokens + rect = option.rect + header = QRect(rect.left(), rect.top(), rect.width(), ROW_H) + hovered = bool(option.state & QStyle.StateFlag.State_MouseOver) + selected = bool(option.state & QStyle.StateFlag.State_Selected) + current = entry.seq == model.current_seq + painter.save() + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + + # background: error tint, then hover / selection + tint = model.row_tint(entry.level) + if tint is not None: + painter.fillRect(rect, tint) + if selected or current: + fill = QColor(tokens.primary) + fill.setAlphaF(0.20 if selected else 0.12) + painter.fillRect(rect, fill) + elif hovered: + painter.fillRect(rect, tokens.hover) + + level_color = model.level_color(entry.level) + # level stripe + painter.fillRect(QRect(rect.left(), rect.top() + 3, 3, rect.height() - 6), level_color) + + # time + painter.setFont(self.mono) + painter.setPen(tokens.fg_subtle) + painter.drawText( + QRect(rect.left() + X_TIME, header.top(), X_BADGE - X_TIME - 6, ROW_H), + Qt.AlignmentFlag.AlignVCenter, + model.time_text(entry), + ) + + # level badge + badge = QRectF(rect.left() + X_BADGE, header.top() + 4, 46, ROW_H - 8) + badge_fill = QColor(level_color) + badge_fill.setAlphaF(0.18) + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(badge_fill) + painter.drawRoundedRect(badge, 4, 4) + painter.setFont(self.badge_font) + painter.setPen(model.level_ink(entry.level)) + painter.drawText(badge, Qt.AlignmentFlag.AlignCenter, entry.badge) + + # service + service_color = model.service_color(entry.service) + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(service_color) + painter.drawEllipse( + QPointF(rect.left() + X_SERVICE + 4, header.center().y() + 0.5), 3.5, 3.5 + ) + painter.setFont(self.service_font) + painter.setPen(tokens.fg_muted) + service_rect = QRect(rect.left() + X_SERVICE + 13, header.top(), SERVICE_W - 13, ROW_H) + painter.drawText( + service_rect, + Qt.AlignmentFlag.AlignVCenter, + painter.fontMetrics().elidedText( + entry.service, Qt.TextElideMode.ElideRight, service_rect.width() + ), + ) + + # chevron for multi-line entries + expanded = entry.seq in model.expanded + if entry.line_count > 1: + self._icon("expand_more" if expanded else "chevron_right", tokens.fg_muted).paint( + painter, QRect(rect.left() + X_CHEVRON, header.top() + 5, 16, 16) + ) + + # right side extras: repeat counter, line count, hover copy button + right = rect.right() - 8 + if hovered: + self._icon("content_copy", tokens.fg_muted).paint( + painter, self.copy_rect(rect).adjusted(4, 3, -4, -3) + ) + right = self.copy_rect(rect).left() - 4 + painter.setFont(self.small_font) + metrics = painter.fontMetrics() + if entry.count > 1: + text = f"×{entry.count}" + width = metrics.horizontalAdvance(text) + 12 + pill = QRectF(right - width, header.top() + 5, width, ROW_H - 10) + fill = QColor(level_color) + fill.setAlphaF(0.18) + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(fill) + painter.drawRoundedRect(pill, pill.height() / 2, pill.height() / 2) + painter.setPen(model.level_ink(entry.level)) + painter.drawText(pill, Qt.AlignmentFlag.AlignCenter, text) + right = int(pill.left()) - 6 + if entry.line_count > 1 and not expanded: + text = f"+{entry.line_count - 1} lines" + width = metrics.horizontalAdvance(text) + 12 + pill = QRectF(right - width, header.top() + 5, width, ROW_H - 10) + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(tokens.track) + painter.drawRoundedRect(pill, pill.height() / 2, pill.height() / 2) + painter.setPen(tokens.fg_muted) + painter.drawText(pill, Qt.AlignmentFlag.AlignCenter, text) + right = int(pill.left()) - 6 + + # message summary with search highlights + message_rect = QRect( + rect.left() + X_MESSAGE, header.top(), max(0, right - rect.left() - X_MESSAGE), ROW_H + ) + color = tokens.fg_muted if entry.group == "debug" else tokens.fg + self._draw_text(painter, message_rect, entry.summary, color, self.mono) + + if expanded: + self._paint_body(painter, rect, entry) + painter.restore() + + def _draw_text(self, painter: QPainter, rect: QRect, text: str, color: QColor, font: QFont): + painter.setFont(font) + metrics = painter.fontMetrics() + elided = metrics.elidedText(text, Qt.TextElideMode.ElideRight, rect.width()) + spans = self._backend.model.matcher.spans(elided) + if spans: + mark = QColor(self.tokens.warning) + mark.setAlphaF(0.45 if self.tokens.dark else 0.55) + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(mark) + for start, length in spans: + x = rect.left() + metrics.horizontalAdvance(elided[:start]) + width = metrics.horizontalAdvance(elided[start : start + length]) + painter.drawRoundedRect(QRectF(x - 1, rect.center().y() - 8, width + 2, 17), 3, 3) + painter.setPen(color) + painter.drawText(rect, Qt.AlignmentFlag.AlignVCenter, elided) + + def _paint_body(self, painter: QPainter, rect: QRect, entry: LogEntry) -> None: + tokens = self.tokens + block = QRectF( + rect.left() + X_CHEVRON, + rect.top() + ROW_H, + rect.width() - X_CHEVRON - 12, + rect.height() - ROW_H - 6, + ) + painter.setPen(tokens.border) + painter.setBrush(tokens.field) + painter.drawRoundedRect(block, 6, 6) + y = int(block.top()) + 7 + width = int(block.width()) - 20 + exception_color = self._backend.model.level_ink("ERROR") + for text, kind in entry.body_lines(): + line_rect = QRect(int(block.left()) + 10, y, width, LINE_H) + if kind == "exception": + self._draw_text(painter, line_rect, text, exception_color, self.mono_bold) + elif kind in ("file", "head"): + self._draw_text(painter, line_rect, text, tokens.fg_subtle, self.mono) + else: + self._draw_text(painter, line_rect, text, tokens.fg, self.mono) + y += LINE_H + + +class LogTreeView(QTreeView): + """Single column view of the log rows that tells the backend when the user leaves the tail.""" + + # pylint: disable=invalid-name,missing-function-docstring + + def __init__(self, backend: LogPanelBackend, parent: QWidget | None = None): + super().__init__(parent) + self._backend = backend + self.setHeaderHidden(True) + self.setRootIsDecorated(False) + self.setIndentation(0) + self.setUniformRowHeights(True) + self.setItemsExpandable(False) + self.setAllColumnsShowFocus(True) + self.setMouseTracking(True) + self.setFrameShape(QTreeView.Shape.NoFrame) + self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) + self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) + self.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerItem) + self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self.setModel(backend.model) + self.delegate = LogRowDelegate(self, backend) + self.setItemDelegate(self.delegate) + self._scroll_pending = False + self._expanded_role = backend.model.roleNames_ids()["expanded"] + self.verticalScrollBar().actionTriggered.connect(self._on_user_scroll) + self.verticalScrollBar().valueChanged.connect(self._on_value_changed) + + def toggle_row(self, row: int) -> None: + """Expand or collapse a multi-line row.""" + self._backend.toggleExpanded(row) + + def sync_row_heights(self) -> None: + """Use uniform row heights while nothing is expanded. + + With uniform heights the view lays out tens of thousands of rows without asking the + delegate for every row's size, which keeps live updates cheap. Only while an entry is + expanded does it measure each row. + """ + uniform = not self._backend.model.expanded + if self.uniformRowHeights() != uniform: + self.setUniformRowHeights(uniform) + + def schedule_scroll_to_end(self) -> None: + """Scroll to the newest row once the current model update has been laid out.""" + if self._scroll_pending: + return + self._scroll_pending = True + QTimer.singleShot(0, self._scroll_to_end) + + def _scroll_to_end(self) -> None: + self._scroll_pending = False + if self._backend.follow: + self.scrollToBottom() + + def at_bottom(self) -> bool: + """Whether the newest row is visible.""" + scrollbar = self.verticalScrollBar() + return scrollbar.value() >= scrollbar.maximum() + + def _on_user_scroll(self, *_): + QTimer.singleShot(0, lambda: self._backend.setFollow(self.at_bottom())) + + def _on_value_changed(self, _value: int) -> None: + if self.at_bottom() and not self._backend.follow: + self._backend.setFollow(True) + + # pylint: disable=invalid-name + def wheelEvent(self, event) -> None: + super().wheelEvent(event) + self._backend.setFollow(self.at_bottom()) + + def keyPressEvent(self, event) -> None: + if event.key() in (Qt.Key.Key_Space, Qt.Key.Key_Right, Qt.Key.Key_Left): + index = self.currentIndex() + if index.isValid(): + self.toggle_row(index.row()) + return + super().keyPressEvent(event) + self._backend.setFollow(self.at_bottom()) + + def mouseDoubleClickEvent(self, event) -> None: + index = self.indexAt(event.position().toPoint()) + if index.isValid(): + self.toggle_row(index.row()) + return + super().mouseDoubleClickEvent(event) + + def dataChanged(self, top_left: QModelIndex, bottom_right: QModelIndex, roles=()) -> None: + super().dataChanged(top_left, bottom_right, roles) + if self._expanded_role in (roles or ()): + # an entry was expanded or collapsed, possibly from the backend or a test + self.sync_row_heights() + self.delegate.sizeHintChanged.emit(top_left) + + def currentChanged(self, current: QModelIndex, previous: QModelIndex) -> None: + super().currentChanged(current, previous) + self._backend.setCurrent(current.row() if current.isValid() else -1) + + +class Chip(QPushButton): + """Level filter chip with a coloured dot and a count, matching the QML chip. + + Signals: + double_clicked(): Show only this level. + """ + + double_clicked = Signal() + + def __init__(self, key: str, parent: QWidget | None = None): + super().__init__(parent) + self.key = key + self.setCheckable(True) + self.setCursor(Qt.CursorShape.PointingHandCursor) + self.setFixedHeight(26) + self._color = QColor("gray") + self._label = "" + self._count = 0 + + def set_data(self, label: str, count: int, active: bool, color: QColor) -> None: + """Update the chip from the backend state.""" + self._label, self._count, self._color = label, count, QColor(color) + self.setChecked(active) + text = f"{label} {count:,}" + self.setMinimumWidth( + QFontMetrics(_ui(12, QFont.Weight.DemiBold)).horizontalAdvance(text) + 34 + ) + self.setToolTip(f"Show or hide {label.lower()} (double-click to show only {label.lower()})") + self.update() + + def mouseDoubleClickEvent(self, _event): # pylint: disable=invalid-name + """Double-click shows only this level.""" + self.double_clicked.emit() + + def paintEvent(self, _event): # pylint: disable=invalid-name + """Paint the chip.""" + tokens = ThemeTokens() + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + rect = QRectF(self.rect()).adjusted(0.5, 0.5, -0.5, -0.5) + active = self.isChecked() + if active: + fill = QColor(self._color) + fill.setAlphaF(0.16) + painter.setBrush(fill) + painter.setPen(QColor(self._color)) + else: + painter.setBrush(tokens.hover if self.underMouse() else Qt.GlobalColor.transparent) + painter.setPen(tokens.border) + painter.drawRoundedRect(rect, rect.height() / 2, rect.height() / 2) + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(self._color if active else tokens.fg_subtle) + painter.drawEllipse(QPointF(14, rect.center().y()), 4, 4) + font = _ui(12, QFont.Weight.DemiBold if active else QFont.Weight.Normal) + painter.setFont(font) + painter.setPen(tokens.fg if active else tokens.fg_subtle) + label_rect = rect.adjusted(24, 0, -10, 0) + painter.drawText(label_rect, Qt.AlignmentFlag.AlignVCenter, self._label) + painter.setPen(tokens.fg_muted if active else tokens.fg_subtle) + painter.drawText( + label_rect, + Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignRight, + f"{self._count:,}", + ) + painter.end() + + +class LivePill(StatusPill): + """Live / Paused status pill that toggles pausing when clicked. + + Signals: + clicked(): The pill was clicked. + """ + + clicked = Signal() + + def mousePressEvent(self, _event): # pylint: disable=invalid-name + """Report the click.""" + self.clicked.emit() + + +class SelectButton(QToolButton): + """Field styled button that opens a menu, matching ``BecUi.SelectField``.""" + + def __init__(self, icon_name: str, parent: QWidget | None = None): + super().__init__(parent) + self._icon_name = icon_name + self.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup) + self.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon) + self.setFixedHeight(26) + self.setCursor(Qt.CursorShape.PointingHandCursor) + + def refresh_theme(self, tokens: ThemeTokens, highlighted: bool = False) -> None: + """Apply the theme tokens; ``highlighted`` marks an active filter.""" + color = tokens.primary if highlighted else tokens.fg_muted + self.setIcon( + material_icon(self._icon_name, size=(32, 32), color=color, convert_to_pixmap=False) + ) + self.setIconSize(QSize(15, 15)) + border = tokens.primary.name() if highlighted else tokens.border.name() + self.setStyleSheet( + f"QToolButton {{ background: {tokens.field.name()}; color: {tokens.fg.name()};" + f" border: 1px solid {border}; border-radius: 13px; padding: 0 12px 0 8px;" + f" font-size: 12px; }}" + f"QToolButton:hover {{ border-color: {tokens.fg_subtle.name()}; }}" + f"QToolButton::menu-indicator {{ image: none; width: 0; }}" + ) + + +class _StayOpenMenu(QMenu): # pylint: disable=too-few-public-methods + """Menu whose checkable entries toggle without closing it.""" + + def mouseReleaseEvent(self, event): # pylint: disable=invalid-name + """Toggle checkable entries in place.""" + action = self.actionAt(event.position().toPoint()) + if action is not None and action.isCheckable(): + action.trigger() + return + super().mouseReleaseEvent(event) + + +class LogPanelQWidget(BECWidget, QWidget): + """Live BEC log viewer with level chips, search with highlight, follow-tail and folding. + + Args: + parent: Parent widget. + backend(LogPanelBackend | None): Use an existing backend (tests, demos). By default the + panel creates one fed by the live BEC log stream. + """ + + PLUGIN = False + RPC = False + ICON_NAME = "browse_activity" + + def __init__( + self, parent: QWidget | None = None, backend: LogPanelBackend | None = None, **kwargs + ): + super().__init__(parent=parent, **kwargs) + self.backend = backend or LogPanelBackend(self) + self._chips: dict[str, Chip] = {} + self._build() + self.backend.changed.connect(self._render) + self.backend.scroll_to_end.connect(self.view.schedule_scroll_to_end) + self.backend.reveal_row.connect(self._reveal_row) + self.backend.toast.connect(self._show_toast) + self.backend.focus_search.connect(self._focus_search) + self.backend.model.modelReset.connect(self._after_reset) + self.backend.model.rowsInserted.connect(self._update_overlays) + self.backend.model.modelReset.connect(self._update_overlays) + self._setup_shortcuts() + self.apply_theme("") + self._render() + self.view.schedule_scroll_to_end() + + # ------------------------------------------------------------------ build + def _build(self) -> None: + # pylint: disable=too-many-statements + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + + self.toolbar = QWidget(self) + self.toolbar.setObjectName("logToolbar") + rows = QVBoxLayout(self.toolbar) + rows.setContentsMargins(10, 8, 10, 8) + rows.setSpacing(8) + + # row 1: search and actions + top = QHBoxLayout() + top.setSpacing(6) + self.search = QLineEdit(self.toolbar) + self.search.setPlaceholderText("Search logs (Ctrl+F)") + self.search.setClearButtonEnabled(True) + self.search.setMaximumWidth(440) + self.search.setMinimumWidth(160) + self._search_icon = self.search.addAction( + material_icon("search", size=(32, 32), convert_to_pixmap=False), + QLineEdit.ActionPosition.LeadingPosition, + ) + self.search.textEdited.connect(self.backend.setSearch) + self.search.returnPressed.connect(self._on_return) + top.addWidget(self.search, 1) + + self.mode_buttons: dict[str, QPushButton] = {} + mode_box = QWidget(self.toolbar) + mode_box.setObjectName("modeBox") + mode_layout = QHBoxLayout(mode_box) + mode_layout.setContentsMargins(2, 2, 2, 2) + mode_layout.setSpacing(0) + group = QButtonGroup(mode_box) + for key, label, tip in ( + ("filter", "Filter", "Show only matching entries"), + ("highlight", "Highlight", "Show everything and mark the matches"), + ): + button = QPushButton(label, mode_box) + button.setCheckable(True) + button.setToolTip(tip) + button.setFixedHeight(26) + button.setCursor(Qt.CursorShape.PointingHandCursor) + button.clicked.connect(partial(self.backend.setSearchMode, key)) + group.addButton(button) + mode_layout.addWidget(button) + self.mode_buttons[key] = button + self.mode_box = mode_box + top.addWidget(mode_box) + + self.regex_button = IconButton("regular_expression", "Regular expression", self.toolbar) + self.regex_button.setCheckable(True) + self.regex_button.toggled.connect(self.backend.setRegex) + top.addWidget(self.regex_button) + + self.match_label = QLabel(self.toolbar) + top.addWidget(self.match_label) + self.prev_button = IconButton( + "keyboard_arrow_up", "Previous match (Shift+Enter)", self.toolbar + ) + self.prev_button.clicked.connect(self.backend.prevMatch) + self.next_button = IconButton("keyboard_arrow_down", "Next match (Enter)", self.toolbar) + self.next_button.clicked.connect(self.backend.nextMatch) + top.addWidget(self.prev_button) + top.addWidget(self.next_button) + top.addStretch(1) + + self.live_pill = LivePill(self.toolbar) + self.live_pill.setCursor(Qt.CursorShape.PointingHandCursor) + self.live_pill.clicked.connect(self.backend.togglePause) + top.addWidget(self.live_pill) + self.time_button = IconButton("schedule", "Show relative times", self.toolbar) + self.time_button.clicked.connect(self.backend.toggleTimeMode) + self.copy_button = IconButton("content_copy", "Copy visible entries", self.toolbar) + self.copy_button.clicked.connect(self.backend.copyVisible) + self.export_button = IconButton( + "download", "Export visible entries to a file", self.toolbar + ) + self.export_button.clicked.connect(self.backend.exportVisible) + self.clear_button = IconButton("delete_sweep", "Clear the panel", self.toolbar) + self.clear_button.clicked.connect(self.backend.clear) + for button in (self.time_button, self.copy_button, self.export_button, self.clear_button): + top.addWidget(button) + rows.addLayout(top) + + # row 2: level chips, service and time filters, summary + chips = QHBoxLayout() + chips.setSpacing(6) + for group_state in self.backend.state["groups"]: + chip = Chip(group_state["key"], self.toolbar) + chip.clicked.connect(partial(self._on_chip_clicked, chip)) + chip.double_clicked.connect(partial(self.backend.soloGroup, chip.key)) + chips.addWidget(chip) + self._chips[chip.key] = chip + chips.addSpacing(6) + self.service_button = SelectButton("dns", self.toolbar) + self.service_menu = _StayOpenMenu(self.service_button) + self.service_menu.aboutToShow.connect(self._fill_service_menu) + self.service_button.setMenu(self.service_menu) + chips.addWidget(self.service_button) + self.since_button = SelectButton("schedule", self.toolbar) + self.since_menu = QMenu(self.since_button) + for option in self.backend.state["sinceOptions"]: + self.since_menu.addAction( + option["label"], partial(self.backend.setSince, option["key"]) + ) + self.since_button.setMenu(self.since_menu) + chips.addWidget(self.since_button) + chips.addStretch(1) + self.summary_label = QLabel(self.toolbar) + chips.addWidget(self.summary_label) + self.reset_button = QPushButton("Reset filters", self.toolbar) + self.reset_button.setCursor(Qt.CursorShape.PointingHandCursor) + self.reset_button.setFlat(True) + self.reset_button.clicked.connect(self._reset_filters) + chips.addWidget(self.reset_button) + rows.addLayout(chips) + layout.addWidget(self.toolbar) + + self.view = LogTreeView(self.backend, self) + self.view.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + self.view.customContextMenuRequested.connect(self._show_context_menu) + layout.addWidget(self.view, 1) + + # overlays + self.jump_button = QPushButton(self.view.viewport()) + self.jump_button.setFixedHeight(30) + self.jump_button.setCursor(Qt.CursorShape.PointingHandCursor) + self.jump_button.clicked.connect(self.backend.jumpToLatest) + self.jump_button.hide() + self.empty = QWidget(self.view.viewport()) + empty_layout = QVBoxLayout(self.empty) + empty_layout.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.empty_icon = QLabel(self.empty) + self.empty_icon.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.empty_title = QLabel(self.empty) + self.empty_title.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.empty_hint = QLabel(self.empty) + self.empty_hint.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.empty_reset = QPushButton("Reset filters", self.empty) + self.empty_reset.setCursor(Qt.CursorShape.PointingHandCursor) + self.empty_reset.clicked.connect(self._reset_filters) + for widget in (self.empty_icon, self.empty_title, self.empty_hint): + empty_layout.addWidget(widget) + empty_layout.addWidget(self.empty_reset, 0, Qt.AlignmentFlag.AlignCenter) + self.empty.hide() + self.toast_label = QLabel(self.view.viewport()) + self.toast_label.hide() + self._toast_timer = QTimer(self, singleShot=True, interval=1600) + self._toast_timer.timeout.connect(self.toast_label.hide) + + def _setup_shortcuts(self) -> None: + find = QShortcut(QKeySequence.StandardKey.Find, self) + find.setContext(Qt.ShortcutContext.WidgetWithChildrenShortcut) + find.activated.connect(self.backend.focus_search) + for keys, slot in ( + (QKeySequence.StandardKey.FindNext, self.backend.nextMatch), + (QKeySequence.StandardKey.FindPrevious, self.backend.prevMatch), + (QKeySequence.StandardKey.Copy, self._copy_selection), + (QKeySequence(Qt.Key.Key_End), self.backend.jumpToLatest), + ): + shortcut = QShortcut(keys, self) + shortcut.setContext(Qt.ShortcutContext.WidgetWithChildrenShortcut) + shortcut.activated.connect(slot) + escape = QShortcut(QKeySequence(Qt.Key.Key_Escape), self.search) + escape.setContext(Qt.ShortcutContext.WidgetShortcut) + escape.activated.connect(self._clear_search) + + # ------------------------------------------------------------------ actions + @SafeSlot() + def _focus_search(self) -> None: + self.search.setFocus() + self.search.selectAll() + + @SafeSlot() + def _clear_search(self) -> None: + self.search.clear() + self.backend.setSearch("") + self.view.setFocus() + + @SafeSlot() + def _on_return(self) -> None: + modifiers = QApplication.keyboardModifiers() + if self.backend.state["searchMode"] == "highlight": + if modifiers & Qt.KeyboardModifier.ShiftModifier: + self.backend.prevMatch() + else: + self.backend.nextMatch() + + def _on_chip_clicked(self, chip: Chip) -> None: + self.backend.toggleGroup(chip.key) + + @SafeSlot() + def _reset_filters(self) -> None: + self.search.clear() + self.backend.resetFilters() + + @SafeSlot() + def _copy_selection(self) -> None: + rows = [index.row() for index in self.view.selectionModel().selectedRows()] + if not rows and self.view.currentIndex().isValid(): + rows = [self.view.currentIndex().row()] + self.backend.copy_rows(rows) + + def _fill_service_menu(self) -> None: + menu = self.service_menu + menu.clear() + menu.addAction("Show all services", self.backend.allServices) + menu.addSeparator() + for service in self.backend.state["services"]: + action = QAction(f"{service['name']} {service['count']:,}", menu) + action.setCheckable(True) + action.setChecked(service["active"]) + action.setIcon(self._dot_icon(service["color"])) + action.triggered.connect(partial(self._toggle_service, service["name"])) + menu.addAction(action) + if not self.backend.state["services"]: + empty = menu.addAction("No services yet") + empty.setEnabled(False) + + def _toggle_service(self, name: str, *_): + self.backend.toggleService(name) + # keep the open menu in sync with the new selection + active = {s["name"]: s["active"] for s in self.backend.state["services"]} + for action in self.service_menu.actions(): + if action.isCheckable(): + action.setChecked(active.get(action.text().split(" ")[0], False)) + + @staticmethod + def _dot_icon(color: QColor): + # pylint: disable=import-outside-toplevel + from qtpy.QtGui import QIcon, QPixmap + + pixmap = QPixmap(16, 16) + pixmap.fill(Qt.GlobalColor.transparent) + painter = QPainter(pixmap) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(QColor(color)) + painter.drawEllipse(QRectF(4, 4, 8, 8)) + painter.end() + return QIcon(pixmap) + + def _show_context_menu(self, pos) -> None: + index = self.view.indexAt(pos) + menu = QMenu(self) + entry = self.backend.model.entry(index.row()) if index.isValid() else None + if entry is not None: + row = index.row() + menu.addAction("Copy entry", partial(self.backend.copyRow, row)) + menu.addAction("Copy message only", partial(self.backend.copyMessage, row)) + if len(self.view.selectionModel().selectedRows()) > 1: + menu.addAction("Copy selected entries", self._copy_selection) + if entry.line_count > 1: + menu.addAction( + "Collapse" if entry.seq in self.backend.model.expanded else "Expand", + partial(self.view.toggle_row, row), + ) + menu.addSeparator() + menu.addAction( + f"Only {entry.service}", partial(self.backend.soloService, entry.service) + ) + menu.addAction( + f"Hide {entry.service}", partial(self.backend.hideService, entry.service) + ) + menu.addAction( + f"Only {entry.group} level", partial(self.backend.soloGroup, entry.group) + ) + menu.addSeparator() + menu.addAction("Copy visible entries", self.backend.copyVisible) + menu.addAction("Jump to latest", self.backend.jumpToLatest) + menu.exec(self.view.viewport().mapToGlobal(pos)) + menu.deleteLater() + + @SafeSlot(int) + def _reveal_row(self, row: int) -> None: + index = self.backend.model.index(row, 0) + self.view.setCurrentIndex(index) + self.view.scrollTo(index, QAbstractItemView.ScrollHint.PositionAtCenter) + + @SafeSlot(str) + def _show_toast(self, text: str) -> None: + self.toast_label.setText(text) + self.toast_label.adjustSize() + self._place_overlays() + self.toast_label.show() + self.toast_label.raise_() + self._toast_timer.start() + + def _after_reset(self) -> None: + if self.backend.follow: + self.view.schedule_scroll_to_end() + + # ------------------------------------------------------------------ render + @SafeSlot() + def _render(self) -> None: + state = self.backend.state + tokens = self.view.delegate.tokens + for group_state in state["groups"]: + chip = self._chips.get(group_state["key"]) + if chip is not None: + chip.set_data( + group_state["label"], + group_state["count"], + group_state["active"], + group_state["color"], + ) + self.service_button.setText(state["servicesLabel"]) + self.service_button.refresh_theme(tokens, state["servicesFiltered"]) + self.since_button.setText(state["sinceLabel"]) + self.since_button.refresh_theme(tokens, state["since"] != "all") + for key, button in self.mode_buttons.items(): + button.setChecked(state["searchMode"] == key) + if self.search.text() != state["search"] and not self.search.hasFocus(): + self.search.setText(state["search"]) + self.search.setProperty("invalid", bool(state["regexError"])) + self.search.style().unpolish(self.search) + self.search.style().polish(self.search) + self.search.setToolTip(state["regexError"]) + highlight = state["searchMode"] == "highlight" and bool(state["search"]) + self.match_label.setVisible(highlight) + self.prev_button.setVisible(highlight) + self.next_button.setVisible(highlight) + if highlight: + count = state["matchCount"] + self.match_label.setText( + f"{state['matchPos']} of {count}" if state["matchPos"] else f"{count} matches" + ) + if state["paused"]: + pending = state["pending"] + self.live_pill.set_status( + f"Paused · {pending:,} new" if pending else "Paused", tokens.warning + ) + self.live_pill.setToolTip("Updates are paused. Click to resume.") + else: + self.live_pill.set_status("Live", tokens.success, pulse=True) + self.live_pill.setToolTip("Receiving logs. Click to pause.") + relative = state["timeMode"] == "relative" + self.time_button.set_icon_name("history" if relative else "schedule") + self.time_button.setToolTip("Show clock times" if relative else "Show relative times") + self.summary_label.setText(state["summary"]) + self.reset_button.setVisible(state["filtered"]) + self._update_overlays() + + @SafeSlot() + def _update_overlays(self, *_) -> None: + state = self.backend.state + tokens = self.view.delegate.tokens + new = state["newBelow"] + show_jump = not state["follow"] and self.backend.model.rowCount() > 0 + self.jump_button.setVisible(show_jump) + if show_jump: + self.jump_button.setText(f" {new:,} new " if new else " Jump to latest ") + self.jump_button.setIcon( + material_icon( + "arrow_downward", + size=(32, 32), + color=tokens.on_primary, + convert_to_pixmap=False, + ) + ) + self.jump_button.adjustSize() + empty = self.backend.model.rowCount() == 0 + self.empty.setVisible(empty) + if empty: + if state["empty"]: + self._set_empty( + "hourglass_empty", + "Waiting for logs", + "New BEC log messages appear here.", + False, + ) + else: + self._set_empty( + "filter_alt_off", + "No entries match", + f"{state['total']:,} entries are hidden by the filters.", + True, + ) + self._place_overlays() + + def _set_empty(self, icon: str, title: str, hint: str, reset: bool) -> None: + tokens = self.view.delegate.tokens + self.empty_icon.setPixmap(material_icon(icon, size=(40, 40), color=tokens.fg_subtle)) + self.empty_title.setText(title) + self.empty_hint.setText(hint) + self.empty_reset.setVisible(reset) + + def _place_overlays(self) -> None: + viewport = self.view.viewport() + self.jump_button.move( + (viewport.width() - self.jump_button.width()) // 2, + viewport.height() - self.jump_button.height() - 12, + ) + self.jump_button.raise_() + self.empty.setGeometry(viewport.rect()) + self.toast_label.move(12, viewport.height() - self.toast_label.height() - 12) + + def resizeEvent(self, event): # pylint: disable=invalid-name + super().resizeEvent(event) + self._place_overlays() + + # ------------------------------------------------------------------ theme + @SafeSlot(str) + def apply_theme(self, theme: str): + """Re-apply the BEC theme colours.""" + self.backend.refresh_theme() + self.view.delegate.refresh_theme() + tokens = self.view.delegate.tokens + self.setStyleSheet(f"LogPanelQWidget {{ background: {tokens.bg.name()}; }}") + self.toolbar.setStyleSheet( + f"QWidget#logToolbar {{ background: {tokens.card.name()};" + f" border-bottom: 1px solid {tokens.border.name()}; }}" + + field_qss(tokens) + + f"QLabel {{ color: {tokens.fg_subtle.name()}; font-size: 12px; background: none; }}" + f'QPushButton[flat="true"] {{ color: {tokens.primary.name()}; border: none;' + f" background: none; font-size: 12px; padding: 0 4px; }}" + ) + self.mode_box.setStyleSheet( + f"QWidget#modeBox {{ background: {tokens.field.name()};" + f" border: 1px solid {tokens.border.name()}; border-radius: 7px; }}" + f"QPushButton {{ background: transparent; color: {tokens.fg_muted.name()};" + f" border: none; border-radius: 5px; padding: 0 10px; font-size: 12px; }}" + f"QPushButton:hover {{ color: {tokens.fg.name()}; }}" + f"QPushButton:checked {{ background: {tokens.hover.name()}; color: {tokens.fg.name()};" + f" font-weight: 600; }}" + ) + self._search_icon.setIcon( + material_icon("search", size=(32, 32), color=tokens.fg_subtle, convert_to_pixmap=False) + ) + self.view.setStyleSheet( + f"QTreeView {{ background: {tokens.bg.name()}; border: none; outline: none; }}" + f"QTreeView::item {{ border: none; }}" + ) + self.jump_button.setStyleSheet( + f"QPushButton {{ background: {tokens.primary.name()};" + f" color: {tokens.on_primary.name()}; border: none; border-radius: 14px;" + f" padding: 0 14px; font-size: 12px; font-weight: 600; }}" + f"QPushButton:hover {{ background: {tokens.primary.lighter(110).name()}; }}" + ) + self.toast_label.setStyleSheet( + f"QLabel {{ background: {tokens.card.name()}; color: {tokens.fg.name()};" + f" border: 1px solid {tokens.border.name()}; border-radius: 8px; padding: 6px 12px;" + f" font-size: 12px; }}" + ) + self.empty_title.setStyleSheet( + f"color: {tokens.fg.name()}; font-size: 14px; font-weight: 600;" + ) + self.empty_hint.setStyleSheet(f"color: {tokens.fg_subtle.name()}; font-size: 12px;") + self.empty_reset.setStyleSheet( + f"QPushButton {{ color: {tokens.primary.name()}; background: transparent;" + f" border: 1px solid {tokens.border.name()}; border-radius: 6px; padding: 5px 12px; }}" + ) + for button in self.findChildren(IconButton): + button.refresh_theme(tokens) + self._render() + self.view.viewport().update() + + def sizeHint(self) -> QSize: # pylint: disable=invalid-name + return QSize(900, 420) + + def cleanup(self): + """Stop timers and detach from the BEC log stream.""" + self._toast_timer.stop() + self.live_pill.cleanup() + self.backend.cleanup() + super().cleanup() diff --git a/bec_widgets/widgets/utility/logpanel/log_ux_common.py b/bec_widgets/widgets/utility/logpanel/log_ux_common.py new file mode 100644 index 00000000..2e878690 --- /dev/null +++ b/bec_widgets/widgets/utility/logpanel/log_ux_common.py @@ -0,0 +1,1110 @@ +"""Toolkit-independent core of the reworked log panel. + +The QML and QWidget versions of the log panel share everything that is not drawing: + +- :class:`LogEntry` is one log message, flattened once at ingest. Multi-line messages keep a + one-line summary (the exception line for tracebacks) and are expanded on demand. +- :class:`LogFeedModel` is a flat list model of the visible entries. It filters incrementally + (a live batch only checks the new entries), folds identical consecutive messages into one row + with a counter, keeps per level and per service counts and knows the search matches, so both + views render the same rows from the same model. +- :class:`LogPanelBackend` owns the model, the filters, pause and follow-tail state and all user + actions. Both views call its slots and render :attr:`LogPanelBackend.state`. + +Level colours follow the notification rework (``notification_ux_common.severity_color``) so a +warning or error looks the same in a toast, the notification drawer and the log panel. +""" + +from __future__ import annotations + +import re +import time +import zlib +from bisect import bisect_left +from datetime import datetime +from pathlib import Path +from typing import Any, Iterable + +from bec_lib.logger import bec_logger +from qtpy.QtCore import ( + Property, + QAbstractListModel, + QByteArray, + QModelIndex, + QObject, + Qt, + QTimer, + Signal, + Slot, +) +from qtpy.QtGui import QColor +from qtpy.QtWidgets import QApplication, QFileDialog + +from bec_widgets.utils.quick.host import ThemeTokens, _blend + +logger = bec_logger.logger + +# level name -> (badge text, group) +LEVELS: dict[str, tuple[str, str]] = { + "CRITICAL": ("CRIT", "error"), + "ERROR": ("ERROR", "error"), + "WARNING": ("WARN", "warning"), + "SUCCESS": ("OK", "info"), + "INFO": ("INFO", "info"), + "DEBUG": ("DEBUG", "debug"), + "TRACE": ("TRACE", "debug"), +} + +# level chips, in display order: key -> label +GROUPS: dict[str, str] = { + "error": "Errors", + "warning": "Warnings", + "info": "Info", + "debug": "Debug", +} + +# time window presets: key -> (label, seconds or None for no limit) +SINCE: dict[str, tuple[str, int | None]] = { + "all": ("All time", None), + "5m": ("Last 5 min", 300), + "15m": ("Last 15 min", 900), + "1h": ("Last hour", 3600), + "24h": ("Last 24 h", 86400), +} + +UNKNOWN_SERVICE = "unknown" +FOLD_WINDOW_S = 30.0 +MAX_BODY_LINES = 400 +_TRACE_FILE = re.compile(r'^\s*File "') +_EXCEPTION_LINE = re.compile(r"^[A-Za-z_][\w.]*(Error|Exception|Exit|Interrupt|Warning)\b") + + +def level_color(tokens: ThemeTokens, level: str) -> QColor: + """Theme colour of a log level, matching the notification severities.""" + if level == "CRITICAL": + return QColor(tokens.danger) + if level == "ERROR": + return _blend(tokens.warning, tokens.danger, 0.55) + if level == "WARNING": + return QColor(tokens.warning) + if level == "SUCCESS": + return QColor(tokens.success) + if level == "INFO": + return QColor(tokens.accent) + return QColor(tokens.fg_subtle) + + +def ink(tokens: ThemeTokens, color: QColor) -> QColor: + """Readable text version of an accent colour: light accents are darkened on light themes.""" + if tokens.dark or color.lightnessF() < 0.45: + return QColor(color) + return color.darker(100 + int((color.lightnessF() - 0.3) * 260)) + + +def group_color(tokens: ThemeTokens, group: str) -> QColor: + """Colour of a level chip.""" + return level_color( + tokens, {"error": "ERROR", "warning": "WARNING", "info": "INFO"}.get(group, "DEBUG") + ) + + +def service_color(tokens: ThemeTokens, service: str) -> QColor: + """Stable colour per service name so a service is recognisable at a glance.""" + if not service or service == UNKNOWN_SERVICE: + return QColor(tokens.fg_subtle) + hue = zlib.crc32(service.encode()) % 360 + if tokens.dark: + return QColor.fromHsl(hue, 150, 170) + return QColor.fromHsl(hue, 170, 95) + + +def classify_line(line: str) -> str: + """Kind of one message line, used to style tracebacks. + + Returns: + str: ``head`` (Traceback header), ``file`` (frame location), ``exception`` (raised + exception) or ``text``. + """ + stripped = line.strip() + if stripped.startswith(("Traceback (most recent call last)", "During handling", "The above")): + return "head" + if _TRACE_FILE.match(line): + return "file" + if _EXCEPTION_LINE.match(stripped): + return "exception" + return "text" + + +class LogEntry: + """One log message, flattened for display, filtering and search.""" + + __slots__ = ( + "seq", + "level", + "badge", + "group", + "ts", + "first_ts", + "service", + "function", + "message", + "lower", + "summary", + "line_count", + "is_traceback", + "count", + ) + + def __init__( + self, + seq: int, + level: str, + ts: float, + service: str | None, + message: str, + function: str | None = None, + ): + level = (level or "INFO").upper() + self.seq = seq + self.level = level + self.badge, self.group = LEVELS.get(level, (level[:5] or "LOG", "debug")) + self.ts = ts or time.time() + self.first_ts = self.ts + self.service = service or UNKNOWN_SERVICE + self.function = function or "" + message = (message or "").rstrip() + self.message = message + self.lower = message.lower() + lines = [line for line in message.splitlines() if line.strip()] + self.line_count = len(lines) + self.is_traceback = "Traceback (most recent call last)" in message + summary = lines[0].strip() if lines else "" + if self.is_traceback and summary.startswith("Traceback"): + # the raised exception says more than the traceback header + summary = lines[-1].strip() + self.summary = summary + self.count = 1 + + @classmethod + def from_record(cls, rec: Any, seq: int) -> LogEntry: + """Build an entry from a ``logpanel._LogRec`` (the shared BecLogsQueue record).""" + return cls(seq, rec.level, rec.ts, rec.service_name, rec.message, rec.function) + + @property + def fold_key(self) -> tuple: + """Entries with the same key arriving back to back are folded into one row.""" + return (self.level, self.service, self.message) + + def body_lines(self) -> list[tuple[str, str]]: + """Lines shown when the entry is expanded, as ``(text, kind)``.""" + lines = self.message.splitlines() + out = [(line.rstrip(), classify_line(line)) for line in lines[:MAX_BODY_LINES]] + if len(lines) > MAX_BODY_LINES: + out.append((f"... {len(lines) - MAX_BODY_LINES} more lines (use Copy)", "head")) + return out + + def copy_text(self) -> str: + """Plain text of the entry, ready to paste into a logbook or an issue.""" + stamp = datetime.fromtimestamp(self.ts).strftime("%Y-%m-%d %H:%M:%S.%f")[:-3] + repeat = f" (x{self.count})" if self.count > 1 else "" + return f"{stamp} [{self.level}] {self.service}: {self.message}{repeat}" + + +def format_clock(ts: float, now: float | None = None) -> str: + """Wall clock time, with the date only when it is not today.""" + stamp = datetime.fromtimestamp(ts) + today = datetime.fromtimestamp(time.time() if now is None else now).date() + millis = f"{stamp.microsecond // 1000:03d}" + if stamp.date() == today: + return f"{stamp:%H:%M:%S}.{millis}" + return f"{stamp:%b %d %H:%M:%S}" + + +def format_relative(ts: float, now: float | None = None) -> str: + """Age such as ``now``, ``42 s ago``, ``5 min ago`` or ``3 h ago``.""" + seconds = max(0, int((time.time() if now is None else now) - ts)) + if seconds < 5: + return "now" + if seconds < 60: + return f"{seconds} s ago" + if seconds < 3600: + return f"{seconds // 60} min ago" + if seconds < 86400: + return f"{seconds // 3600} h ago" + return f"{seconds // 86400} d ago" + + +def format_full(ts: float) -> str: + """Full timestamp for tooltips and copies.""" + return datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M:%S.%f")[:-3] + + +class SearchMatcher: + """Case-insensitive plain or regular expression search over log messages. + + Args: + text(str): The search text. + regex(bool): Interpret ``text`` as a regular expression. + + Attributes: + error(str): Why the regular expression is invalid, empty when it is fine. + """ + + def __init__(self, text: str, regex: bool = False): + self.text = text + self.regex = regex + self.error = "" + self._needle = text.lower() + self._pattern: re.Pattern | None = None + if regex and text: + try: + self._pattern = re.compile(text, re.IGNORECASE) + except re.error as exc: + self.error = str(exc) + + @property + def active(self) -> bool: + """Whether the search narrows or marks anything.""" + return bool(self.text) and not self.error + + def matches(self, entry: LogEntry) -> bool: + """Whether the entry's message or service matches.""" + if not self.active: + return True + if self._pattern is not None: + return bool(self._pattern.search(entry.message) or self._pattern.search(entry.service)) + return self._needle in entry.lower or self._needle in entry.service.lower() + + def spans(self, text: str) -> list[tuple[int, int]]: + """``(start, length)`` of every match in ``text``, for highlighting.""" + if not self.active or not text: + return [] + if self._pattern is not None: + return [ + (m.start(), m.end() - m.start()) + for m in self._pattern.finditer(text) + if m.end() > m.start() + ][:64] + out = [] + lower = text.lower() + start = lower.find(self._needle) + while start >= 0 and len(out) < 64: + out.append((start, len(self._needle))) + start = lower.find(self._needle, start + len(self._needle)) + return out + + +ROLES = [ + "seq", + "level", + "group", + "badge", + "levelColor", + "levelInk", + "rowTint", + "time", + "timeFull", + "service", + "serviceColor", + "function", + "summary", + "summarySpans", + "lineCount", + "isTraceback", + "expanded", + "bodyLines", + "count", + "matched", + "current", +] + + +class LogFeedModel(QAbstractListModel): + """Flat list model of the log entries that pass the current filters. + + Args: + max_entries(int): Entries kept in memory. Older ones are dropped in chunks. + parent: Parent QObject. + """ + + TRIM_CHUNK = 1000 + counts_changed = Signal() + + def __init__(self, max_entries: int = 20000, parent: QObject | None = None): + super().__init__(parent) + self.max_entries = max_entries + self._entries: list[LogEntry] = [] + self._rows: list[LogEntry] = [] + self._seq = 0 + self._roles = {Qt.ItemDataRole.UserRole + 1 + i: key for i, key in enumerate(ROLES)} + self._role_ids = {key: role for role, key in self._roles.items()} + # filters + self.groups: set[str] = set(GROUPS) + self.services: set[str] | None = None + self.since_key = "all" + self._since_ts: float | None = None + self.matcher = SearchMatcher("") + self.search_mode = "filter" + # presentation + self.time_mode = "clock" + self.expanded: set[int] = set() + self.current_seq: int | None = None + self._matches: list[LogEntry] = [] + self.match_pos = -1 + # counts over the entries passing every filter but the level filter + self.group_counts: dict[str, int] = dict.fromkeys(GROUPS, 0) + self.service_counts: dict[str, int] = {} + self.tokens = ThemeTokens() + self._row_tint: dict[str, QColor] = {} + self._level_colors: dict[str, QColor] = {} + self._level_inks: dict[str, QColor] = {} + self._service_colors: dict[str, QColor] = {} + self.refresh_theme() + + # ------------------------------------------------------------------ Qt model API + # pylint: disable=invalid-name,missing-function-docstring + def roleNames(self) -> dict[int, QByteArray]: + return {role: QByteArray(key.encode()) for role, key in self._roles.items()} + + def roleNames_ids(self) -> dict[str, int]: + """Role id of every role name.""" + return dict(self._role_ids) + + def rowCount(self, parent: QModelIndex = QModelIndex()) -> int: + if parent.isValid(): + return 0 + return len(self._rows) + + def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole): + if not index.isValid() or not 0 <= index.row() < len(self._rows): + return None + entry = self._rows[index.row()] + if role == Qt.ItemDataRole.DisplayRole: + return entry.summary + key = self._roles.get(role) + if key is None: + return None + return self.role_value(entry, key) + + def role_value(self, entry: LogEntry, key: str) -> Any: + """Value of one role for ``entry``.""" + # pylint: disable=too-many-return-statements + if key == "summary": + return entry.summary + if key == "time": + return self.time_text(entry) + if key == "badge": + return entry.badge + if key == "levelColor": + return self.level_color(entry.level) + if key == "levelInk": + return self.level_ink(entry.level) + if key == "rowTint": + return self._row_tint.get(entry.level, QColor(0, 0, 0, 0)) + if key == "service": + return entry.service + if key == "serviceColor": + return self.service_color(entry.service) + if key == "summarySpans": + return [list(span) for span in self.matcher.spans(entry.summary)] + if key == "lineCount": + return entry.line_count + if key == "expanded": + return entry.seq in self.expanded + if key == "bodyLines": + if entry.seq not in self.expanded: + return [] + return [ + {"text": text, "kind": kind, "spans": [list(s) for s in self.matcher.spans(text)]} + for text, kind in entry.body_lines() + ] + if key == "count": + return entry.count + if key == "matched": + return ( + self.search_mode == "highlight" + and self.matcher.active + and self.matcher.matches(entry) + ) + if key == "current": + return entry.seq == self.current_seq + if key == "timeFull": + full = format_full(entry.ts) + if entry.count > 1: + full += f" (first {format_full(entry.first_ts)}, {entry.count} times)" + return full + if key == "isTraceback": + return entry.is_traceback + return getattr(entry, key, None) + + # ------------------------------------------------------------------ helpers for views + def entry(self, row: int) -> LogEntry | None: + """Entry shown in ``row``.""" + if 0 <= row < len(self._rows): + return self._rows[row] + return None + + def row_of(self, entry: LogEntry) -> int: + """Row of a visible entry, -1 when it is filtered out.""" + row = bisect_left(self._rows, entry.seq, key=lambda e: e.seq) + if row < len(self._rows) and self._rows[row] is entry: + return row + return -1 + + @property + def total(self) -> int: + """Entries in memory, visible or not.""" + return len(self._entries) + + @property + def rows(self) -> list[LogEntry]: + """Visible entries, in row order.""" + return self._rows + + @property + def entries(self) -> list[LogEntry]: + """All entries in memory, oldest first.""" + return self._entries + + def time_text(self, entry: LogEntry) -> str: + """Time column text in the current time mode.""" + if self.time_mode == "relative": + return format_relative(entry.ts) + return format_clock(entry.ts) + + def level_color(self, level: str) -> QColor: + """Cached theme colour of a level.""" + color = self._level_colors.get(level) + if color is None: + color = self._level_colors[level] = level_color(self.tokens, level) + return color + + def level_ink(self, level: str) -> QColor: + """Cached readable text colour of a level, for badges and counters.""" + color = self._level_inks.get(level) + if color is None: + color = self._level_inks[level] = ink(self.tokens, self.level_color(level)) + return color + + def service_color(self, service: str) -> QColor: + """Cached colour of a service.""" + color = self._service_colors.get(service) + if color is None: + color = self._service_colors[service] = service_color(self.tokens, service) + return color + + def row_tint(self, level: str) -> QColor | None: + """Background tint of error and critical rows, None for the other levels.""" + return self._row_tint.get(level) + + def refresh_theme(self) -> None: + """Re-read the theme colours.""" + self.tokens = ThemeTokens() + self._level_colors.clear() + self._level_inks.clear() + self._service_colors.clear() + self._row_tint = { + "CRITICAL": self.tokens.soft(self.tokens.danger, 0.14 if self.tokens.dark else 0.09), + "ERROR": self.tokens.soft( + self.level_color("ERROR"), 0.10 if self.tokens.dark else 0.07 + ), + } + self._touch_all() + + def _touch_all(self, keys: Iterable[str] | None = None) -> None: + if not self._rows: + return + roles = [self._role_ids[k] for k in keys] if keys else [] + self.dataChanged.emit(self.index(0, 0), self.index(len(self._rows) - 1, 0), roles) + + def _touch_entry(self, entry: LogEntry, keys: Iterable[str] | None = None) -> None: + row = self.row_of(entry) + if row >= 0: + index = self.index(row, 0) + roles = [self._role_ids[k] for k in keys] if keys else [] + self.dataChanged.emit(index, index, roles) + + # ------------------------------------------------------------------ filtering + def _passes_common(self, entry: LogEntry) -> bool: + if self.services is not None and entry.service not in self.services: + return False + if self._since_ts is not None and entry.ts < self._since_ts: + return False + if self.search_mode == "filter" and not self.matcher.matches(entry): + return False + return True + + def _accepts(self, entry: LogEntry) -> bool: + return entry.group in self.groups and self._passes_common(entry) + + def refilter(self) -> None: + """Rebuild the visible rows, the counts and the search matches from scratch.""" + counts = dict.fromkeys(GROUPS, 0) + rows = [] + for entry in self._entries: + if not self._passes_common(entry): + continue + counts[entry.group] = counts.get(entry.group, 0) + 1 + if entry.group in self.groups: + rows.append(entry) + self.beginResetModel() + self._rows = rows + self.endResetModel() + self.group_counts = counts + self._rebuild_matches() + self.counts_changed.emit() + + def _rebuild_matches(self) -> None: + if self.search_mode == "highlight" and self.matcher.active: + self._matches = [e for e in self._rows if self.matcher.matches(e)] + else: + self._matches = [] + self.match_pos = min(self.match_pos, len(self._matches) - 1) + + @property + def match_count(self) -> int: + """Number of search matches among the visible rows (highlight mode).""" + return len(self._matches) + + def step_match(self, step: int) -> int: + """Move to the next (``step=1``) or previous (``-1``) match; return its row or -1.""" + if not self._matches: + return -1 + self.match_pos = (self.match_pos + step) % len(self._matches) + entry = self._matches[self.match_pos] + self.set_current(entry) + return self.row_of(entry) + + def set_current(self, entry: LogEntry | None) -> None: + """Mark one entry as the current row.""" + previous = self.current_seq + self.current_seq = entry.seq if entry is not None else None + if previous is not None: + old = self._find(previous) + if old is not None: + self._touch_entry(old, ["current"]) + if entry is not None: + self._touch_entry(entry, ["current"]) + + def current_row(self) -> int: + """Row of the current entry, -1 when there is none or it is filtered out.""" + if self.current_seq is None: + return -1 + entry = self._find(self.current_seq) + return -1 if entry is None else self.row_of(entry) + + def _find(self, seq: int) -> LogEntry | None: + row = bisect_left(self._rows, seq, key=lambda e: e.seq) + if row < len(self._rows) and self._rows[row].seq == seq: + return self._rows[row] + return None + + def set_since(self, key: str) -> None: + """Only show entries newer than a preset age.""" + self.since_key = key if key in SINCE else "all" + seconds = SINCE[self.since_key][1] + self._since_ts = None if seconds is None else time.time() - seconds + self.refilter() + + def reset_filters(self, matcher: SearchMatcher, mode: str) -> None: + """Show every level, service and time, with the given (usually empty) search.""" + self.groups = set(GROUPS) + self.services = None + self.since_key = "all" + self._since_ts = None + self.set_search(matcher, mode) + + def set_search(self, matcher: SearchMatcher, mode: str) -> None: + """Apply a new search.""" + self.matcher = matcher + self.search_mode = mode + self.match_pos = -1 + self.refilter() + + # ------------------------------------------------------------------ ingest + def append_entries(self, entries: list[LogEntry]) -> int: + """Add new entries, folding repeats. Returns the number of rows inserted.""" + fresh: list[LogEntry] = [] + last = self._entries[-1] if self._entries else None + for entry in entries: + if ( + last is not None + and last.fold_key == entry.fold_key + and 0 <= entry.ts - last.ts <= FOLD_WINDOW_S + ): + last.count += 1 + last.ts = entry.ts + if not fresh or fresh[-1] is not last: + self._touch_entry(last, ["count", "time", "timeFull"]) + continue + fresh.append(entry) + self._entries.append(entry) + last = entry + if not fresh: + return 0 + visible = [] + for entry in fresh: + self.service_counts[entry.service] = self.service_counts.get(entry.service, 0) + 1 + if not self._passes_common(entry): + continue + self.group_counts[entry.group] = self.group_counts.get(entry.group, 0) + 1 + if entry.group in self.groups: + visible.append(entry) + if visible: + first = len(self._rows) + self.beginInsertRows(QModelIndex(), first, first + len(visible) - 1) + self._rows.extend(visible) + self.endInsertRows() + if self.search_mode == "highlight" and self.matcher.active: + self._matches.extend(e for e in visible if self.matcher.matches(e)) + self._trim() + self.counts_changed.emit() + return len(visible) + + def next_seq(self) -> int: + """Sequence number for the next entry.""" + self._seq += 1 + return self._seq + + def _trim(self) -> None: + overflow = len(self._entries) - self.max_entries + if overflow < self.TRIM_CHUNK: + return + dropped = self._entries[:overflow] + del self._entries[:overflow] + cutoff = dropped[-1].seq + for entry in dropped: + self.service_counts[entry.service] -= 1 + if self.service_counts[entry.service] <= 0: + del self.service_counts[entry.service] + if self._passes_common(entry): + self.group_counts[entry.group] -= 1 + self.expanded.discard(entry.seq) + visible = bisect_left(self._rows, cutoff + 1, key=lambda e: e.seq) + if visible: + self.beginRemoveRows(QModelIndex(), 0, visible - 1) + del self._rows[:visible] + self.endRemoveRows() + if self._matches: + kept = bisect_left(self._matches, cutoff + 1, key=lambda e: e.seq) + del self._matches[:kept] + self.match_pos = max(-1, self.match_pos - kept) + + def clear(self) -> None: + """Forget every entry.""" + self.beginResetModel() + self._entries.clear() + self._rows.clear() + self._matches.clear() + self.expanded.clear() + self.match_pos = -1 + self.current_seq = None + self.service_counts.clear() + self.group_counts = dict.fromkeys(GROUPS, 0) + self.endResetModel() + self.counts_changed.emit() + + # ------------------------------------------------------------------ expand / time mode + def toggle_expanded(self, row: int) -> bool: + """Expand or collapse a multi-line entry. Returns the new state.""" + entry = self.entry(row) + if entry is None or entry.line_count <= 1: + return False + if entry.seq in self.expanded: + self.expanded.discard(entry.seq) + else: + self.expanded.add(entry.seq) + self._touch_entry(entry, ["expanded", "bodyLines"]) + return entry.seq in self.expanded + + def set_time_mode(self, mode: str) -> None: + """``clock`` or ``relative`` timestamps.""" + self.time_mode = mode + self._touch_all(["time"]) + + def tick(self) -> None: + """Refresh relative timestamps.""" + if self.time_mode == "relative": + self._touch_all(["time"]) + + +class LogPanelBackend(QObject): + """State and actions of the reworked log panel, shared by the QML and QWidget views. + + Args: + parent: Parent QObject, usually the panel widget. + use_queue(bool): Feed the panel from the shared ``BecLogsQueue`` (live BEC logs). Tests, + demos and benchmarks pass False and call :meth:`ingest` directly. + max_entries(int): Entries kept in memory. + + Signals: + changed(): :attr:`state` changed. + scroll_to_end(): The view should show the newest row (follow tail). + reveal_row(int): The view should scroll to this row (search navigation). + toast(str): Short feedback for the user, e.g. "Copied 3 lines". + focus_search(): The view should focus its search field (Ctrl+F). + """ + + changed = Signal() + scroll_to_end = Signal() + reveal_row = Signal(int) + toast = Signal(str) + focus_search = Signal() + + SEARCH_DEBOUNCE_MS = 150 + + def __init__(self, parent: QObject | None = None, use_queue: bool = True, max_entries=20000): + super().__init__(parent) + self.model = LogFeedModel(max_entries=max_entries, parent=self) + self._state: dict = {} + self._search_text = "" + self._search_mode = "filter" + self._regex = False + self.paused = False + self._pending: list[Any] = [] + self.follow = True + self.new_below = 0 + self._queue = None + self._search_timer = QTimer(self, singleShot=True, interval=self.SEARCH_DEBOUNCE_MS) + self._search_timer.timeout.connect(self._apply_search) + self._tick_timer = QTimer(self, interval=10000) + self._tick_timer.timeout.connect(self.model.tick) + self.model.counts_changed.connect(self._publish) + self.model.rowsInserted.connect(self._on_rows_inserted) + if use_queue: + self._attach_queue() + self._publish() + + # ------------------------------------------------------------------ data in + def _attach_queue(self) -> None: + # pylint: disable=import-outside-toplevel + from bec_widgets.widgets.utility.logpanel.logpanel import BecLogsQueue + + self._queue = BecLogsQueue.instance() + self._queue.attach() + self.ingest(self._queue.snapshot_records()) + self._queue.new_records.connect(self.ingest) + + @Slot(list) + def ingest(self, records: list) -> None: + """Add ``logpanel._LogRec`` records or ready :class:`LogEntry` objects.""" + if self.paused: + self._pending.extend(records) + self._publish() + return + entries = [ + r if isinstance(r, LogEntry) else LogEntry.from_record(r, self.model.next_seq()) + for r in records + ] + self.model.append_entries(entries) + + def _on_rows_inserted(self, _parent, first: int, last: int) -> None: + if self.follow: + self.scroll_to_end.emit() + else: + self.new_below += last - first + 1 + self._publish() + + # ------------------------------------------------------------------ state for the views + @property + def state(self) -> dict: + """Everything the views need to render the toolbar, chips and banners.""" + return self._state + + def _publish(self) -> None: + model = self.model + tokens = model.tokens + groups = [ + { + "key": key, + "label": label, + "count": model.group_counts.get(key, 0), + "active": key in model.groups, + "color": group_color(tokens, key), + "ink": ink(tokens, group_color(tokens, key)), + } + for key, label in GROUPS.items() + ] + services = [ + { + "name": name, + "count": count, + "active": model.services is None or name in model.services, + "color": model.service_color(name), + } + for name, count in sorted(model.service_counts.items(), key=lambda kv: kv[0].lower()) + ] + if model.services is None: + services_label = "All services" + elif len(model.services) == 1: + services_label = next(iter(model.services)) + else: + services_label = f"{len(model.services)} services" + filtered = ( + model.groups != set(GROUPS) + or model.services is not None + or model.since_key != "all" + or (self._search_mode == "filter" and model.matcher.active) + ) + self._state = { + "groups": groups, + "services": services, + "servicesLabel": services_label, + "servicesFiltered": model.services is not None, + "since": model.since_key, + "sinceLabel": SINCE[model.since_key][0], + "sinceOptions": [{"key": k, "label": v[0]} for k, v in SINCE.items()], + "search": self._search_text, + "searchMode": self._search_mode, + "regex": self._regex, + "regexError": model.matcher.error, + "matchCount": model.match_count, + "matchPos": model.match_pos + 1, + "shown": model.rowCount(), + "total": model.total, + "summary": self._summary_text(filtered), + "filtered": filtered, + "paused": self.paused, + "pending": len(self._pending), + "follow": self.follow, + "newBelow": self.new_below, + "timeMode": model.time_mode, + "empty": model.total == 0, + "exceptionInk": model.level_ink("ERROR"), + } + self.changed.emit() + + def _summary_text(self, filtered: bool) -> str: + shown, total = self.model.rowCount(), self.model.total + if not filtered: + return f"{total:,} entries" + return f"{shown:,} of {total:,} entries" + + # ------------------------------------------------------------------ filters + # pylint: disable=invalid-name,missing-function-docstring + @Slot(str) + def toggleGroup(self, key: str) -> None: + groups = set(self.model.groups) + groups ^= {key} + self.model.groups = groups + self.model.refilter() + + @Slot(str) + def soloGroup(self, key: str) -> None: + self.model.groups = {key} if self.model.groups != {key} else set(GROUPS) + self.model.refilter() + + @Slot(str) + def toggleService(self, name: str) -> None: + current = ( + set(self.model.service_counts) + if self.model.services is None + else set(self.model.services) + ) + current ^= {name} + self._set_services(None if current >= set(self.model.service_counts) else current) + + @Slot(str) + def soloService(self, name: str) -> None: + self._set_services({name}) + + @Slot(str) + def hideService(self, name: str) -> None: + current = ( + set(self.model.service_counts) + if self.model.services is None + else set(self.model.services) + ) + self._set_services(current - {name}) + + @Slot() + def allServices(self) -> None: + self._set_services(None) + + def _set_services(self, services: set[str] | None) -> None: + self.model.services = services + self.model.refilter() + + @Slot(str) + def setSince(self, key: str) -> None: + self.model.set_since(key) + + @Slot(str) + def setSearch(self, text: str) -> None: + self._search_text = text + if text: + self._search_timer.start() + else: + self._search_timer.stop() + self._apply_search() + + @Slot(str) + def setSearchMode(self, mode: str) -> None: + self._search_mode = "highlight" if mode == "highlight" else "filter" + self._apply_search() + + @Slot(bool) + def setRegex(self, regex: bool) -> None: + self._regex = regex + self._apply_search() + + def _apply_search(self) -> None: + self._search_timer.stop() + self.model.set_search(SearchMatcher(self._search_text, self._regex), self._search_mode) + if self.follow: + self.scroll_to_end.emit() + + @Slot() + def nextMatch(self) -> None: + self._step_match(1) + + @Slot() + def prevMatch(self) -> None: + self._step_match(-1) + + def _step_match(self, step: int) -> None: + # stop following first so a pending search does not scroll back to the newest row + self.follow = False + if self._search_timer.isActive(): + self._apply_search() + row = self.model.step_match(step) + if row >= 0: + self.reveal_row.emit(row) + self._publish() + + @Slot() + def resetFilters(self) -> None: + self._search_text = "" + self.model.reset_filters(SearchMatcher(""), self._search_mode) + + # ------------------------------------------------------------------ live / follow + @Slot() + def togglePause(self) -> None: + self.setPaused(not self.paused) + + @Slot(bool) + def setPaused(self, paused: bool) -> None: + if paused == self.paused: + return + self.paused = paused + if not paused and self._pending: + pending, self._pending = self._pending, [] + self.ingest(pending) + self._publish() + + @Slot(bool) + def setFollow(self, follow: bool) -> None: + if follow == self.follow: + return + self.follow = follow + if follow: + self.new_below = 0 + self._publish() + + @Slot(result=bool) + def isFollowing(self) -> bool: + """Whether the view should keep showing the newest row (read by QML).""" + return self.follow + + @Slot() + def jumpToLatest(self) -> None: + self.follow = True + self.new_below = 0 + self.scroll_to_end.emit() + self._publish() + + # ------------------------------------------------------------------ rows + @Slot(int) + def toggleExpanded(self, row: int) -> None: + self.model.toggle_expanded(row) + + @Slot(int) + def setCurrent(self, row: int) -> None: + self.model.set_current(self.model.entry(row)) + + @Slot(int) + def copyRow(self, row: int) -> None: + entry = self.model.entry(row) + if entry is not None: + self._to_clipboard(entry.copy_text(), "Copied 1 entry") + + @Slot(int) + def copyMessage(self, row: int) -> None: + entry = self.model.entry(row) + if entry is not None: + self._to_clipboard(entry.message, "Copied message") + + def copy_rows(self, rows: Iterable[int]) -> None: + """Copy several rows, e.g. a QWidget multi-selection.""" + entries = [e for e in (self.model.entry(r) for r in sorted(rows)) if e is not None] + if entries: + self._to_clipboard( + "\n".join(e.copy_text() for e in entries), f"Copied {len(entries)} entries" + ) + + @Slot() + def copyVisible(self) -> None: + self.copy_rows(range(self.model.rowCount())) + + def visible_text(self) -> str: + """Plain text of every visible row.""" + return "\n".join(e.copy_text() for e in self.model.rows) + + @Slot() + def exportVisible(self) -> None: + default = f"bec_logs_{datetime.now():%Y%m%d_%H%M%S}.log" + path, _ = QFileDialog.getSaveFileName( + None, "Export logs", default, "Log files (*.log *.txt)" + ) + if path: + self.export_to(Path(path)) + + def export_to(self, path: Path) -> None: + """Write the visible rows to ``path``.""" + path.write_text(self.visible_text() + "\n", encoding="utf-8") + self.toast.emit(f"Saved {self.model.rowCount():,} entries to {path.name}") + + def _to_clipboard(self, text: str, message: str) -> None: + QApplication.clipboard().setText(text) + self.toast.emit(message) + + @Slot() + def clear(self) -> None: + self._pending.clear() + self.new_below = 0 + self.model.clear() + + @Slot() + def toggleTimeMode(self) -> None: + mode = "relative" if self.model.time_mode == "clock" else "clock" + self.model.set_time_mode(mode) + if mode == "relative": + self._tick_timer.start() + else: + self._tick_timer.stop() + self._publish() + + def refresh_theme(self) -> None: + """Re-read theme colours after a theme switch.""" + self.model.refresh_theme() + self._publish() + + def cleanup(self) -> None: + """Stop timers and detach from the shared log queue.""" + self._search_timer.stop() + self._tick_timer.stop() + if self._queue is not None: + try: + self._queue.new_records.disconnect(self.ingest) + except (RuntimeError, TypeError): + pass + self._queue.detach() + self._queue = None + + stateMap = Property("QVariantMap", lambda self: self._state, notify=changed) + feed = Property(QObject, lambda self: self.model, constant=True) + + +def make_entries(model: LogFeedModel, specs: Iterable[tuple]) -> list[LogEntry]: + """Build entries from ``(level, ts, service, message[, function])`` tuples (demos and tests).""" + return [LogEntry(model.next_seq(), *spec) for spec in specs] diff --git a/bec_widgets/widgets/utility/logpanel/qml/LogPanelView.qml b/bec_widgets/widgets/utility/logpanel/qml/LogPanelView.qml new file mode 100644 index 00000000..40b6b91e --- /dev/null +++ b/bec_widgets/widgets/utility/logpanel/qml/LogPanelView.qml @@ -0,0 +1,736 @@ +import QtQuick +import QtQuick.Controls.Basic +import QtQuick.Layouts +import BecUi + +// Reworked BEC log panel. Same UX as log_panel_qwidget.py; all state comes from +// LogPanelBackend (backend.stateMap) and the rows from its LogFeedModel (backend.feed). +Rectangle { + id: root + required property var backend + property string monoFamily: "monospace" + readonly property var st: backend.stateMap + property int currentRow: -1 + + // geometry shared with log_panel_qwidget.py + readonly property int rowH: 26 + readonly property int lineH: 18 + readonly property int xTime: 12 + readonly property int xBadge: 112 + readonly property int xService: 166 + readonly property int xChevron: 294 + readonly property int xMessage: 312 + readonly property int serviceW: 120 + readonly property color markColor: Qt.rgba(theme.warning.r, theme.warning.g, theme.warning.b, theme.dark ? 0.45 : 0.55) + + color: theme.bg + focus: true + + FontMetrics { id: mono; font.family: root.monoFamily; font.pixelSize: 12 } + FontMetrics { id: monoBold; font.family: root.monoFamily; font.pixelSize: 12; font.bold: true } + + function followTail() { + if (root.backend.isFollowing()) list.positionViewAtEnd() + } + + function setCurrent(row) { + root.currentRow = row + root.backend.setCurrent(row) + } + + Connections { + target: root.backend + function onScroll_to_end() { Qt.callLater(root.followTail) } + function onReveal_row(row) { + root.currentRow = row + list.positionViewAtIndex(row, ListView.Center) + } + function onToast(text) { + toast.text = text + toast.opacity = 1 + toastTimer.restart() + } + function onFocus_search() { + search.forceActiveFocus() + search.selectAll() + } + } + + // text with search highlights painted behind the matches (monospace metrics) + component MarkedText: Item { + id: marked + property string text: "" + property var spans: [] + property color color: theme.fg + property bool bold: false + readonly property var metrics: bold ? monoBold : mono + clip: true + implicitHeight: label.implicitHeight + Repeater { + model: marked.spans + delegate: Rectangle { + required property var modelData + x: marked.metrics.advanceWidth(marked.text.substring(0, modelData[0])) - 1 + width: marked.metrics.advanceWidth(marked.text.substring(modelData[0], modelData[0] + modelData[1])) + 2 + height: 17 + anchors.verticalCenter: parent.verticalCenter + radius: 3 + color: root.markColor + } + } + Text { + id: label + anchors.fill: parent + text: marked.text + textFormat: Text.PlainText + color: marked.color + font.family: root.monoFamily + font.pixelSize: 12 + font.bold: marked.bold + elide: Text.ElideRight + verticalAlignment: Text.AlignVCenter + } + } + + // pill-shaped field button that opens a popup, like the QWidget SelectButton + component SelectPill: AbstractButton { + id: pill + property string iconName: "" + property bool highlighted: false + implicitHeight: 26 + implicitWidth: pillRow.implicitWidth + 20 + hoverEnabled: true + background: Rectangle { + radius: 13 + color: theme.field + border.color: pill.highlighted ? theme.primary : pill.hovered ? theme.fgSubtle : theme.border + } + contentItem: Item { + RowLayout { + id: pillRow + anchors.centerIn: parent + spacing: 5 + Icon { name: pill.iconName; size: 15; color: pill.highlighted ? theme.primary : theme.fgMuted } + Text { text: pill.text; color: theme.fg; font.pixelSize: 12 } + } + } + } + + ColumnLayout { + anchors.fill: parent + spacing: 0 + + // ---------------------------------------------------------------- toolbar + Rectangle { + Layout.fillWidth: true + implicitHeight: bar.implicitHeight + 16 + color: theme.card + Rectangle { anchors.bottom: parent.bottom; width: parent.width; height: 1; color: theme.border } + + ColumnLayout { + id: bar + anchors.fill: parent + anchors.leftMargin: 10 + anchors.rightMargin: 10 + anchors.topMargin: 8 + anchors.bottomMargin: 8 + spacing: 8 + + RowLayout { + Layout.fillWidth: true + spacing: 6 + + InputField { + id: search + Layout.fillWidth: true + Layout.maximumWidth: 440 + Layout.minimumWidth: 160 + leftPadding: 32 + placeholderText: "Search logs (Ctrl+F)" + invalid: (root.st.regexError || "") !== "" + onTextEdited: root.backend.setSearch(text) + Keys.onReturnPressed: (event) => { + if (root.st.searchMode === "highlight") { + if (event.modifiers & Qt.ShiftModifier) root.backend.prevMatch() + else root.backend.nextMatch() + } + } + Keys.onEscapePressed: { text = ""; root.backend.setSearch(""); list.forceActiveFocus() } + Icon { x: 9; anchors.verticalCenter: parent.verticalCenter; name: "search"; size: 17; color: theme.fgSubtle } + ToolTip.visible: invalid && hovered + ToolTip.text: root.st.regexError || "" + } + + // Filter | Highlight segmented control + Rectangle { + implicitWidth: modes.implicitWidth + 4 + implicitHeight: 30 + radius: 7 + color: theme.field + border.color: theme.border + RowLayout { + id: modes + anchors.centerIn: parent + spacing: 0 + Repeater { + model: [ + { key: "filter", label: "Filter", tip: "Show only matching entries" }, + { key: "highlight", label: "Highlight", tip: "Show everything and mark the matches" } + ] + delegate: AbstractButton { + id: mode + required property var modelData + readonly property bool active: root.st.searchMode === modelData.key + implicitHeight: 26 + implicitWidth: modeLabel.implicitWidth + 20 + hoverEnabled: true + onClicked: root.backend.setSearchMode(modelData.key) + background: Rectangle { radius: 5; color: mode.active ? theme.hover : "transparent" } + contentItem: Text { + id: modeLabel + text: mode.modelData.label + color: mode.active || mode.hovered ? theme.fg : theme.fgMuted + font.pixelSize: 12 + font.weight: mode.active ? Font.DemiBold : Font.Normal + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + ToolTip.visible: hovered + ToolTip.delay: 500 + ToolTip.text: modelData.tip + } + } + } + } + + IconButton { + iconName: "regular_expression" + tip: "Regular expression" + checked: root.st.regex || false + onClicked: root.backend.setRegex(!root.st.regex) + } + + readonly property bool highlighting: root.st.searchMode === "highlight" && (root.st.search || "") !== "" + Text { + visible: parent.highlighting + text: root.st.matchPos > 0 ? root.st.matchPos + " of " + root.st.matchCount : root.st.matchCount + " matches" + color: theme.fgSubtle + font.pixelSize: 12 + } + IconButton { visible: parent.highlighting; iconName: "keyboard_arrow_up"; tip: "Previous match (Shift+Enter)"; onClicked: root.backend.prevMatch() } + IconButton { visible: parent.highlighting; iconName: "keyboard_arrow_down"; tip: "Next match (Enter)"; onClicked: root.backend.nextMatch() } + + Item { Layout.fillWidth: true } + + StatusPill { + text: root.st.paused ? (root.st.pending > 0 ? "Paused · " + root.st.pending.toLocaleString(Qt.locale("en_US"), "f", 0) + " new" : "Paused") : "Live" + tone: root.st.paused ? theme.warning : theme.success + pulse: !root.st.paused + MouseArea { + id: liveMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.backend.togglePause() + } + ToolTip.visible: liveMouse.containsMouse + ToolTip.delay: 500 + ToolTip.text: root.st.paused ? "Updates are paused. Click to resume." : "Receiving logs. Click to pause." + } + IconButton { + iconName: root.st.timeMode === "relative" ? "history" : "schedule" + tip: root.st.timeMode === "relative" ? "Show clock times" : "Show relative times" + onClicked: root.backend.toggleTimeMode() + } + IconButton { iconName: "content_copy"; tip: "Copy visible entries"; onClicked: root.backend.copyVisible() } + IconButton { iconName: "download"; tip: "Export visible entries to a file"; onClicked: root.backend.exportVisible() } + IconButton { iconName: "delete_sweep"; tip: "Clear the panel"; onClicked: root.backend.clear() } + } + + RowLayout { + Layout.fillWidth: true + spacing: 6 + + Repeater { + model: root.st.groups || [] + delegate: AbstractButton { + id: chip + required property var modelData + implicitHeight: 26 + implicitWidth: chipMetrics.advanceWidth(modelData.label + " " + modelData.count.toLocaleString(Qt.locale("en_US"), "f", 0)) + 34 + hoverEnabled: true + onClicked: root.backend.toggleGroup(modelData.key) + onDoubleClicked: root.backend.soloGroup(modelData.key) + FontMetrics { id: chipMetrics; font.pixelSize: 12; font.weight: Font.DemiBold } + background: Rectangle { + radius: 13 + color: chip.modelData.active ? Qt.rgba(chip.modelData.color.r, chip.modelData.color.g, chip.modelData.color.b, 0.16) + : chip.hovered ? theme.hover : "transparent" + border.color: chip.modelData.active ? chip.modelData.color : theme.border + } + contentItem: Item { + Rectangle { + x: 10; width: 8; height: 8; radius: 4 + anchors.verticalCenter: parent.verticalCenter + color: chip.modelData.active ? chip.modelData.color : theme.fgSubtle + } + Text { + x: 24 + anchors.verticalCenter: parent.verticalCenter + text: chip.modelData.label + color: chip.modelData.active ? theme.fg : theme.fgSubtle + font.pixelSize: 12 + font.weight: chip.modelData.active ? Font.DemiBold : Font.Normal + } + Text { + anchors.right: parent.right + anchors.rightMargin: 10 + anchors.verticalCenter: parent.verticalCenter + text: chip.modelData.count.toLocaleString(Qt.locale("en_US"), "f", 0) + color: chip.modelData.active ? theme.fgMuted : theme.fgSubtle + font.pixelSize: 12 + font.weight: chip.modelData.active ? Font.DemiBold : Font.Normal + } + } + ToolTip.visible: hovered + ToolTip.delay: 500 + ToolTip.text: "Show or hide " + modelData.label.toLowerCase() + " (double-click to show only " + modelData.label.toLowerCase() + ")" + } + } + Item { implicitWidth: 6 } + + SelectPill { + id: serviceButton + iconName: "dns" + text: root.st.servicesLabel || "All services" + highlighted: root.st.servicesFiltered || false + onClicked: servicePopup.opened ? servicePopup.close() : servicePopup.open() + Popup { + id: servicePopup + y: parent.height + 4 + width: 260 + padding: 4 + implicitHeight: Math.min(serviceColumn.implicitHeight + 8, 360) + background: Rectangle { color: theme.card; border.color: theme.border; radius: 8 } + contentItem: Flickable { + clip: true + contentHeight: serviceColumn.implicitHeight + implicitHeight: serviceColumn.implicitHeight + Column { + id: serviceColumn + width: parent.width + ItemDelegate { + width: parent.width + height: 30 + onClicked: root.backend.allServices() + contentItem: Text { text: "Show all services"; color: theme.primary; font.pixelSize: 12; verticalAlignment: Text.AlignVCenter } + background: Rectangle { color: parent.hovered ? theme.hover : "transparent"; radius: 5 } + } + Rectangle { width: parent.width; height: 1; color: theme.border } + Text { + visible: (root.st.services || []).length === 0 + text: "No services yet" + color: theme.fgSubtle + font.pixelSize: 12 + padding: 8 + } + Repeater { + model: root.st.services || [] + delegate: ItemDelegate { + id: svc + required property var modelData + width: serviceColumn.width + height: 30 + onClicked: root.backend.toggleService(modelData.name) + background: Rectangle { color: svc.hovered ? theme.hover : "transparent"; radius: 5 } + contentItem: RowLayout { + spacing: 8 + Rectangle { + width: 16; height: 16; radius: 4 + color: svc.modelData.active ? theme.primary : "transparent" + border.color: svc.modelData.active ? theme.primary : theme.fgSubtle + Icon { anchors.centerIn: parent; visible: svc.modelData.active; name: "check"; size: 13; color: theme.onPrimary } + } + Rectangle { width: 8; height: 8; radius: 4; color: svc.modelData.color } + Text { Layout.fillWidth: true; text: svc.modelData.name; color: theme.fg; font.pixelSize: 12; elide: Text.ElideRight } + Text { text: svc.modelData.count.toLocaleString(Qt.locale("en_US"), "f", 0); color: theme.fgSubtle; font.pixelSize: 12 } + } + } + } + } + } + } + } + + SelectPill { + iconName: "schedule" + text: root.st.sinceLabel || "All time" + highlighted: (root.st.since || "all") !== "all" + onClicked: sincePopup.opened ? sincePopup.close() : sincePopup.open() + Popup { + id: sincePopup + y: parent.height + 4 + width: 160 + padding: 4 + background: Rectangle { color: theme.card; border.color: theme.border; radius: 8 } + contentItem: Column { + Repeater { + model: root.st.sinceOptions || [] + delegate: ItemDelegate { + id: opt + required property var modelData + width: 152 + height: 30 + onClicked: { root.backend.setSince(modelData.key); sincePopup.close() } + background: Rectangle { + color: opt.hovered ? theme.hover : "transparent"; radius: 5 + Rectangle { + width: 3; height: parent.height - 10; radius: 1.5 + anchors.verticalCenter: parent.verticalCenter + color: theme.primary + visible: root.st.since === opt.modelData.key + } + } + contentItem: Text { text: opt.modelData.label; color: theme.fg; font.pixelSize: 12; verticalAlignment: Text.AlignVCenter } + } + } + } + } + } + + Item { Layout.fillWidth: true } + Text { text: root.st.summary || ""; color: theme.fgSubtle; font.pixelSize: 12 } + Text { + visible: root.st.filtered || false + text: "Reset filters" + color: theme.primary + font.pixelSize: 12 + leftPadding: 4 + rightPadding: 4 + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: { search.text = ""; root.backend.resetFilters() } + } + } + } + } + } + + // ---------------------------------------------------------------- rows + ListView { + id: list + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + model: root.backend.feed + boundsBehavior: Flickable.StopAtBounds + reuseItems: true + cacheBuffer: 400 + focus: true + ScrollBar.vertical: ScrollBar { + policy: ScrollBar.AsNeeded + onPressedChanged: if (!pressed) root.backend.setFollow(list.atYEnd) + } + onMovementEnded: root.backend.setFollow(atYEnd) + onAtYEndChanged: if (atYEnd && !root.st.follow) root.backend.setFollow(true) + + Keys.onPressed: (event) => { + if (event.key === Qt.Key_Down || event.key === Qt.Key_Up) { + const next = Math.max(0, Math.min(count - 1, root.currentRow + (event.key === Qt.Key_Down ? 1 : -1))) + root.setCurrent(next) + positionViewAtIndex(next, ListView.Contain) + root.backend.setFollow(next === count - 1) + event.accepted = true + } else if ((event.key === Qt.Key_Space || event.key === Qt.Key_Right || event.key === Qt.Key_Left) && root.currentRow >= 0) { + root.backend.toggleExpanded(root.currentRow) + event.accepted = true + } + } + + delegate: Item { + id: row + required property int index + required property string level + required property string group + required property string badge + required property color levelColor + required property color levelInk + required property color rowTint + required property string time + required property string timeFull + required property string service + required property color serviceColor + required property string summary + required property var summarySpans + required property int lineCount + required property bool expanded + required property var bodyLines + required property int count + required property bool current + readonly property bool debug: level === "DEBUG" || level === "TRACE" + + width: ListView.view.width + height: expanded ? root.rowH + bodyLines.length * root.lineH + 20 : root.rowH + + HoverHandler { id: hover } + + Rectangle { anchors.fill: parent; color: row.rowTint } + Rectangle { + anchors.fill: parent + color: row.current ? Qt.rgba(theme.primary.r, theme.primary.g, theme.primary.b, 0.12) + : hover.hovered ? theme.hover : "transparent" + } + Rectangle { x: 0; y: 3; width: 3; height: parent.height - 6; color: row.levelColor } + + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.LeftButton | Qt.RightButton + onClicked: (mouse) => { + root.setCurrent(row.index) + list.forceActiveFocus() + if (mouse.button === Qt.RightButton) { + rowMenu.row = row.index + rowMenu.service = row.service + rowMenu.group = row.group + rowMenu.multiLine = row.lineCount > 1 + rowMenu.expanded = row.expanded + rowMenu.popup() + } + } + onDoubleClicked: root.backend.toggleExpanded(row.index) + } + + Item { + id: header + width: parent.width + height: root.rowH + + Text { + x: root.xTime + width: root.xBadge - root.xTime - 6 + height: parent.height + verticalAlignment: Text.AlignVCenter + text: row.time + color: theme.fgSubtle + font.family: root.monoFamily + font.pixelSize: 12 + ToolTip.visible: timeHover.hovered + ToolTip.delay: 400 + ToolTip.text: row.timeFull + HoverHandler { id: timeHover } + } + Rectangle { + x: root.xBadge; y: 4 + width: 46; height: root.rowH - 8 + radius: 4 + color: Qt.rgba(row.levelColor.r, row.levelColor.g, row.levelColor.b, 0.18) + Text { + anchors.centerIn: parent + text: row.badge + color: row.levelInk + font.pixelSize: 10 + font.bold: true + } + } + Rectangle { + x: root.xService + 0.5; width: 7; height: 7; radius: 3.5 + anchors.verticalCenter: parent.verticalCenter + color: row.serviceColor + } + Text { + x: root.xService + 13 + width: root.serviceW - 13 + height: parent.height + verticalAlignment: Text.AlignVCenter + text: row.service + color: theme.fgMuted + font.pixelSize: 12 + elide: Text.ElideRight + } + Icon { + visible: row.lineCount > 1 + x: root.xChevron; y: 5 + name: row.expanded ? "expand_more" : "chevron_right" + size: 16 + color: theme.fgMuted + MouseArea { + anchors.fill: parent + anchors.margins: -4 + cursorShape: Qt.PointingHandCursor + onClicked: root.backend.toggleExpanded(row.index) + } + } + MarkedText { + x: root.xMessage + width: Math.max(0, extras.x - root.xMessage - 6) + height: parent.height + text: row.summary + spans: row.summarySpans + color: row.debug ? theme.fgMuted : theme.fg + } + Row { + id: extras + anchors.right: parent.right + anchors.rightMargin: 8 + height: parent.height + spacing: 6 + Rectangle { + visible: row.lineCount > 1 && !row.expanded + anchors.verticalCenter: parent.verticalCenter + height: root.rowH - 10 + width: linesLabel.implicitWidth + 12 + radius: height / 2 + color: theme.track + Text { id: linesLabel; anchors.centerIn: parent; text: "+" + (row.lineCount - 1) + " lines"; color: theme.fgMuted; font.pixelSize: 11; font.weight: Font.DemiBold } + } + Rectangle { + visible: row.count > 1 + anchors.verticalCenter: parent.verticalCenter + height: root.rowH - 10 + width: countLabel.implicitWidth + 12 + radius: height / 2 + color: Qt.rgba(row.levelColor.r, row.levelColor.g, row.levelColor.b, 0.18) + Text { id: countLabel; anchors.centerIn: parent; text: "×" + row.count; color: row.levelInk; font.pixelSize: 11; font.weight: Font.DemiBold } + } + IconButton { + visible: hover.hovered + anchors.verticalCenter: parent.verticalCenter + implicitWidth: 24; implicitHeight: 22 + iconSize: 16 + iconName: "content_copy" + tip: "Copy entry" + onClicked: root.backend.copyRow(row.index) + } + } + } + + // expanded body: full message, tracebacks styled per line + Rectangle { + visible: row.expanded + x: root.xChevron + y: root.rowH + width: parent.width - root.xChevron - 12 + height: parent.height - root.rowH - 6 + radius: 6 + color: theme.field + border.color: theme.border + Column { + x: 10; y: 7 + width: parent.width - 20 + Repeater { + model: row.expanded ? row.bodyLines : [] + delegate: MarkedText { + required property var modelData + width: parent.width + height: root.lineH + text: modelData.text + spans: modelData.spans + bold: modelData.kind === "exception" + color: modelData.kind === "exception" ? root.st.exceptionInk + : (modelData.kind === "file" || modelData.kind === "head") ? theme.fgSubtle : theme.fg + } + } + } + } + } + + // empty states + Column { + anchors.centerIn: parent + visible: list.count === 0 + spacing: 6 + Icon { anchors.horizontalCenter: parent.horizontalCenter; name: root.st.empty ? "hourglass_empty" : "filter_alt_off"; size: 40; color: theme.fgSubtle } + Text { anchors.horizontalCenter: parent.horizontalCenter; text: root.st.empty ? "Waiting for logs" : "No entries match"; color: theme.fg; font.pixelSize: 14; font.weight: Font.DemiBold } + Text { + anchors.horizontalCenter: parent.horizontalCenter + text: root.st.empty ? "New BEC log messages appear here." : (root.st.total || 0).toLocaleString(Qt.locale("en_US"), "f", 0) + " entries are hidden by the filters." + color: theme.fgSubtle + font.pixelSize: 12 + } + TextButton { + anchors.horizontalCenter: parent.horizontalCenter + visible: !root.st.empty + text: "Reset filters" + onClicked: { search.text = ""; root.backend.resetFilters() } + } + } + + // jump to latest + AbstractButton { + id: jump + visible: !root.st.follow && list.count > 0 + anchors.horizontalCenter: parent.horizontalCenter + anchors.bottom: parent.bottom + anchors.bottomMargin: 12 + implicitHeight: 30 + implicitWidth: jumpRow.implicitWidth + 28 + hoverEnabled: true + onClicked: root.backend.jumpToLatest() + background: Rectangle { radius: 15; color: jump.hovered ? Qt.lighter(theme.primary, 1.1) : theme.primary } + contentItem: Item { + RowLayout { + id: jumpRow + anchors.centerIn: parent + spacing: 6 + Icon { name: "arrow_downward"; size: 16; color: theme.onPrimary } + Text { + text: root.st.newBelow > 0 ? root.st.newBelow.toLocaleString(Qt.locale("en_US"), "f", 0) + " new" : "Jump to latest" + color: theme.onPrimary + font.pixelSize: 12 + font.weight: Font.DemiBold + } + } + } + } + + // short feedback such as "Copied 1 entry" + Rectangle { + id: toastBox + x: 12 + anchors.bottom: parent.bottom + anchors.bottomMargin: 12 + visible: toast.opacity > 0 + opacity: toast.opacity + width: toast.implicitWidth + 24 + height: 30 + radius: 8 + color: theme.card + border.color: theme.border + Text { + id: toast + anchors.centerIn: parent + opacity: 0 + color: theme.fg + font.pixelSize: 12 + Behavior on opacity { NumberAnimation { duration: 150 } } + } + Timer { id: toastTimer; interval: 1600; onTriggered: toast.opacity = 0 } + } + } + } + + Menu { + id: rowMenu + property int row: -1 + property string service: "" + property string group: "" + property bool multiLine: false + property bool expanded: false + MenuItem { text: "Copy entry"; onTriggered: root.backend.copyRow(rowMenu.row) } + MenuItem { text: "Copy message only"; onTriggered: root.backend.copyMessage(rowMenu.row) } + MenuItem { + visible: rowMenu.multiLine + height: visible ? implicitHeight : 0 + text: rowMenu.expanded ? "Collapse" : "Expand" + onTriggered: root.backend.toggleExpanded(rowMenu.row) + } + MenuSeparator {} + MenuItem { text: "Only " + rowMenu.service; onTriggered: root.backend.soloService(rowMenu.service) } + MenuItem { text: "Hide " + rowMenu.service; onTriggered: root.backend.hideService(rowMenu.service) } + MenuItem { text: "Only " + rowMenu.group + " level"; onTriggered: root.backend.soloGroup(rowMenu.group) } + MenuSeparator {} + MenuItem { text: "Copy visible entries"; onTriggered: root.backend.copyVisible() } + MenuItem { text: "Jump to latest"; onTriggered: root.backend.jumpToLatest() } + } +} diff --git a/tests/unit_tests/test_log_panel_ports.py b/tests/unit_tests/test_log_panel_ports.py new file mode 100644 index 00000000..ac0a6857 --- /dev/null +++ b/tests/unit_tests/test_log_panel_ports.py @@ -0,0 +1,310 @@ +# pylint: disable=missing-function-docstring,missing-module-docstring,redefined-outer-name +# pylint: disable=protected-access,unused-argument,invalid-name +import time + +import pytest +from bec_lib.messages import LogMessage +from qtpy.QtWidgets import QApplication + +from bec_widgets.tests.utils import create_widget +from bec_widgets.widgets.utility.logpanel import log_ux_common as common +from bec_widgets.widgets.utility.logpanel.log_panel_demo import demo_entries +from bec_widgets.widgets.utility.logpanel.log_panel_qml import LogPanelQml +from bec_widgets.widgets.utility.logpanel.log_panel_qwidget import LogPanelQWidget +from bec_widgets.widgets.utility.logpanel.log_ux_common import LogEntry, LogPanelBackend +from bec_widgets.widgets.utility.logpanel.logpanel import BecLogsQueue + +TRACE = """Traceback (most recent call last): + File "/opt/bec/device_server.py", line 412, in _set_device + status = obj.set(value) +ophyd.utils.errors.LimitError: position=12.5 not within limits (-10, 10)""" + +NOW = 1_760_000_000.0 + + +@pytest.fixture +def backend(qtbot): + obj = LogPanelBackend(use_queue=False) + yield obj + obj.cleanup() + obj.deleteLater() + + +def add(backend, *specs): + backend.ingest([LogEntry(backend.model.next_seq(), *spec) for spec in specs]) + + +def visible(backend): + model = backend.model + return [model.entry(row) for row in range(model.rowCount())] + + +def test_entry_summarises_tracebacks(): + entry = LogEntry(1, "error", NOW, "DeviceServer", TRACE) + assert entry.level == "ERROR" + assert entry.badge == "ERROR" + assert entry.group == "error" + assert entry.is_traceback + assert entry.line_count == 4 + assert entry.summary.startswith("ophyd.utils.errors.LimitError") + kinds = [kind for _, kind in entry.body_lines()] + assert kinds == ["head", "file", "text", "exception"] + + +def test_entry_defaults_for_missing_fields(): + entry = LogEntry(1, "console_log", 0, None, "hello") + assert entry.service == common.UNKNOWN_SERVICE + assert entry.group == "debug" + assert entry.ts > 0 + + +def test_repeats_are_folded(backend): + add( + backend, + ("WARNING", NOW, "DeviceServer", "Waiting for samy"), + ("WARNING", NOW + 1, "DeviceServer", "Waiting for samy"), + ("WARNING", NOW + 2, "DeviceServer", "Waiting for samy"), + ("WARNING", NOW + 3, "ScanServer", "Waiting for samy"), + ("WARNING", NOW + 100, "ScanServer", "Waiting for samy"), + ) + rows = visible(backend) + assert [row.count for row in rows] == [3, 1, 1] + assert rows[0].ts == NOW + 2 + assert rows[0].first_ts == NOW + + +def test_folding_in_a_later_batch_updates_the_row(backend): + add(backend, ("INFO", NOW, "ScanServer", "same")) + changed = [] + backend.model.dataChanged.connect(lambda *args: changed.append(args)) + add(backend, ("INFO", NOW + 1, "ScanServer", "same")) + assert backend.model.rowCount() == 1 + assert visible(backend)[0].count == 2 + assert changed + + +def test_level_service_and_time_filters(backend): + add( + backend, + ("ERROR", time.time() - 7200, "DeviceServer", "old error"), + ("WARNING", time.time() - 10, "ScanServer", "warn"), + ("INFO", time.time() - 5, "ScanServer", "info"), + ("DEBUG", time.time(), "SciHub", "debug"), + ) + backend.toggleGroup("debug") + assert [e.summary for e in visible(backend)] == ["old error", "warn", "info"] + backend.soloService("ScanServer") + assert [e.summary for e in visible(backend)] == ["warn", "info"] + assert backend.state["servicesLabel"] == "ScanServer" + # chips count everything that passes the other filters + counts = {g["key"]: g["count"] for g in backend.state["groups"]} + assert counts == {"error": 0, "warning": 1, "info": 1, "debug": 0} + backend.allServices() + backend.setSince("1h") + assert [e.summary for e in visible(backend)] == ["warn", "info"] + assert backend.state["filtered"] + backend.resetFilters() + assert backend.model.rowCount() == 4 + assert not backend.state["filtered"] + + +def test_hide_service_and_toggle_back(backend): + add(backend, ("INFO", NOW, "A", "a"), ("INFO", NOW + 1, "B", "b")) + backend.hideService("A") + assert [e.service for e in visible(backend)] == ["B"] + backend.toggleService("A") + assert backend.model.services is None + assert backend.model.rowCount() == 2 + + +def test_search_filter_and_regex(backend): + add( + backend, + ("INFO", NOW, "DeviceServer", "samx moved to 1.0"), + ("INFO", NOW + 1, "DeviceServer", "samy moved to 2.0"), + ("ERROR", NOW + 2, "DeviceServer", TRACE), + ) + backend.setSearch("SAMX") + backend._apply_search() + assert [e.summary for e in visible(backend)] == ["samx moved to 1.0"] + # matches inside the collapsed lines count too + backend.setSearch("obj.set") + backend._apply_search() + assert [e.is_traceback for e in visible(backend)] == [True] + backend.setRegex(True) + backend.setSearch(r"sam[xy] moved") + backend._apply_search() + assert backend.model.rowCount() == 2 + backend.setSearch("sam[") + backend._apply_search() + assert backend.state["regexError"] + assert backend.model.rowCount() == 3 # an invalid pattern filters nothing + + +def test_highlight_mode_navigates_matches(backend, qtbot): + add( + backend, + *[("INFO", NOW + i, "S", f"line {i} {'hit' if i % 3 == 0 else ''}") for i in range(10)], + ) + backend.setSearchMode("highlight") + backend.setSearch("hit") + backend._apply_search() + assert backend.model.rowCount() == 10 + assert backend.state["matchCount"] == 4 + assert backend.model.matcher.spans("a hit and hit") == [(2, 3), (10, 3)] + with qtbot.waitSignal(backend.reveal_row) as blocker: + backend.nextMatch() + assert blocker.args == [0] + assert not backend.follow + backend.prevMatch() + assert backend.state["matchPos"] == 4 + assert backend.model.current_row() == 9 + + +def test_pause_buffers_and_resume_flushes(backend): + add(backend, ("INFO", NOW, "S", "a")) + backend.togglePause() + add(backend, ("INFO", NOW + 1, "S", "b"), ("INFO", NOW + 2, "S", "c")) + assert backend.model.rowCount() == 1 + assert backend.state["pending"] == 2 + backend.togglePause() + assert backend.model.rowCount() == 3 + assert backend.state["pending"] == 0 + + +def test_follow_counts_new_rows_below(backend, qtbot): + add(backend, ("INFO", NOW, "S", "a")) + with qtbot.waitSignal(backend.scroll_to_end): + add(backend, ("INFO", NOW + 1, "S", "b")) + backend.setFollow(False) + add(backend, ("INFO", NOW + 2, "S", "c"), ("INFO", NOW + 3, "S", "d")) + assert backend.state["newBelow"] == 2 + backend.jumpToLatest() + assert backend.follow + assert backend.state["newBelow"] == 0 + + +def test_trim_keeps_counts_consistent(qtbot): + backend = LogPanelBackend(use_queue=False, max_entries=100) + backend.model.TRIM_CHUNK = 10 + add( + backend, + *[("INFO" if i % 2 else "WARNING", NOW + i, f"S{i % 3}", f"m{i}") for i in range(150)], + ) + model = backend.model + assert model.total == 100 + assert model.entries[0].summary == "m50" + assert sum(model.group_counts.values()) == 100 + assert sum(model.service_counts.values()) == 100 + assert model.rowCount() == 100 + backend.cleanup() + + +def test_expand_and_copy(backend, tmp_path): + add(backend, ("ERROR", NOW, "DeviceServer", TRACE), ("INFO", NOW + 1, "S", "single")) + backend.toggleExpanded(1) + assert not backend.model.expanded # single-line rows do not expand + backend.toggleExpanded(0) + model = backend.model + index = model.index(0, 0) + assert model.data(index, model.roleNames_ids()["expanded"]) is True + lines = model.data(index, model.roleNames_ids()["bodyLines"]) + assert lines[-1]["kind"] == "exception" + backend.copyRow(0) + assert "[ERROR] DeviceServer: Traceback" in QApplication.clipboard().text() + backend.copyMessage(1) + assert QApplication.clipboard().text() == "single" + path = tmp_path / "logs.log" + backend.export_to(path) + assert path.read_text().count("\n") >= 2 + + +def test_time_modes(): + assert common.format_relative(100, now=101) == "now" + assert common.format_relative(100, now=160) == "1 min ago" + assert common.format_relative(100, now=100 + 7300) == "2 h ago" + assert len(common.format_clock(time.time())) == len("12:00:00.000") + + +def test_queue_feeds_the_backend(qtbot, mocked_client, monkeypatch): + msg = LogMessage( + metadata={}, + log_type="warning", + log_msg={ + "text": "x", + "record": {"time": {"timestamp": NOW, "repr": ""}, "message": "from redis"}, + "service_name": "ScanServer", + }, + ) + monkeypatch.setattr(mocked_client.connector, "xread", lambda *_, **__: [{"data": msg}]) + backend = LogPanelBackend(use_queue=True) + assert [e.summary for e in visible(backend)] == ["from redis"] + queue = BecLogsQueue.instance() + queue._incoming.append(msg.model_copy(update={"log_type": "error"})) + queue._proc_update() + assert [e.level for e in visible(backend)] == ["WARNING", "ERROR"] + backend.cleanup() + assert BecLogsQueue._instance is None + + +@pytest.fixture(params=["qwidget", "qml"]) +def panel(request, qtbot, mocked_client): + cls = LogPanelQWidget if request.param == "qwidget" else LogPanelQml + backend = LogPanelBackend(use_queue=False) + backend.ingest(demo_entries(backend.model, 200)) + widget = create_widget(qtbot, cls, backend=backend) + backend.setParent(widget) + widget.resize(1000, 500) + yield widget + + +def test_panel_renders_and_follows_state(panel, qtbot): + backend = panel.backend + assert backend.model.rowCount() > 0 + backend.toggleGroup("info") + qtbot.wait(20) + assert all(e.group != "info" for e in visible(backend)) + rows = [r for r in range(backend.model.rowCount()) if backend.model.entry(r).line_count > 1] + backend.toggleExpanded(rows[0]) + qtbot.wait(20) + assert panel.grab().width() == 1000 + if isinstance(panel, LogPanelQWidget): + assert not panel.view.uniformRowHeights() + assert panel._chips["info"].isChecked() is False + backend.toggleExpanded(rows[0]) + assert panel.view.uniformRowHeights() + else: + assert panel.view.rootObject() is not None + assert not panel.view.errors() + + +def test_panel_search_highlight_and_jump(panel, qtbot): + backend = panel.backend + backend.setSearchMode("highlight") + backend.setSearch("samx") + backend._apply_search() + backend.nextMatch() + qtbot.wait(20) + assert backend.model.current_row() >= 0 + assert not backend.state["follow"] + backend.jumpToLatest() + qtbot.wait(20) + assert backend.state["follow"] + + +def test_qwidget_toolbar_drives_backend(qtbot, mocked_client): + backend = LogPanelBackend(use_queue=False) + add(backend, ("ERROR", NOW, "A", "a"), ("INFO", NOW + 1, "B", "b")) + panel = create_widget(qtbot, LogPanelQWidget, backend=backend) + backend.setParent(panel) + panel._chips["error"].click() + assert backend.model.groups == {"warning", "info", "debug"} + panel.mode_buttons["highlight"].click() + assert backend.state["searchMode"] == "highlight" + panel.regex_button.click() + assert backend.state["regex"] + panel.live_pill.clicked.emit() + assert backend.paused + assert panel.live_pill.text == "Paused" + panel._fill_service_menu() + assert len([a for a in panel.service_menu.actions() if a.isCheckable()]) == 2