feat(logpanel): QML and QWidget log panels with level chips, search highlight, folded repeats, expandable tracebacks and follow-tail

This commit is contained in:
Claude committed 2026-10-09 22:16:06 +00:00
1 parent d2d7486930
commit 0e96ef15eb
6 files changed
+3414

No files matched your search

@@ -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()
@@ -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()
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -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() }
}
}
+310
View File
@@ -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