diff --git a/.copier-answers.yml b/.copier-answers.yml
index f46a724..a77eca8 100644
--- a/.copier-answers.yml
+++ b/.copier-answers.yml
@@ -2,7 +2,7 @@
# It is needed to track the repo template version, and editing may break things.
# This file will be overwritten by copier on template updates.
-_commit: v1.4.1
+_commit: v1.5.1
_src_path: https://github.com/bec-project/plugin_copier_template.git
make_commit: false
project_name: debye_bec
diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml
index 9418659..85659ba 100644
--- a/.gitea/workflows/ci.yml
+++ b/.gitea/workflows/ci.yml
@@ -95,8 +95,19 @@ jobs:
uv pip install --system -e ./bec/bec_ipython_client
uv pip install --system -e ./bec/bec_server[dev]
uv pip install --system -e ./bec_widgets[dev,pyside6]
- uv pip install --system -e ./debye_bec
+ uv pip install --system -e ./debye_bec[dev]
- - name: Run Pytest with Coverage
+ - name: Run Tests with Coverage
id: coverage
- run: pytest --random-order --cov=./debye_bec --cov-config=./debye_bec/pyproject.toml --cov-branch --cov-report=xml --no-cov-on-fail ./debye_bec/tests/ || test $? -eq 5
+ shell: bash
+ run: |
+ set +e
+ coverage run --branch --source=./debye_bec -m pytest --random-order ./debye_bec/tests/
+ status=$?
+ # Allow pytest exit code 5 so repositories without tests do not fail CI.
+ if [ "$status" -ne 0 ] && [ "$status" -ne 5 ]; then
+ exit "$status"
+ fi
+ if [ "$status" -eq 0 ]; then
+ coverage report
+ fi
diff --git a/debye_bec/bec_widgets/widgets/client.py b/debye_bec/bec_widgets/widgets/client.py
index af97cf7..c974339 100644
--- a/debye_bec/bec_widgets/widgets/client.py
+++ b/debye_bec/bec_widgets/widgets/client.py
@@ -4,6 +4,7 @@
from __future__ import annotations
from bec_lib.logger import bec_logger
+
from bec_widgets.cli.rpc.rpc_base import RPCBase, rpc_call, rpc_timeout
logger = bec_logger.logger
@@ -14,6 +15,7 @@ logger = bec_logger.logger
_Widgets = {
"DataViewer": "DataViewer",
"DigitalTwin": "DigitalTwin",
+ "RestartServer": "RestartServer",
"ScanControlXAS": "ScanControlXAS",
}
@@ -66,7 +68,33 @@ class DigitalTwin(RPCBase):
"""
+class RestartServer(RPCBase):
+ """Main widget of server restart widget"""
+
+ _IMPORT_MODULE = "debye_bec.bec_widgets.widgets.restart_server.restart_server"
+
+ @rpc_call
+ def remove(self):
+ """
+ Cleanup the BECConnector
+ """
+
+ @rpc_call
+ def attach(self):
+ """
+ None
+ """
+
+ @rpc_call
+ def detach(self):
+ """
+ Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget.
+ """
+
+
class ScanControlXAS(RPCBase):
+ """Main widget of Scan Control XAS"""
+
_IMPORT_MODULE = "debye_bec.bec_widgets.widgets.scan_control_xas.scan_control_xas"
@rpc_call
diff --git a/debye_bec/bec_widgets/widgets/designer_plugins.py b/debye_bec/bec_widgets/widgets/designer_plugins.py
index b098ee3..77ddd10 100644
--- a/debye_bec/bec_widgets/widgets/designer_plugins.py
+++ b/debye_bec/bec_widgets/widgets/designer_plugins.py
@@ -7,10 +7,19 @@ from __future__ import annotations
designer_plugins = {
"DataViewer": ("debye_bec.bec_widgets.widgets.data_viewer.data_viewer", "DataViewer"),
"DigitalTwin": ("debye_bec.bec_widgets.widgets.digital_twin.digital_twin", "DigitalTwin"),
+ "RestartServer": (
+ "debye_bec.bec_widgets.widgets.restart_server.restart_server",
+ "RestartServer",
+ ),
"ScanControlXAS": (
"debye_bec.bec_widgets.widgets.scan_control_xas.scan_control_xas",
"ScanControlXAS",
),
}
-widget_icons = {"DataViewer": "find_in_page", "DigitalTwin": "lightbulb", "ScanControlXAS": "tune"}
+widget_icons = {
+ "DataViewer": "find_in_page",
+ "DigitalTwin": "lightbulb",
+ "RestartServer": "restart_alt",
+ "ScanControlXAS": "tune",
+}
diff --git a/debye_bec/bec_widgets/widgets/restart_server/__init__,py b/debye_bec/bec_widgets/widgets/restart_server/__init__,py
new file mode 100644
index 0000000..e69de29
diff --git a/debye_bec/bec_widgets/widgets/restart_server/register_restart_server.py b/debye_bec/bec_widgets/widgets/restart_server/register_restart_server.py
new file mode 100644
index 0000000..5ac2c87
--- /dev/null
+++ b/debye_bec/bec_widgets/widgets/restart_server/register_restart_server.py
@@ -0,0 +1,15 @@
+def main(): # pragma: no cover
+ from qtpy import PYSIDE6
+
+ if not PYSIDE6:
+ print("PYSIDE6 is not available in the environment. Cannot patch designer.")
+ return
+ from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
+
+ from .restart_server_plugin import RestartServerPlugin
+
+ QPyDesignerCustomWidgetCollection.addCustomWidget(RestartServerPlugin())
+
+
+if __name__ == "__main__": # pragma: no cover
+ main()
diff --git a/debye_bec/bec_widgets/widgets/restart_server/restart_server.py b/debye_bec/bec_widgets/widgets/restart_server/restart_server.py
new file mode 100644
index 0000000..c068137
--- /dev/null
+++ b/debye_bec/bec_widgets/widgets/restart_server/restart_server.py
@@ -0,0 +1,207 @@
+"""
+Restart Server: Custom BEC widget to restart server.
+"""
+
+import sys
+
+from bec_lib import bec_logger
+from bec_widgets.utils.bec_dispatcher import BECDispatcher
+from bec_widgets.utils.bec_widget import BECWidget
+from bec_widgets.utils.colors import apply_theme
+from bec_widgets.widgets.utility.logpanel.logpanel import LogPanel
+from PySide6QtAds import CDockWidget
+from qtpy.QtCore import QThread, QTimer, Signal
+
+# pylint: disable=E0611
+from qtpy.QtWidgets import QApplication, QDialog, QDialogButtonBox, QLabel, QVBoxLayout, QWidget
+
+logger = bec_logger.logger
+
+
+class RestartServer(BECWidget, QWidget):
+ """
+ Main widget of server restart widget.
+
+ This widget has no visible UI of its own - it only drives the confirmation/
+ progress dialog. The surrounding dock panel is hidden as soon as we can find
+ it, so the user only ever sees the dialog, never an empty "Restart Server" tab.
+ """
+
+ PLUGIN = True
+ ICON_NAME = "restart_alt"
+
+ def __init__(self, *arg, parent=None, **kwargs):
+ super().__init__(parent=parent, *arg, **kwargs)
+ self.get_bec_shortcuts()
+ self.dialog = None
+ self._worker = None
+ self._dock_widget = None
+ QTimer.singleShot(0, self._start)
+
+ def _start(self):
+ """Hide the containing dock panel, then show the confirmation dialog."""
+ self._dock_widget = self._find_containing_dock()
+ if self._dock_widget is not None:
+ self._dock_widget.toggleView(False)
+ self.prompt_restart()
+
+ def _find_containing_dock(self):
+ """Walk up the parent chain to find the wrapping CDockWidget, if any."""
+ widget = self.parentWidget()
+ while widget is not None:
+ if CDockWidget is not None and isinstance(widget, CDockWidget):
+ return widget
+ widget = widget.parentWidget()
+ return None
+
+ def prompt_restart(self):
+ """Prompt the user for confirmation, then restart the BEC server if confirmed."""
+ self.dialog = RestartConfirmationDialog(parent=self)
+ if self.dialog.exec_() == QDialog.Accepted:
+ self._start_restart()
+ else:
+ self._close_containing_dock()
+
+ def _start_restart(self):
+ """Kick off the restart on a background thread and show progress feedback."""
+ self.dialog.show_restarting()
+ self.dialog.finished.connect(self._close_containing_dock)
+
+ self._worker = _RestartWorker(self.client, parent=self)
+ self._worker.finished_ok.connect(self._on_restart_finished)
+ self._worker.failed.connect(self._on_restart_failed)
+ self._worker.finished.connect(self._worker.deleteLater)
+ self._worker.start()
+
+ def _on_restart_finished(self):
+ self.dialog.show_done()
+
+ def _on_restart_failed(self, error_msg: str):
+ self.dialog.show_error(error_msg)
+
+ def _close_containing_dock(self):
+ """Actually close (and typically delete) the dock panel this widget lives in.
+
+ Reuses the reference found in `_start()` rather than re-walking the
+ parent chain, since toggleView(False) doesn't reparent anything.
+ """
+ if self._dock_widget is not None:
+ self._dock_widget.closeDockWidget()
+ return
+
+ # Fallback: no dock wrapper found (e.g. standalone use)
+ self.close()
+
+
+class _RestartWorker(QThread):
+ """Runs the blocking server-restart call off the GUI thread."""
+
+ finished_ok = Signal()
+ failed = Signal(str)
+
+ def __init__(self, client, parent=None):
+ super().__init__(parent)
+ self._client = client
+
+ def run(self):
+ try:
+ self._client._request_server_restart()
+ except Exception as exc: # pylint: disable=broad-except
+ logger.exception("Failed to restart BEC server")
+ self.failed.emit(str(exc))
+ else:
+ self.finished_ok.emit()
+
+
+class RestartConfirmationDialog(QDialog):
+ """Dialog asking the user to confirm a restart of the BEC server, then shows progress
+ and waits for the user to acknowledge completion before closing."""
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self.setWindowTitle("Restart BEC Server")
+ self.setModal(True)
+
+ self.layout = QVBoxLayout(self)
+
+ self.message = QLabel(
+ "Are you sure you want to restart the BEC server?\n"
+ "This will interrupt any running scans!"
+ )
+ self.layout.addWidget(self.message)
+
+ self.button_box = QDialogButtonBox()
+ self.cancel_button = self.button_box.addButton("Cancel", QDialogButtonBox.RejectRole)
+ self.restart_button = self.button_box.addButton("Restart now", QDialogButtonBox.AcceptRole)
+
+ self.button_box.accepted.connect(self.accept)
+ self.button_box.rejected.connect(self.reject)
+
+ self.layout.addWidget(self.button_box)
+
+ # Log panel is created lazily, only once the restart actually begins, so
+ # opening/cancelling the confirmation dialog never touches the log stream.
+ self._log_panel: LogPanel | None = None
+
+ # Regardless of how the dialog ends (accept/reject/close), tear the log
+ # panel down. finished() fires reliably in all of those cases; it's a
+ # no-op if the log panel was never created.
+ self.finished.connect(self._teardown_log_panel)
+
+ def show_restarting(self):
+ """Switch the dialog into a non-blocking 'in progress' state, with a live
+ view of the server logs so the user can see what's happening.
+ """
+ self.message.setText("BEC server is restarting, please wait...")
+ self.button_box.hide()
+ self._setup_log_panel()
+ self.resize(750, 450)
+ self.show()
+
+ def _setup_log_panel(self):
+ """Embed the standard LogPanel widget"""
+ if self._log_panel is not None:
+ return
+ self._log_panel = LogPanel(parent=self, show_toolbar=False)
+ self.layout.removeWidget(self.button_box)
+ self.layout.addWidget(self._log_panel)
+ self.layout.addWidget(self.button_box)
+
+ def _teardown_log_panel(self, *_):
+ """Clean up the log panel so it stops receiving updates once this dialog
+ is gone. LogPanel.cleanup() detaches it from the shared log queue and
+ handles its own BECWidget/RPC teardown."""
+ if self._log_panel is None:
+ return
+ self._log_panel.cleanup()
+ self._log_panel = None
+
+ def show_done(self):
+ """Show a success state and require the user to explicitly close the dialog."""
+ self.message.setText("Server restarted successfully.")
+ self._show_close_button("Close")
+
+ def show_error(self, error_msg: str):
+ """Show a failure state and require the user to explicitly close the dialog."""
+ self.message.setText(f"Restart failed:\n{error_msg}")
+ self._show_close_button("Close")
+
+ def _show_close_button(self, label: str):
+ """Replace the button row with a single acknowledgement button.
+
+ Clicking it calls accept(), which emits the dialog's `finished` signal -
+ that's what RestartServer listens for to know it's safe to close the dock.
+ """
+ self.button_box.clear()
+ self.close_button = self.button_box.addButton(label, QDialogButtonBox.AcceptRole)
+ self.button_box.accepted.connect(self.accept)
+ self.button_box.show()
+
+
+if __name__ == "__main__":
+ app = QApplication(sys.argv)
+ apply_theme("light")
+ dispatcher = BECDispatcher(gui_id="restart_server")
+ win = RestartServer()
+ win.show()
+ sys.exit(app.exec_())
diff --git a/debye_bec/bec_widgets/widgets/restart_server/restart_server.pyproject b/debye_bec/bec_widgets/widgets/restart_server/restart_server.pyproject
new file mode 100644
index 0000000..a2ce968
--- /dev/null
+++ b/debye_bec/bec_widgets/widgets/restart_server/restart_server.pyproject
@@ -0,0 +1 @@
+{'files': ['restart_server.py']}
\ No newline at end of file
diff --git a/debye_bec/bec_widgets/widgets/restart_server/restart_server_plugin.py b/debye_bec/bec_widgets/widgets/restart_server/restart_server_plugin.py
new file mode 100644
index 0000000..230247b
--- /dev/null
+++ b/debye_bec/bec_widgets/widgets/restart_server/restart_server_plugin.py
@@ -0,0 +1,57 @@
+# Copyright (C) 2022 The Qt Company Ltd.
+# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
+
+from bec_widgets.utils.bec_designer import designer_material_icon
+from qtpy.QtDesigner import QDesignerCustomWidgetInterface
+from qtpy.QtWidgets import QWidget
+
+from .restart_server import RestartServer
+
+DOM_XML = """
+
+
+
+
+"""
+
+
+class RestartServerPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
+ def __init__(self):
+ super().__init__()
+ self._form_editor = None
+
+ def createWidget(self, parent):
+ if parent is None:
+ return QWidget()
+ t = RestartServer(parent)
+ return t
+
+ def domXml(self):
+ return DOM_XML
+
+ def group(self):
+ return ""
+
+ def icon(self):
+ return designer_material_icon(RestartServer.ICON_NAME)
+
+ def includeFile(self):
+ return "restart_server"
+
+ def initialize(self, form_editor):
+ self._form_editor = form_editor
+
+ def isContainer(self):
+ return False
+
+ def isInitialized(self):
+ return self._form_editor is not None
+
+ def name(self):
+ return "RestartServer"
+
+ def toolTip(self):
+ return "RestartServer"
+
+ def whatsThis(self):
+ return self.toolTip()
diff --git a/debye_bec/file_writer/storage_copy.py b/debye_bec/file_writer/storage_copy.py
new file mode 100644
index 0000000..fa76a5e
--- /dev/null
+++ b/debye_bec/file_writer/storage_copy.py
@@ -0,0 +1,76 @@
+"""
+Beamline storage-copy hook for debye_bec.
+
+Use this module to handle ``bec.beamline_storage_copy(...)`` requests with
+beamline-specific file transfer logic.
+
+The plugin_storage_copy function is commented out by default. Uncomment and implement it to handle
+beamline-specific storage copy operations. The function should accept the source file path,
+a scope identifier, the active BEC account, and an optional subdirectory for the destination. It should
+perform the necessary checks and copy the file to the appropriate location based on the provided scope.
+The subdirectory, if provided, has already been sanitized to ensure it is safe for use in file paths.
+"""
+
+# import os
+# import shutil
+
+# from bec_lib.logger import bec_logger
+
+# logger = bec_logger.logger
+
+
+# def plugin_storage_copy(
+# source_file: str, scope: str, active_account: str, subdir: str | None = None
+# ) -> None:
+# """
+# Run a beamline-defined storage copy operation.
+
+# Args:
+# source_file: Source file that should be copied.
+# scope: Beamline-defined copy scope identifier.
+# active_account: Active BEC account, if one is available.
+# subdir: Optional sanitized relative destination subdirectory.
+# """
+# supported_file_extensions = [
+# ".h5",
+# ".txt",
+# ".csv",
+# ".log",
+# ".json",
+# ".png",
+# ".jpg",
+# ".jpeg",
+# ".tiff",
+# ".yaml",
+# ".yml",
+# ]
+# if not any(source_file.endswith(ext) for ext in supported_file_extensions):
+# logger.error(
+# f"Unsupported file type for '{source_file}'. Supported extensions: "
+# f"{supported_file_extensions}"
+# )
+# return
+
+# # Example storage mapping. Replace this with beamline-specific scopes and paths.
+# storage_locations = {"alignment": f"/sls/x99sa/data/p12345/raw/bec/alignment/{active_account}/"}
+# if scope not in storage_locations:
+# logger.error(
+# f"Received unknown scope '{scope}'. Available scopes: {list(storage_locations.keys())}"
+# )
+# return
+
+# if not os.path.isfile(source_file):
+# logger.error(f"File '{source_file}' does not exist or is not reachable.")
+# return
+
+# destination_dir = storage_locations[scope]
+# if subdir:
+# destination_dir = os.path.join(destination_dir, subdir)
+# os.makedirs(destination_dir, exist_ok=True)
+# destination_file = os.path.join(destination_dir, os.path.basename(source_file))
+
+# try:
+# shutil.copy(source_file, destination_file)
+# logger.info(f"Copied '{source_file}' to '{destination_file}'")
+# except Exception as exc:
+# logger.error(f"Failed to copy '{source_file}' to '{destination_file}': {exc}")
diff --git a/pyproject.toml b/pyproject.toml
index 792d73b..59b6017 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -44,6 +44,9 @@ plugin_ds_startup = "debye_bec.deployments.device_server.startup:run"
[project.entry-points."bec.file_writer"]
plugin_file_writer = "debye_bec.file_writer"
+[project.entry-points."bec.file_writer.storage_copy"]
+plugin_storage_copy = "debye_bec.file_writer.storage_copy:plugin_storage_copy"
+
[project.entry-points."bec.scans"]
plugin_scans = "debye_bec.scans"