mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-11-06 09:43:24 +01:00
merge: bring in queue-widget-qml-ojrchl for the system dock, queue keeps its own QML host
This commit is contained in:
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
|
||||
|
||||
Reference in new issue
Block a user