mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-10-28 16:11:27 +01:00
feat(logpanel): QML and QWidget log panels with level chips, search highlight, folded repeats, expandable tracebacks and follow-tail
This commit is contained in:
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() }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
Reference in new issue
Block a user