merge: bring in queue-widget-qml-ojrchl for the system dock, queue keeps its own QML host

This commit is contained in:
Claude committed 2026-10-09 21:34:43 +00:00
commit 4acf8dd833
13 files changed
+2623 -338

No files matched your search

@@ -1,47 +1,41 @@
from __future__ import annotations
import json
import threading
from pathlib import Path
from bec_lib import messages
from bec_lib.endpoints import MessageEndpoints
from bec_lib.logger import bec_logger
from bec_qthemes import material_icon
from qtpy.QtCore import Property, Qt, Signal, Slot
from qtpy.QtGui import QColor
from qtpy.QtWidgets import QHeaderView, QLabel, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget
from qtpy.QtCore import Property, QUrl, Signal
from qtpy.QtWidgets import QVBoxLayout, QWidget
from bec_widgets.utils.bec_connector import ConnectionConfig
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.compact_popup import CompactPopupWidget
from bec_widgets.utils.toolbars.actions import WidgetAction
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
from bec_widgets.widgets.control.buttons.button_abort.button_abort import AbortButton
from bec_widgets.widgets.control.buttons.button_reset.button_reset import ResetButton
from bec_widgets.widgets.control.buttons.button_resume.button_resume import ResumeButton
from bec_widgets.widgets.control.buttons.stop_button.stop_button import StopButton
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.widgets.services.bec_queue.qml_host import create_quick_widget
from bec_widgets.widgets.services.bec_queue.queue_model import IDLE_STATUSES, QueueController
logger = bec_logger.logger
QML_FILE = Path(__file__).parent / "qml" / "BECQueue.qml"
class BECQueue(BECWidget, CompactPopupWidget):
"""
Widget to display the BEC queue.
Shows the running scan with its progress, the waiting scans (reorder, remove) and the most
recent finished scans, together with the queue state and Pause / Resume / Abort controls.
The view is written in QML and hosted in a ``QQuickWidget``; this class is the QWidget shell
that keeps the RPC, Designer and docking interface.
"""
PLUGIN = True
ICON_NAME = "edit_note"
status_colors = {
"STOPPED": "red",
"PENDING": "orange",
"IDLE": "gray",
"PAUSED": "yellow",
"DEFERRED_PAUSE": "lightyellow",
"RUNNING": "green",
"COMPLETED": "blue",
}
queue_busy = Signal(bool)
_history_ready = Signal(list)
def __init__(
self,
@@ -57,58 +51,39 @@ class BECQueue(BECWidget, CompactPopupWidget):
)
self.layout.setSpacing(0)
self.layout.setContentsMargins(0, 0, 0, 0)
self.get_bec_shortcuts()
self._toolbar_hidden = False
self._known_queue_ids: set[str] = set()
self._history_thread: threading.Thread | None = None
# Set up the toolbar
self.set_toolbar()
# Set up the table
self.table = QTableWidget(self)
# self.layout.addWidget(self.table)
self.table.setColumnCount(5)
self.table.setHorizontalHeaderLabels(
["Scan Number", "Scan Name", "Type", "Status", "Cancel"]
)
self.controller = QueueController(self)
self.controller.pause_requested.connect(self.pause_queue)
self.controller.resume_requested.connect(self.resume_queue)
self.controller.abort_requested.connect(self.abort_request)
self.controller.halt_requested.connect(self.halt_scan)
self.controller.remove_requested.connect(self.abort_request)
self.controller.move_requested.connect(self.move_request)
self.controller.clear_requested.connect(self.clear_queue)
self._history_ready.connect(self.controller.set_history)
header = self.table.horizontalHeader()
header.setSectionResizeMode(QHeaderView.Stretch)
self.addWidget(self.table)
self.view = self._create_view()
self.view.setMinimumSize(320, 160)
self.addWidget(self.view)
self.label = "BEC Queue"
self.tooltip = "BEC Queue status"
self.bec_dispatcher.connect_slot(self.update_queue, MessageEndpoints.scan_queue_status())
self.reset_content()
self.bec_dispatcher.connect_slot(self.on_scan_progress, MessageEndpoints.scan_progress())
if refresh_upon_start:
self.refresh_queue()
def set_toolbar(self):
"""
Set the toolbar.
"""
widget_label = QLabel(text="Live Queue", parent=self)
widget_label.setStyleSheet("font-weight: bold;")
self.toolbar = ModularToolBar(parent=self)
self.toolbar.components.add_safe("widget_label", WidgetAction(widget=widget_label))
bundle = ToolbarBundle("queue_label", self.toolbar.components)
bundle.add_action("widget_label")
self.toolbar.add_bundle(bundle)
def _create_view(self) -> QWidget:
"""Create the widget that renders the queue: here the QML view."""
return create_quick_widget(self, QML_FILE, {"queue": self.controller})
self.toolbar.add_action(
"resume", WidgetAction(widget=ResumeButton(parent=self, toolbar=True))
)
self.toolbar.add_action("stop", WidgetAction(widget=StopButton(parent=self, toolbar=True)))
self.toolbar.add_action(
"reset", WidgetAction(widget=ResetButton(parent=self, toolbar=True))
)
control_bundle = ToolbarBundle("control", self.toolbar.components)
control_bundle.add_action("resume")
control_bundle.add_action("stop")
control_bundle.add_action("reset")
self.toolbar.add_bundle(control_bundle)
self.toolbar.show_bundles(["queue_label", "control"])
self.addWidget(self.toolbar)
def _release_view(self):
"""Unload the QML tree before the controller it binds to is deleted."""
self.view.setSource(QUrl())
@Property(bool)
def hide_toolbar(self):
@@ -133,194 +108,134 @@ class BECQueue(BECWidget, CompactPopupWidget):
hide(bool): Whether to hide the toolbar.
"""
self._toolbar_hidden = hide
self.toolbar.setVisible(not hide)
self.controller.set_toolbar_visible(not hide)
def refresh_queue(self):
"""
Refresh the queue.
Refresh the queue and the recent history.
"""
msg = self.client.connector.get(MessageEndpoints.scan_queue_status())
self._fetch_history()
if msg is None:
# msg is None if no scan has been run yet (fresh start)
return
self.update_queue(msg.content, msg.metadata)
@Slot(dict, dict)
@SafeSlot(dict, dict)
def update_queue(self, content, _metadata):
"""
Update the queue table with the latest queue information.
Update the view with the latest queue information.
Args:
content (dict): The queue content.
_metadata (dict): The metadata.
"""
# only show the primary queue for now
queues = content.get("queue", {})
if not queues:
self.reset_content()
return
primary_queue: messages.ScanQueueStatus | None = queues.get("primary")
if not primary_queue:
self.reset_content()
return
queue_info = primary_queue.info
primary_queue: messages.ScanQueueStatus | None = content.get("queue", {}).get("primary")
self.controller.set_queue(primary_queue)
queue_info = primary_queue.info if primary_queue else []
self.table.setRowCount(len(queue_info))
self.table.clearContents()
queue_ids = {item.queue_id for item in queue_info}
if self._known_queue_ids - queue_ids:
# something left the queue: it is now in the history
self._fetch_history()
self._known_queue_ids = queue_ids
if not queue_info:
self.reset_content()
return
for index, item in enumerate(queue_info):
blocks = item.request_blocks
scan_types = []
scan_numbers = []
scan_names = []
user_metadatas = []
request_ids = []
status = item.status
for request_block in blocks:
scan_type = request_block.msg.scan_type
user_metadata = request_block.msg.metadata.get("user_metadata", {})
scan_name = user_metadata.get("scan_name", scan_type)
if scan_type:
scan_types.append(scan_type)
scan_number = request_block.scan_number
if scan_number:
scan_numbers.append(str(scan_number))
if scan_name:
scan_names.append(scan_name)
if user_metadata:
user_metadatas.append(user_metadata)
if request_block.RID:
request_ids.append(request_block.RID)
if scan_types:
scan_types = ", ".join(scan_types)
if scan_numbers:
scan_numbers = ", ".join(scan_numbers)
if scan_names:
scan_names = ", ".join(scan_names)
# Pretty print user metadata as tooltip
tooltip = ""
if user_metadatas:
if len(user_metadatas) == 1:
tooltip = json.dumps(user_metadatas[0], indent=2)
else:
tooltip = json.dumps(user_metadatas, indent=2)
self.set_row(
index,
scan_numbers,
scan_names,
scan_types,
status,
tooltip,
abort_request_id=request_ids[0] if request_ids else "",
)
busy = (
False
if all(item.status in ("STOPPED", "COMPLETED", "IDLE") for item in queue_info)
else True
)
busy = not all(item.status in IDLE_STATUSES for item in queue_info)
self.set_global_state("warning" if busy else "default")
self.queue_busy.emit(busy)
def format_item(self, content: str, status=False, tooltip: str = "") -> QTableWidgetItem:
@SafeSlot(dict, dict)
def on_scan_progress(self, content: dict, metadata: dict):
"""
Format the content of the table item.
Show the progress of the running scan.
Args:
content (str): The content to be formatted.
tooltip (str): Optional tooltip to display.
Returns:
QTableWidgetItem: The formatted item.
content (dict): ProgressMessage content.
metadata (dict): ProgressMessage metadata.
"""
if not content or not isinstance(content, str):
content = ""
item = QTableWidgetItem(content)
item.setTextAlignment(Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignVCenter)
if tooltip:
item.setToolTip(tooltip)
if status:
try:
color = self.status_colors.get(content, "black") # Default to black if not found
item.setForeground(QColor(color))
except Exception as e:
logger.warning(f"Could not apply status color for queue item '{content}': {e}")
return item
def set_row(
self,
index: int,
scan_number: str,
scan_name: str,
scan_type: str,
status: str,
tooltip: str = "",
abort_request_id: str = "",
):
"""
Set the row of the table.
Args:
index (int): The index of the row.
scan_number (str): The scan number.
scan_name (str): The scan name.
scan_type (str): The scan type.
status (str): The status.
tooltip (str): Optional tooltip to display (pretty-printed user metadata).
abort_request_id (str): request id to abort.
"""
abort_button = self._create_abort_button(abort_request_id)
abort_button.button.clicked.connect(self.delete_selected_row)
self.table.setItem(index, 0, self.format_item(scan_number, tooltip=tooltip))
self.table.setItem(index, 1, self.format_item(scan_name, tooltip=tooltip))
self.table.setItem(index, 2, self.format_item(scan_type, tooltip=tooltip))
self.table.setItem(index, 3, self.format_item(status, status=True, tooltip=tooltip))
self.table.setCellWidget(index, 4, abort_button)
def _create_abort_button(self, request_id: str) -> AbortButton:
"""
Create an abort button with styling for BEC Queue widget for certain request_id.
Args:
request_id(str): The request id to abort.
Returns:
AbortButton: The abort button.
"""
abort_button = AbortButton(parent=self, request_id=request_id)
abort_button.button.setText("")
abort_button.button.setIcon(
material_icon("cancel", color="#cc181e", filled=True, convert_to_pixmap=False)
self.controller.set_progress(
metadata.get("scan_id"),
content.get("value", 0),
content.get("max_value", 0),
content.get("done", False),
)
abort_button.setStyleSheet("""
QPushButton {
background-color: transparent;
border: none;
}
""")
return abort_button
def _fetch_history(self):
"""Read the queue history from Redis off the GUI thread."""
if self._history_thread is not None and self._history_thread.is_alive():
return
def delete_selected_row(self):
def worker():
try:
history = self.client.connector.lrange(MessageEndpoints.scan_queue_history(), 0, 9)
except Exception as exc: # pylint: disable=broad-except
logger.warning(f"Could not read the queue history: {exc}")
return
if isinstance(history, list):
self._history_ready.emit(
[m for m in history if isinstance(m, messages.ScanQueueHistoryMessage)]
)
button = self.sender()
row = self.table.indexAt(button.pos()).row()
self.table.removeRow(row)
button.close()
button.deleteLater()
self._history_thread = threading.Thread(target=worker, daemon=True)
self._history_thread.start()
def reset_content(self):
"""
Reset the content of the table.
################################################################################
# Requests to BEC
################################################################################
@SafeSlot()
def pause_queue(self):
"""Pause the queue: the running scan pauses at its next checkpoint, nothing new starts."""
self.client.connector.send(
MessageEndpoints.scan_queue_modification_request(),
messages.ScanQueueModificationMessage(
scan_id=None, action="deferred_pause", parameter={}
),
)
@SafeSlot()
def resume_queue(self):
"""Resume the queue."""
self.queue.request_scan_continuation()
@SafeSlot(str)
def abort_request(self, request_id: str):
"""
Abort a request: the running scan is aborted, a waiting one is removed from the queue.
self.table.setRowCount(1)
self.set_row(0, "", "", "", "", "")
Args:
request_id(str): The request to abort; empty aborts the current request.
"""
self.queue.request_scan_abortion(request_id=request_id or None)
@SafeSlot()
def halt_scan(self):
"""Halt the running scan without its cleanup routines."""
self.queue.request_scan_halt()
@SafeSlot(str, str)
def move_request(self, scan_id: str, action: str):
"""
Move a waiting scan within the queue.
Args:
scan_id(str): Scan id of the queue item.
action(str): "move_up", "move_down", "move_top" or "move_bottom".
"""
self.queue.request_queue_order_modification(scan_id=scan_id, action=action)
@SafeSlot()
def clear_queue(self):
"""Stop the running scan and remove every waiting scan."""
self.queue.request_queue_reset()
def cleanup(self):
"""Wait for a pending history read and release the view."""
if self._history_thread is not None:
self._history_thread.join(timeout=1)
self._release_view()
super().cleanup()
if __name__ == "__main__": # pragma: no cover