diff --git a/bec_widgets/applications/main_app.py b/bec_widgets/applications/main_app.py index 83fef461..c17d7239 100644 --- a/bec_widgets/applications/main_app.py +++ b/bec_widgets/applications/main_app.py @@ -162,14 +162,14 @@ class BECMainApp(BECMainWindow): return if flavor == "qml": from bec_widgets.applications.views.devices_views.devices_qml import ( - DeviceConfigViewQML as ConfigView, + DeviceConfigGateQML as ConfigView, ) from bec_widgets.applications.views.devices_views.devices_qml import ( DevicesViewQML as DevicesView, ) else: from bec_widgets.applications.views.devices_views.devices_qwidget import ( - DeviceConfigViewQWidget as ConfigView, + DeviceConfigGateQWidget as ConfigView, ) from bec_widgets.applications.views.devices_views.devices_qwidget import ( DevicesViewQWidget as DevicesView, @@ -178,7 +178,8 @@ class BECMainApp(BECMainWindow): self.devices_view.browser.open_in_workspace.connect(self._open_device_in_workspace) self.device_config_view = ConfigView(parent=self) self.add_view(icon="memory", title="Devices", widget=self.devices_view, mini_text="Devices") - # Adding and configuring devices is beamline staff work: it sits with Admin View. + # Adding and configuring devices is beamline staff work: it sits with Admin View and + # opens only after a BEC Atlas sign-in by an owner of this deployment. self.add_view( icon="tune", title="Device Config", diff --git a/bec_widgets/applications/views/devices_views/compare_devices_views.py b/bec_widgets/applications/views/devices_views/compare_devices_views.py index 14679f76..b231aaf2 100644 --- a/bec_widgets/applications/views/devices_views/compare_devices_views.py +++ b/bec_widgets/applications/views/devices_views/compare_devices_views.py @@ -271,6 +271,143 @@ def screenshots(app, client, out: Path, theme: str) -> None: _settle(app, 200) +class _DemoHelper: + """Config helper for the apply pictures: one device fails, one is still running.""" + + def __init__(self): + import threading # pylint: disable=import-outside-toplevel + + self.release = threading.Event() + + def send_config_request(self, action=None, config=None, **_kwargs): + """Pretend to send one request; ``samx`` waits for :attr:`release`.""" + name = next(iter(config or {}), "") + if name == "newmot": + raise RuntimeError("Cannot connect to X05LA-ES-PH:Z.VAL (no answer after 5 s)") + if name == "samx": + self.release.wait(10) + + +def popups(app, client, out: Path, theme: str) -> None: + """Every Config popup in both new views, the staff sign-in, and today's dialogs.""" + # pylint: disable=import-outside-toplevel, protected-access + from bec_qthemes import apply_theme + + apply_theme(theme) + out.mkdir(parents=True, exist_ok=True) + for view in ("qml", "qwidget"): + widget = build(view, "config", client) + widget.resize(1500, 900) + widget.show() + _settle(app, 700) + shot = lambda tag, w=widget: w.grab().save(str(out / f"{theme}_{view}_popup_{tag}.png")) + sheet = widget.open_add_device() + _settle(app, 400) + grab_sheet(widget, sheet).save(str(out / f"{theme}_{view}_popup_add.png")) + close_sheet(widget, sheet) + sheet = widget.open_clear_session() + _settle(app, 400) + grab_sheet(widget, sheet).save(str(out / f"{theme}_{view}_popup_clear.png")) + close_sheet(widget, sheet) + _settle(app, 200) + helper = _DemoHelper() + widget.editor._helper = helper + sheet = widget.open_review() + _settle(app, 300) + if view == "qml": + widget.view.rootObject().startApply() + else: + widget._start_apply(sheet) + _settle(app, 700) + grab_sheet(widget, sheet).save(str(out / f"{theme}_{view}_popup_applying.png")) + helper.release.set() + _settle(app, 700) + grab_sheet(widget, sheet).save(str(out / f"{theme}_{view}_popup_applied.png")) + close_sheet(widget, sheet) + _settle(app, 300) + widget.editor.remove("bpm3a") + _settle(app, 400) + shot("toast") + widget.close() + widget.deleteLater() + _settle(app, 200) + gate_cls = _gate_class(view) + gate = gate_cls(client=client) + gate.resize(1500, 900) + gate.show() + gate.access.deployment = "x05la" + gate.access.error = "User name or password is not correct." + gate.access.changed.emit() + _settle(app, 500) + gate.grab().save(str(out / f"{theme}_{view}_popup_signin.png")) + gate.close() + gate.deleteLater() + _settle(app, 200) + _legacy_popups(app, client, out, theme) + + +def _gate_class(view: str): + # pylint: disable=import-outside-toplevel + if view == "qml": + from bec_widgets.applications.views.devices_views.devices_qml import DeviceConfigGateQML + + return DeviceConfigGateQML + from bec_widgets.applications.views.devices_views.devices_qwidget import DeviceConfigGateQWidget + + return DeviceConfigGateQWidget + + +def _legacy_popups(app, client, out: Path, theme: str) -> None: + """Today's Device Manager dialogs, opened the way its toolbar opens them.""" + # pylint: disable=import-outside-toplevel, protected-access + from qtpy.QtWidgets import QMessageBox + + from bec_widgets.applications.views.device_manager_view.device_manager_dialogs.config_choice_dialog import ( + ConfigChoiceDialog, + ) + from bec_widgets.applications.views.device_manager_view.device_manager_dialogs.device_form_dialog import ( + DeviceFormDialog, + ) + from bec_widgets.applications.views.device_manager_view.device_manager_dialogs.upload_redis_dialog import ( + UploadRedisDialog, + ) + + legacy = build("legacy", "config", client) + legacy.resize(1500, 900) + legacy.show() + _settle(app, 900) + display = legacy.device_manager_display + dialogs = {} + add = DeviceFormDialog(parent=display, add_btn_text="Add Device") + dialogs["add"] = add + edit = DeviceFormDialog(parent=display, add_btn_text="Apply Changes") + samx = next(c for c in display.device_table_view.get_device_config() if c["name"] == "samx") + edit.set_device_config(samx) + dialogs["edit"] = edit + dialogs["upload"] = UploadRedisDialog( + parent=display, device_configs=display.device_table_view.get_validation_results() + ) + dialogs["load_choice"] = ConfigChoiceDialog(display) + flush = QMessageBox( + QMessageBox.Icon.Question, + "Flush BEC Server Config", + "Do you really want to flush the current config in BEC Server?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + display, + ) + dialogs["flush"] = flush + for tag, dialog in dialogs.items(): + dialog.show() + _settle(app, 500) + dialog.grab().save(str(out / f"{theme}_legacy_popup_{tag}.png")) + dialog.close() + dialog.deleteLater() + _settle(app, 150) + legacy.close() + legacy.deleteLater() + _settle(app, 200) + + def grab_sheet(widget, sheet): """Picture of a sheet: QWidget dialogs grab themselves, QML popups are inside the view.""" if hasattr(sheet, "grab"): @@ -350,6 +487,7 @@ def main() -> None: parser.add_argument("--theme", choices=["light", "dark"], default="dark") parser.add_argument("--screenshots", type=Path) parser.add_argument("--bench", action="store_true") + parser.add_argument("--popups", type=Path, help="write every popup picture to this folder") args = parser.parse_args() os.environ.setdefault("OPHYD_CONTROL_LAYER", "dummy") @@ -367,9 +505,12 @@ def main() -> None: if args.screenshots: for theme in ("light", "dark"): screenshots(app, client, args.screenshots, theme) + if args.popups: + for theme in ("light", "dark"): + popups(app, client, args.popups, theme) if args.bench: print(json.dumps(bench(app, client), indent=2)) - if args.screenshots or args.bench: + if args.screenshots or args.bench or args.popups: os._exit(0) # pylint: disable=protected-access widget = build(args.view, args.page, client) widget.resize(1500, 900) diff --git a/bec_widgets/applications/views/devices_views/devices_core.py b/bec_widgets/applications/views/devices_views/devices_core.py index 6b610f84..611773f2 100644 --- a/bec_widgets/applications/views/devices_views/devices_core.py +++ b/bec_widgets/applications/views/devices_views/devices_core.py @@ -1149,17 +1149,21 @@ class DeviceBrowser(QObject): facts = [("Type", info["deviceClass"]), ("Tags", ", ".join(info["tags"]) or "—")] if not info["enabled"]: facts.append(("Enabled", "No — disabled in the session")) + value = self.values.get(name) return { "name": name, "kind": kind, + "kindLabel": {"positioner": "Motor", "detector": "Detector"}.get(kind, "Monitor"), + "enabled": info["enabled"], + "subtitle": " · ".join(x for x in (info["deviceClass"], ", ".join(info["tags"])) if x), + "numeric": isinstance(value, (int, float)) and not isinstance(value, bool), "description": info["description"] or info["deviceClass"], "valueText": format_value(self.values.get(name), info["precision"]), "units": info["units"], "facts": [{"label": k, "value": v} for k, v in facts], - "actionText": { - "positioner": f"Add {name} to the workspace", - "detector": "Open the live image in the workspace", - }.get(kind, f"Plot {name} in the workspace"), + "actionText": {"positioner": "Add to workspace", "detector": "Open live image"}.get( + kind, "Plot in workspace" + ), } def signal_rows(self) -> list[dict]: diff --git a/bec_widgets/applications/views/devices_views/devices_qml.py b/bec_widgets/applications/views/devices_views/devices_qml.py index 9f8e0bc0..5f882203 100644 --- a/bec_widgets/applications/views/devices_views/devices_qml.py +++ b/bec_widgets/applications/views/devices_views/devices_qml.py @@ -9,7 +9,7 @@ from __future__ import annotations from pathlib import Path from qtpy.QtCore import Property, QObject, QUrl, Signal, Slot -from qtpy.QtWidgets import QFileDialog, QVBoxLayout, QWidget +from qtpy.QtWidgets import QFileDialog, QStackedWidget, QVBoxLayout, QWidget from bec_widgets.applications.views.devices_views.devices_core import ( DEVICE_CLASSES, @@ -22,6 +22,7 @@ from bec_widgets.applications.views.devices_views.devices_core import ( DeviceBrowser, class_docstring, ) +from bec_widgets.applications.views.devices_views.staff_access import StaffAccess from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.error_popups import SafeSlot from bec_widgets.utils.quick import DictListModel, create_quick_widget, release_quick_widget @@ -149,7 +150,11 @@ class DevicesBackend(QObject): detailName = Property(str, lambda self: self._detail.get("name", ""), notify=changed) kind = Property(str, lambda self: self.browser.kind, notify=changed) kinds = Property( - "QVariantList", lambda self: [{"key": k, "label": l} for k, l in KINDS], constant=True + "QVariantList", + lambda self: [ + {"key": k, "label": f"{l} {self.browser.kind_counts().get(k, 0)}"} for k, l in KINDS + ], + notify=changed, ) shownText = Property(str, lambda self: self.browser.shown_text(), notify=changed) selectedIndex = Property( @@ -236,6 +241,7 @@ class ConfigBackend(QObject): self._tab = "form" editor.changed.connect(self.refresh) editor.toast.connect(self.toast) + self._staff = "" def refresh(self) -> None: """Re-read everything the scene shows.""" @@ -359,6 +365,19 @@ class ConfigBackend(QObject): rows = Property(QObject, lambda self: self._rows, constant=True) bar = Property("QVariantMap", lambda self: self.editor.bar_state(), notify=changed) + staff_changed = Signal() + sign_out_requested = Signal() + staffUser = Property(str, lambda self: self._staff, notify=staff_changed) + + def set_staff(self, user: str) -> None: + """Who is signed in through BEC Atlas ("" when locked).""" + self._staff = user + self.staff_changed.emit() + + @Slot() + def signOut(self) -> None: # pylint: disable=invalid-name + self.sign_out_requested.emit() + detail = Property("QVariantMap", lambda self: self._detail, notify=detail_changed) facets = Property("QVariantList", lambda self: self._facets, notify=facets_changed) problems = Property("QVariantList", lambda self: self.editor.problems(), notify=changed) @@ -403,6 +422,7 @@ class DeviceConfigViewQML(BECWidget, QWidget): RPC = False PLUGIN = False + sign_out_requested = Signal() def __init__(self, parent=None, client=None, load_session: bool = True, **kwargs): super().__init__(parent=parent, client=client, **kwargs) @@ -418,6 +438,11 @@ class DeviceConfigViewQML(BECWidget, QWidget): self, QML_DIR / "DeviceConfigView.qml", {"backend": self.backend} ) lay.addWidget(self.view) + self.backend.sign_out_requested.connect(self.sign_out_requested) + + def set_staff(self, user: str) -> None: + """Show who is signed in (BEC Atlas) next to Review changes.""" + self.backend.set_staff(user) def open_review(self): """Open the review sheet (also reachable from the session bar).""" @@ -447,3 +472,74 @@ class DeviceConfigViewQML(BECWidget, QWidget): release_quick_widget(self.view) self.editor.cleanup() super().cleanup() + + +# ------------------------------------------------------------------------------------ staff gate +class StaffBackend(QObject): + """Bridge between :class:`StaffAccess` and ``LockScreen.qml``.""" + + changed = Signal() + + def __init__(self, access: StaffAccess, parent: QObject): + super().__init__(parent) + self.access = access + access.changed.connect(self.changed) + + # pylint: disable=invalid-name, missing-function-docstring + @Slot(str, str) + def signIn(self, user: str, password: str) -> None: + self.access.sign_in(user, password) + + busy = Property(bool, lambda self: self.access.busy, notify=changed) + error = Property(str, lambda self: self.access.error, notify=changed) + deployment = Property(str, lambda self: self.access.deployment, notify=changed) + + +class DeviceConfigGateQML(BECWidget, QWidget): + """Device Config behind the BEC Atlas staff sign-in (QML version). + + The Config view is built on the first sign-in and locks again when the view is left. + """ + + RPC = False + PLUGIN = False + + def __init__(self, parent=None, client=None, **kwargs): + super().__init__(parent=parent, client=client, **kwargs) + self.get_bec_shortcuts() + self.access = StaffAccess(self.bec_dispatcher, self) + self.staff_backend = StaffBackend(self.access, self) + self.config: DeviceConfigViewQML | None = None + self.stack = QStackedWidget(self) + self.lock_view = create_quick_widget( + self, QML_DIR / "LockScreen.qml", {"backend": self.staff_backend} + ) + self.stack.addWidget(self.lock_view) + lay = QVBoxLayout(self) + lay.setContentsMargins(0, 0, 0, 0) + lay.addWidget(self.stack) + self.access.changed.connect(self._sync) + + def _sync(self) -> None: + if self.access.unlocked and self.config is None: + self.config = DeviceConfigViewQML(parent=self, client=self.client) + self.config.sign_out_requested.connect(self.access.sign_out) + self.stack.addWidget(self.config) + if self.config is not None: + self.config.set_staff(self.access.user) + self.stack.setCurrentWidget( + self.config if self.access.unlocked and self.config else self.lock_view + ) + + def hideEvent(self, event): # pylint: disable=invalid-name + super().hideEvent(event) + if not event.spontaneous(): + self.access.sign_out() + + def cleanup(self): + release_quick_widget(self.lock_view) + self.access.cleanup() + if self.config is not None: + self.config.close() + self.config.deleteLater() + super().cleanup() diff --git a/bec_widgets/applications/views/devices_views/devices_qwidget.py b/bec_widgets/applications/views/devices_views/devices_qwidget.py index a68d0725..356d41fc 100644 --- a/bec_widgets/applications/views/devices_views/devices_qwidget.py +++ b/bec_widgets/applications/views/devices_views/devices_qwidget.py @@ -11,6 +11,7 @@ from qtpy.QtCore import QAbstractTableModel, QModelIndex, QRectF, QSize, Qt, QTi from qtpy.QtGui import QColor, QFont, QFontMetrics, QPainter, QPainterPath, QPen from qtpy.QtWidgets import ( QAbstractItemView, + QBoxLayout, QButtonGroup, QCheckBox, QComboBox, @@ -47,6 +48,7 @@ from bec_widgets.applications.views.devices_views.devices_core import ( DeviceBrowser, class_docstring, ) +from bec_widgets.applications.views.devices_views.staff_access import StaffAccess from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.error_popups import SafeSlot from bec_widgets.utils.quick.host import ThemeTokens @@ -146,6 +148,14 @@ def clear_layout(layout) -> None: clear_layout(item.layout()) +def vline() -> QFrame: + """One-pixel vertical rule.""" + line = QFrame() + line.setProperty("role", "rule") + line.setFixedWidth(1) + return line + + def hline() -> QFrame: """Thin separator line.""" line = QFrame() @@ -221,7 +231,7 @@ def view_qss(t: ThemeTokens) -> str: {t.border.name()}; border-radius: 7px; }} QFrame[role="toast"] {{ background: {t.fg.name()}; border-radius: 8px; }} QFrame[role="toast"] QLabel {{ color: {t.bg.name()}; font-size: 12px; }} - QFrame[role="toast"] QPushButton {{ color: {t.primary.lighter(140).name() if t.dark + QFrame[role="toast"] QPushButton {{ color: {t.primary.darker(130).name() if t.dark else t.primary.lighter(160).name()}; background: transparent; border: none; font-weight: 700; font-size: 12px; }} QMenu {{ background: {t.card.name()}; color: {t.fg.name()}; border: 1px solid @@ -324,6 +334,7 @@ class Toasts(QWidget): row.setContentsMargins(12, 8, 8, 8) text = label(message) text.setWordWrap(True) + text.setMinimumWidth(280) text.setMaximumWidth(380) row.addWidget(text, 1) if undo: @@ -650,11 +661,16 @@ class Sparkline(QWidget): def paintEvent(self, _event): # pylint: disable=invalid-name if len(self.points) < 2: return + tokens = ThemeTokens() painter = QPainter(self) painter.setRenderHint(QPainter.RenderHint.Antialiasing) lo, hi = min(self.points), max(self.points) span = (hi - lo) or 1.0 w, h = self.width(), self.height() + painter.setPen(QPen(tokens.soft(tokens.border, 0.7), 1, Qt.PenStyle.DashLine)) + for frac in (0.0, 0.5, 1.0): + y = 5 + frac * (h - 10) + painter.drawLine(0, int(y), w, int(y)) path = QPainterPath() for i, v in enumerate(self.points): x = i / 89 * w @@ -663,16 +679,112 @@ class Sparkline(QWidget): path.lineTo(x, y) else: path.moveTo(x, y) - painter.setPen(QPen(ThemeTokens().primary, 1.6)) + area = QPainterPath(path) + area.lineTo((len(self.points) - 1) / 89 * w, h) + area.lineTo(0, h) + area.closeSubpath() + fill = QColor(tokens.primary) + fill.setAlphaF(0.12) + painter.fillPath(area, fill) + painter.setPen(QPen(tokens.primary, 1.8)) painter.drawPath(path) + end = path.currentPosition() + painter.setBrush(tokens.primary) + painter.drawEllipse(end, 3, 3) + font = QFont() + font.setPixelSize(10) + painter.setFont(font) + painter.setPen(tokens.fg_subtle) + painter.drawText(QRectF(0, 0, w - 4, 14), Qt.AlignmentFlag.AlignRight, f"{hi:.6g}") + painter.drawText(QRectF(0, h - 14, w - 4, 14), Qt.AlignmentFlag.AlignRight, f"{lo:.6g}") # ----------------------------------------------------------------------------------- Devices view +class DeviceRowDelegate(QStyledItemDelegate): + """Two-line device row: bold name over what it is, live value on the right.""" + + def paint(self, painter, option, index): + row = index.data(Qt.ItemDataRole.UserRole) + if row is None: + return + tokens = ThemeTokens() + painter.save() + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + rect = option.rect + if option.state & QStyle.StateFlag.State_Selected: + painter.fillRect(rect, tokens.soft(tokens.primary, 0.18)) + painter.fillRect(rect.x(), rect.y(), 3, rect.height(), tokens.primary) + elif option.state & QStyle.StateFlag.State_MouseOver: + painter.fillRect(rect, tokens.hover) + painter.setPen(tokens.soft(tokens.border, 0.6)) + painter.drawLine(rect.bottomLeft(), rect.bottomRight()) + inner = QRectF(rect.adjusted(14, 6, -14, -6)) + value_font = QFont(MONO.split(",", maxsplit=1)[0]) + value_font.setPixelSize(12) + painter.setFont(value_font) + value_w = min(QFontMetrics(value_font).horizontalAdvance(row["valueText"]) + 4, 150) + painter.setPen(tokens.fg) + painter.drawText( + QRectF(inner.right() - value_w, inner.top(), value_w, inner.height()), + Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter, + row["valueText"], + ) + text_w = inner.width() - value_w - 12 + name_font = QFont(MONO.split(",", maxsplit=1)[0]) + name_font.setPixelSize(13) + name_font.setWeight(QFont.Weight.DemiBold) + painter.setFont(name_font) + half = inner.height() / 2 + painter.drawText( + QRectF(inner.left(), inner.top(), text_w, half), + Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, + QFontMetrics(name_font).elidedText(row["name"], Qt.TextElideMode.ElideRight, text_w), + ) + what_font = QFont() + what_font.setPixelSize(11) + painter.setFont(what_font) + painter.setPen(tokens.fg_muted) + painter.drawText( + QRectF(inner.left(), inner.top() + half, text_w, half), + Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, + QFontMetrics(what_font).elidedText(row["what"], Qt.TextElideMode.ElideRight, text_w), + ) + painter.restore() + + def sizeHint(self, option, index): # pylint: disable=invalid-name + return QSize(300, 48) + + +class Panel(QFrame): + """Bordered card with an upper-case title and an optional hint on the right.""" + + def __init__(self, title: str, hint: str = "", parent=None): + super().__init__(parent) + self.setProperty("role", "panel") + lay = QVBoxLayout(self) + lay.setContentsMargins(16, 12, 16, 14) + lay.setSpacing(8) + head = QHBoxLayout() + head.setSpacing(8) + self.title = label(title.upper(), "upper") + head.addWidget(self.title) + head.addStretch(1) + self.hint = label(hint, "faint") + head.addWidget(self.hint) + lay.addLayout(head) + self.body = lay + + class DevicesViewQWidget(BECWidget, QWidget): - """Devices for everyone: find a device, see its live value and move it (QWidget version).""" + """Devices for users: find any device, watch it live, move it and change its settings. + + QWidget version. The list on the left is compact; the device fills the rest of the view with + a live panel (motor card, value and trend) next to its settings and readings. + """ RPC = False PLUGIN = False + NARROW = 860 def __init__(self, parent=None, client=None, **kwargs): super().__init__(parent=parent, client=client, **kwargs) @@ -682,6 +794,8 @@ class DevicesViewQWidget(BECWidget, QWidget): self.browser = DeviceBrowser(self.client, self.bec_dispatcher, self) self.open_in_workspace = self.browser.open_in_workspace self._motor = None + self._signal_widgets: dict[str, dict] = {} + self._signal_device: str | None = None self._build() self.browser.changed.connect(self._sync) self.browser.values_changed.connect(self._sync_values) @@ -689,136 +803,240 @@ class DevicesViewQWidget(BECWidget, QWidget): self.apply_theme("") self._sync() + # ------------------------------------------------------------------ layout def _build(self) -> None: root = QVBoxLayout(self) root.setContentsMargins(0, 0, 0, 0) root.setSpacing(0) + root.addWidget(self._build_bar()) + body = QHBoxLayout() + body.setSpacing(0) + body.addWidget(self._build_list()) + body.addWidget(vline()) + body.addWidget(self._build_detail(), 1) + root.addLayout(body, 1) + + def _build_bar(self) -> QWidget: bar = QWidget() bar.setProperty("pane", "bar") bar.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True) row = QHBoxLayout(bar) - row.setContentsMargins(12, 8, 12, 8) + row.setContentsMargins(14, 8, 14, 8) row.setSpacing(10) self.title_icon = QLabel() row.addWidget(self.title_icon) title = label("Devices") title.setStyleSheet("font-weight: 700;") row.addWidget(title) - self.search = QLineEdit() - self.search.setPlaceholderText("Search devices by name, tag or description") - self.search.setAccessibleName("Search devices") - self.search.setFixedWidth(300) - self._debounce = QTimer(self, singleShot=True, interval=120) - self._debounce.timeout.connect(lambda: self.browser.set_query(self.search.text())) - self.search.textChanged.connect(lambda _: self._debounce.start()) - row.addWidget(self.search) - self.kinds = Segmented(KINDS, self.browser.kind) + row.addSpacing(8) + counts = self.browser.kind_counts() + self.kinds = Segmented( + [(k, f"{t} {counts.get(k, 0)}") for k, t in KINDS], self.browser.kind + ) self.kinds.changed.connect(self.browser.set_kind) row.addWidget(self.kinds) row.addStretch(1) lock = Chip("Device setup: staff only", "neutral", "lock") lock.setToolTip("Adding devices and changing the session is in Device Config") row.addWidget(lock) - root.addWidget(bar) + return bar - body = QHBoxLayout() - body.setSpacing(0) + def _build_list(self) -> QWidget: left = QWidget() left.setProperty("pane", "card") left.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True) - left_lay = QVBoxLayout(left) - left_lay.setContentsMargins(0, 0, 0, 0) - left_lay.setSpacing(0) - self.shown = label("", "muted") - self.shown.setContentsMargins(10, 6, 10, 6) - left_lay.addWidget(self.shown) - self.model = RowsModel([("name", "Name"), ("what", "What it is"), ("valueText", "Value")]) - self.table = make_table(self.model, {"name": 150, "valueText": 140}) + left.setFixedWidth(360) + lay = QVBoxLayout(left) + lay.setContentsMargins(0, 10, 0, 0) + lay.setSpacing(6) + self.search = QLineEdit() + self.search.setPlaceholderText("Search name, tag or description") + self.search.setAccessibleName("Search devices") + self.search.setClearButtonEnabled(True) + self._debounce = QTimer(self, singleShot=True, interval=120) + self._debounce.timeout.connect(lambda: self.browser.set_query(self.search.text())) + self.search.textChanged.connect(lambda _: self._debounce.start()) + search_row = QHBoxLayout() + search_row.setContentsMargins(12, 0, 12, 0) + search_row.addWidget(self.search) + lay.addLayout(search_row) + self.shown = label("", "faint") + self.shown.setContentsMargins(14, 2, 14, 2) + lay.addWidget(self.shown) + self.model = RowsModel([("name", "Device")]) + self.table = make_table(self.model, {}) + self.table.setItemDelegate(DeviceRowDelegate(self.table)) + self.table.horizontalHeader().hide() + self.table.verticalHeader().setDefaultSectionSize(48) + self.table.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) self.table.selectionModel().currentRowChanged.connect(self._on_row) - left_lay.addWidget(self.table, 1) - body.addWidget(left, 1) - body.addWidget(self._vline()) + lay.addWidget(self.table, 1) + return left + def _build_detail(self) -> QWidget: self.detail = QWidget() self.detail.setProperty("pane", "bg") self.detail.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True) - detail_scroll = QScrollArea() - detail_scroll.setWidgetResizable(True) - detail_scroll.setWidget(self.detail) - outer = QHBoxLayout(self.detail) - outer.setContentsMargins(16, 12, 16, 12) - column = QWidget() - column.setMaximumWidth(440) - column.setMinimumWidth(360) - outer.addStretch(1) - outer.addWidget(column, 3) - outer.addStretch(1) - d = QVBoxLayout(column) - d.setContentsMargins(0, 0, 0, 0) - d.setSpacing(8) - self._column = column - self.head = QWidget() - head = QVBoxLayout(self.head) - head.setContentsMargins(0, 0, 0, 0) - head.setSpacing(4) + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll.setFrameShape(QFrame.Shape.NoFrame) + scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + scroll.setWidget(self.detail) + d = QVBoxLayout(self.detail) + d.setContentsMargins(24, 18, 24, 18) + d.setSpacing(16) + + head = QHBoxLayout() + head.setSpacing(12) + names = QVBoxLayout() + names.setSpacing(2) + title_row = QHBoxLayout() + title_row.setSpacing(10) self.d_name = label("", "title") - self.d_name.setStyleSheet("font-size: 15px;") + self.d_name.setStyleSheet("font-size: 20px;") + title_row.addWidget(self.d_name) + self.d_kind = Chip("", "neutral") + title_row.addWidget(self.d_kind) + self.d_disabled = Chip("Disabled in this session", "warn", "block") + title_row.addWidget(self.d_disabled) + title_row.addStretch(1) + names.addLayout(title_row) self.d_desc = label("", "muted") self.d_desc.setWordWrap(True) - self.d_value = label("", "big") - self.spark = Sparkline() - self.spark_note = label("last ~20 s, live", "faint") - for w in (self.d_name, self.d_desc, self.d_value, self.spark, self.spark_note): - head.addWidget(w) - d.addWidget(self.head) - self.motor_holder = QVBoxLayout() - d.addLayout(self.motor_holder) - self.action = TextButton("", "neutral", "add") + names.addWidget(self.d_desc) + head.addLayout(names, 1) + self.action = TextButton("", "primary", "add") self.action.clicked.connect(self.browser.request_open) - d.addWidget(self.action, 0, Qt.AlignmentFlag.AlignLeft) - self._signal_widgets: dict[str, dict] = {} - self._signal_device: str | None = None - self.settings_title = label("Settings", "upper") - self.settings_grid = self._signal_grid() - self.readings_title = label("Readings", "upper") - self.readings_grid = self._signal_grid() - d.addSpacing(6) - d.addWidget(self.settings_title) - d.addLayout(self.settings_grid) - d.addSpacing(6) - d.addWidget(self.readings_title) - d.addLayout(self.readings_grid) - d.addSpacing(6) + head.addWidget(self.action, 0, Qt.AlignmentFlag.AlignTop) + d.addLayout(head) + + self.columns = QBoxLayout(QBoxLayout.Direction.LeftToRight) + self.columns.setSpacing(16) + left_col = QVBoxLayout() + left_col.setSpacing(16) + right_col = QVBoxLayout() + right_col.setSpacing(16) + self.columns.addLayout(left_col, 1) + self.columns.addLayout(right_col, 1) + d.addLayout(self.columns) + + self.motor_holder = QVBoxLayout() + left_col.addLayout(self.motor_holder) + self.live = Panel("Live value", "last ~20 s") + self.d_value = label("", "big") + self.live.body.addWidget(self.d_value) + self.spark = Sparkline() + self.spark.setMinimumHeight(120) + self.spark.setMaximumWidth(16777215) + self.live.body.addWidget(self.spark) + left_col.addWidget(self.live) + self.about = Panel("About") self.facts = QGridLayout() - self.facts.setHorizontalSpacing(12) - self.facts.setVerticalSpacing(3) + self.facts.setHorizontalSpacing(16) + self.facts.setVerticalSpacing(4) self.facts.setColumnStretch(1, 1) - d.addLayout(self.facts) + self.about.body.addLayout(self.facts) + left_col.addWidget(self.about) + left_col.addStretch(1) + + self.settings = Panel("Settings", "Applied to the device at once") + self.settings_grid = self._signal_grid() + self.settings.body.addLayout(self.settings_grid) + right_col.addWidget(self.settings) + self.readings = Panel("Readings", "live") + self.readings_grid = self._signal_grid() + self.readings.body.addLayout(self.readings_grid) + right_col.addWidget(self.readings) + right_col.addStretch(1) + self.empty = label("Select a device to see it here.", "muted") d.addWidget(self.empty) d.addStretch(1) - body.addWidget(detail_scroll, 1) - root.addLayout(body, 1) + return scroll - @staticmethod - def _vline() -> QFrame: - line = QFrame() - line.setProperty("role", "rule") - line.setFixedWidth(1) - return line - - def _on_row(self, current: QModelIndex, _previous: QModelIndex) -> None: - if current.isValid(): - self.browser.select(self.model.rows[current.row()]["name"]) + def resizeEvent(self, event): # pylint: disable=invalid-name + super().resizeEvent(event) + # Width left for the two columns: view minus list, rule and the detail margins. + narrow = self.width() - 361 - 48 < self.NARROW + self.columns.setDirection( + QBoxLayout.Direction.TopToBottom if narrow else QBoxLayout.Direction.LeftToRight + ) @staticmethod def _signal_grid() -> QGridLayout: grid = QGridLayout() grid.setHorizontalSpacing(10) - grid.setVerticalSpacing(4) + grid.setVerticalSpacing(6) grid.setColumnStretch(1, 1) return grid + # ------------------------------------------------------------------ syncing + def _on_row(self, current: QModelIndex, _previous: QModelIndex) -> None: + if current.isValid(): + self.browser.select(self.model.rows[current.row()]["name"]) + + @SafeSlot() + def _sync(self) -> None: + rows = self.browser.rows() + self.model.set_rows(rows) + select_row(self.table, self.model, self.browser.selected) + self.shown.setText(self.browser.shown_text()) + for key, btn in self.kinds.buttons.items(): + btn.setChecked(key == self.browser.kind) + self._sync_detail() + + @SafeSlot() + def _sync_values(self) -> None: + self.model.set_rows(self.browser.rows()) + detail = self.browser.detail() + if detail.get("name") and detail["kind"] != "positioner": + self.d_value.setText(self._value_html(detail)) + self.spark.setVisible(detail["numeric"]) + self.live.hint.setVisible(detail["numeric"]) + self._update_signals() + + @staticmethod + def _value_html(detail: dict) -> str: + unit = detail.get("units", "") + return f"{detail['valueText']} {unit}" + + @SafeSlot() + def _sync_spark(self) -> None: + self.spark.set_points(self.browser.spark_points()) + + def _sync_detail(self) -> None: + detail = self.browser.detail() + has = bool(detail.get("name")) + self.empty.setVisible(not has) + for w in (self.d_name, self.d_kind, self.d_desc, self.action, self.about): + w.setVisible(has) + clear_layout(self.facts) + if not has: + self.d_disabled.hide() + self.live.hide() + self.settings.hide() + self.readings.hide() + self._show_motor(None) + return + kind = detail["kind"] + self._show_motor(detail["name"] if kind == "positioner" else None) + self.d_name.setText(detail["name"]) + self.d_kind.set_chip(detail["kindLabel"], "neutral") + self.d_disabled.setVisible(not detail["enabled"]) + self.d_desc.setText(detail["description"]) + self.live.setVisible(kind != "positioner") + self.d_value.setText(self._value_html(detail)) + self.spark.setVisible(detail["numeric"]) + self.live.hint.setVisible(detail["numeric"]) + self.spark.set_points(self.browser.spark_points()) + for i, fact in enumerate(detail["facts"]): + self.facts.addWidget(label(fact["label"], "muted"), i, 0) + self.facts.addWidget(label(fact["value"]), i, 1) + self.action.setText(detail["actionText"]) + self.action._icon_name = {"detector": "image", "monitor": "show_chart"}.get(kind, "add") + self.action.refresh_theme(ThemeTokens()) + self._build_signals() + def _build_signals(self) -> None: """Lay out the settings and readings of the selected device (on selection change).""" clear_layout(self.settings_grid) @@ -843,7 +1061,7 @@ class DevicesViewQWidget(BECWidget, QWidget): edit = QLineEdit() edit.setPlaceholderText("New value") edit.setAccessibleName(f"New value for {row['key']}") - edit.setFixedWidth(110) + edit.setFixedWidth(120) button = TextButton("Set", "neutral") key = row["key"] submit = lambda _=False, k=key, e=edit: self.browser.set_signal(k, e.text()) @@ -857,8 +1075,8 @@ class DevicesViewQWidget(BECWidget, QWidget): grid.addWidget(status, r + 1, 0, 1, 4) widgets.update(edit=edit, status=status) self._signal_widgets[row["key"]] = widgets - self.settings_title.setVisible(counts["setting"] > 0) - self.readings_title.setVisible(counts["reading"] > 0) + self.settings.setVisible(counts["setting"] > 0) + self.readings.setVisible(counts["reading"] > 0) def _update_signals(self) -> None: """Refresh values and set results in place, keeping typed text and focus.""" @@ -883,69 +1101,6 @@ class DevicesViewQWidget(BECWidget, QWidget): if row["statusTone"] == "ok" and widgets["edit"].text(): widgets["edit"].clear() - @SafeSlot() - def _sync(self) -> None: - rows = self.browser.rows() - if self.browser.selected is None and rows: - self.browser.selected = rows[0]["name"] - rows = self.browser.rows() - self.model.set_rows(rows) - select_row(self.table, self.model, self.browser.selected) - self.shown.setText(self.browser.shown_text()) - for key, btn in self.kinds.buttons.items(): - btn.setChecked(key == self.browser.kind) - self._sync_detail() - - @SafeSlot() - def _sync_values(self) -> None: - self.model.set_rows(self.browser.rows()) - detail = self.browser.detail() - if detail.get("name") and detail["kind"] != "positioner": - self.d_value.setText(self._value_html(detail)) - self._update_signals() - - @staticmethod - def _value_html(detail: dict) -> str: - unit = detail.get("units", "") - return f"{detail['valueText']} {unit}" - - @SafeSlot() - def _sync_spark(self) -> None: - self.spark.set_points(self.browser.spark_points()) - - def _sync_detail(self) -> None: - detail = self.browser.detail() - has = bool(detail.get("name")) - self.empty.setVisible(not has) - self.head.setVisible(has) - self.action.setVisible(has) - clear_layout(self.facts) - if not has: - self._show_motor(None) - clear_layout(self.settings_grid) - clear_layout(self.readings_grid) - self.settings_title.hide() - self.readings_title.hide() - return - kind = detail["kind"] - self._show_motor(detail["name"] if kind == "positioner" else None) - self.d_name.setText(detail["name"]) - self.d_desc.setText(detail["description"]) - self.d_name.setVisible(kind != "positioner") - monitor = kind == "monitor" - self.d_value.setVisible(monitor) - self.spark.setVisible(monitor) - self.spark_note.setVisible(monitor) - self.d_value.setText(self._value_html(detail)) - self.spark.set_points(self.browser.spark_points()) - for i, fact in enumerate(detail["facts"]): - self.facts.addWidget(label(fact["label"], "muted"), i, 0) - self.facts.addWidget(label(fact["value"]), i, 1) - self.action.setText(detail["actionText"]) - self.action._icon_name = {"detector": "image", "monitor": "show_chart"}.get(kind, "add") - self.action.set_variant("primary" if kind == "detector" else "neutral") - self._build_signals() - def _show_motor(self, name: str | None) -> None: if name is None: if self._motor is not None: @@ -956,7 +1111,7 @@ class DevicesViewQWidget(BECWidget, QWidget): PositionerBoxQWidget, ) - self._motor = PositionerBoxQWidget(parent=self._column, device=name, client=self.client) + self._motor = PositionerBoxQWidget(parent=self.detail, device=name, client=self.client) self._motor.hide_device_selection = True self._motor.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Maximum) self.motor_holder.addWidget(self._motor) @@ -967,7 +1122,11 @@ class DevicesViewQWidget(BECWidget, QWidget): @SafeSlot(str) def apply_theme(self, theme: str): tokens = ThemeTokens() - self.setStyleSheet(view_qss(tokens)) + self.setStyleSheet(view_qss(tokens) + f""" + QFrame[role="panel"] {{ background: {tokens.card.name()}; + border: 1px solid {tokens.border.name()}; border-radius: 8px; }} + QFrame[role="panel"] QLabel {{ border: none; }} + """) self.title_icon.setPixmap(material_icon("memory", size=(18, 18), color=tokens.fg_muted)) retheme_icons(self, tokens) self.update() @@ -986,6 +1145,7 @@ class DeviceConfigViewQWidget(BECWidget, QWidget): RPC = False PLUGIN = False + sign_out_requested = Signal() def __init__(self, parent=None, client=None, load_session: bool = True, **kwargs): super().__init__(parent=parent, client=client, **kwargs) @@ -1020,7 +1180,7 @@ class DeviceConfigViewQWidget(BECWidget, QWidget): body = QHBoxLayout() body.setSpacing(0) body.addWidget(self._build_filters()) - body.addWidget(DevicesViewQWidget._vline()) + body.addWidget(vline()) center = QVBoxLayout() center.setSpacing(0) center.addWidget(self._build_table(), 1) @@ -1029,7 +1189,7 @@ class DeviceConfigViewQWidget(BECWidget, QWidget): center_w = QWidget() center_w.setLayout(center) body.addWidget(center_w, 1) - body.addWidget(DevicesViewQWidget._vline()) + body.addWidget(vline()) body.addWidget(self._build_inspector()) root.addLayout(body, 1) @@ -1062,6 +1222,14 @@ class DeviceConfigViewQWidget(BECWidget, QWidget): self.change_chip = Chip() row.addWidget(self.change_chip) row.addStretch(1) + self.staff_label = label("", "muted") + self.staff_label.hide() + row.addWidget(self.staff_label) + self.sign_out_btn = tool("Sign out", "logout", "Lock Device Config again") + self.sign_out_btn.clicked.connect(self.sign_out_requested) + self.sign_out_btn.hide() + row.addWidget(self.sign_out_btn) + row.addSpacing(6) self.review_btn = TextButton("Review changes", "primary", "difference") self.review_btn.clicked.connect(self.open_review) row.addWidget(self.review_btn) @@ -1688,6 +1856,12 @@ class DeviceConfigViewQWidget(BECWidget, QWidget): return sheet @SafeSlot() + def set_staff(self, user: str) -> None: + """Show who is signed in (BEC Atlas) next to Review changes.""" + self.staff_label.setText(f"Signed in as {user}" if user else "") + self.staff_label.setVisible(bool(user)) + self.sign_out_btn.setVisible(bool(user)) + def open_review(self) -> Sheet: """Sheet listing every difference to the running session, with Apply.""" sheet = Sheet( @@ -1885,3 +2059,140 @@ class DeviceConfigViewQWidget(BECWidget, QWidget): if self._sheet is not None: self._sheet.close() super().cleanup() + + +# ------------------------------------------------------------------------------------ staff gate +class DeviceConfigGateQWidget(BECWidget, QWidget): + """Device Config behind the BEC Atlas staff sign-in (QWidget version). + + The Config view is built on the first sign-in and locks again when the view is left. + """ + + RPC = False + PLUGIN = False + + def __init__(self, parent=None, client=None, **kwargs): + super().__init__(parent=parent, client=client, **kwargs) + self.get_bec_shortcuts() + self.setObjectName("ConfigRoot") + self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True) + self.access = StaffAccess(self.bec_dispatcher, self) + self.config: DeviceConfigViewQWidget | None = None + self.stack = QStackedWidget(self) + self.stack.addWidget(self._build_lock()) + lay = QVBoxLayout(self) + lay.setContentsMargins(0, 0, 0, 0) + lay.addWidget(self.stack) + self.access.changed.connect(self._sync) + self.apply_theme("") + self._sync() + + def _build_lock(self) -> QWidget: + page = QWidget() + outer = QVBoxLayout(page) + outer.addStretch(1) + row = QHBoxLayout() + row.addStretch(1) + card = QFrame() + card.setProperty("role", "panel") + card.setFixedWidth(420) + form = QVBoxLayout(card) + form.setContentsMargins(24, 24, 24, 24) + form.setSpacing(12) + self.lock_icon = QLabel() + self.lock_icon.setFixedSize(44, 44) + self.lock_icon.setAlignment(Qt.AlignmentFlag.AlignCenter) + form.addWidget(self.lock_icon) + title = label("Device Config is for beamline staff") + title.setStyleSheet("font-size: 17px; font-weight: 700;") + form.addWidget(title) + text = label( + "Sign in with your BEC Atlas account to add, remove and configure devices. " + "Everyone can watch and operate devices in Devices.", + "muted", + ) + text.setWordWrap(True) + form.addWidget(text) + form.addSpacing(4) + form.addWidget(label("User name", "fieldlabel")) + self.user = QLineEdit() + self.user.setPlaceholderText("e.g. e12345") + self.user.setAccessibleName("User name") + form.addWidget(self.user) + form.addWidget(label("Password", "fieldlabel")) + self.password = QLineEdit() + self.password.setEchoMode(QLineEdit.EchoMode.Password) + self.password.setAccessibleName("Password") + form.addWidget(self.password) + self.error = label("", "err") + self.error.setWordWrap(True) + form.addWidget(self.error) + foot = QHBoxLayout() + self.deployment = label("", "faint") + foot.addWidget(self.deployment, 1) + self.sign_in = TextButton("Sign in", "primary", "login") + foot.addWidget(self.sign_in) + form.addLayout(foot) + self.user.returnPressed.connect(self.password.setFocus) + self.password.returnPressed.connect(self._submit) + self.sign_in.clicked.connect(self._submit) + row.addWidget(card) + row.addStretch(1) + outer.addLayout(row) + outer.addStretch(1) + self.lock_page = page + return page + + def _submit(self) -> None: + self.access.sign_in(self.user.text(), self.password.text()) + + @SafeSlot() + def _sync(self) -> None: + busy = self.access.busy + self.user.setEnabled(not busy) + self.password.setEnabled(not busy) + self.sign_in.setEnabled(not busy) + self.sign_in.setText("Signing in…" if busy else "Sign in") + self.error.setText(self.access.error) + self.error.setVisible(bool(self.access.error)) + self.deployment.setText( + f"Deployment {self.access.deployment}" if self.access.deployment else "" + ) + if self.access.unlocked and self.config is None: + self.config = DeviceConfigViewQWidget(parent=self, client=self.client) + self.config.sign_out_requested.connect(self.access.sign_out) + self.stack.addWidget(self.config) + if self.config is not None: + self.config.set_staff(self.access.user) + if self.access.unlocked and self.config is not None: + self.password.clear() + self.stack.setCurrentWidget(self.config) + else: + self.stack.setCurrentWidget(self.lock_page) + + def hideEvent(self, event): # pylint: disable=invalid-name + super().hideEvent(event) + if not event.spontaneous(): + self.access.sign_out() + + @SafeSlot(str) + def apply_theme(self, theme: str): + tokens = ThemeTokens() + self.setStyleSheet(view_qss(tokens) + f""" + QFrame[role="panel"] {{ background: {tokens.card.name()}; + border: 1px solid {tokens.border.name()}; border-radius: 10px; }} + QFrame[role="panel"] QLabel {{ border: none; }} + QLabel[role="fieldlabel"] {{ font-size: 12px; font-weight: 600; }} + """) + self.lock_icon.setStyleSheet( + f"background: {rgba(tokens.primary, 0.14)}; border-radius: 22px; border: none;" + ) + self.lock_icon.setPixmap(material_icon("lock", size=(22, 22), color=tokens.primary)) + retheme_icons(self, tokens) + + def cleanup(self): + self.access.cleanup() + if self.config is not None: + self.config.close() + self.config.deleteLater() + super().cleanup() diff --git a/bec_widgets/applications/views/devices_views/qml/DeviceConfigView.qml b/bec_widgets/applications/views/devices_views/qml/DeviceConfigView.qml index 25f11834..2afdd774 100644 --- a/bec_widgets/applications/views/devices_views/qml/DeviceConfigView.qml +++ b/bec_widgets/applications/views/devices_views/qml/DeviceConfigView.qml @@ -17,6 +17,7 @@ Rectangle { function openReview() { addSheet.close(); clearSheet.close(); reviewSheet.applyMode = false; reviewSheet.open() } function openAdd() { addSheet.open() } function openClear() { clearSheet.open() } + function startApply() { if (root.backend.apply()) reviewSheet.applyMode = true } function closeSheets() { reviewSheet.close(); addSheet.close(); clearSheet.close() } color: theme.bg @@ -77,6 +78,19 @@ Rectangle { iconName: root.bar.changeCount > 0 ? "" : "check" } Item { Layout.fillWidth: true } + Text { + visible: root.backend.staffUser !== "" + text: "Signed in as " + root.backend.staffUser + color: theme.fgMuted + font.pixelSize: 12 + } + GhostButton { + visible: root.backend.staffUser !== "" + text: "Sign out" + iconName: "logout" + tip: "Lock Device Config again" + onClicked: root.backend.signOut() + } TextButton { text: root.bar.reviewText variant: "primary" @@ -671,7 +685,7 @@ Rectangle { required property string message required property bool undo required property int index - width: Math.min(440, toastRow.implicitWidth + 24) + width: Math.min(460, toastRow.implicitWidth + 28) height: toastRow.implicitHeight + 16 radius: 8 color: theme.fg @@ -680,11 +694,11 @@ Rectangle { anchors.fill: parent anchors.margins: 8 anchors.leftMargin: 12 - Text { text: toast.message; color: theme.bg; font.pixelSize: 12; wrapMode: Text.WordWrap; Layout.maximumWidth: 380 } + Text { text: toast.message; color: theme.bg; font.pixelSize: 12; wrapMode: Text.WordWrap; Layout.maximumWidth: 380; Layout.rightMargin: 8 } Text { visible: toast.undo text: "Undo" - color: Qt.lighter(theme.primary, theme.dark ? 1.4 : 1.6) + color: theme.dark ? Qt.darker(theme.primary, 1.3) : Qt.lighter(theme.primary, 1.6) font.pixelSize: 12 font.weight: Font.Bold TapHandler { onTapped: { root.backend.undo(); toastModel.remove(toast.index) } } @@ -814,7 +828,7 @@ Rectangle { text: reviewSheet.review.applyText variant: "primary" enabled: root.backend.canApply - onClicked: if (root.backend.apply()) reviewSheet.applyMode = true + onClicked: root.startApply() }, TextButton { visible: reviewSheet.applyMode diff --git a/bec_widgets/applications/views/devices_views/qml/DevicesView.qml b/bec_widgets/applications/views/devices_views/qml/DevicesView.qml index 13af825b..3c059332 100644 --- a/bec_widgets/applications/views/devices_views/qml/DevicesView.qml +++ b/bec_widgets/applications/views/devices_views/qml/DevicesView.qml @@ -4,21 +4,103 @@ import QtQuick.Layouts import BecUi import "Tones.js" as Tones -// Devices for everyone: find a device by kind and text, see it live, move motors. -// backend: DevicesBackend. +// Devices for users: find any device, watch it live, move it and change its settings. +// A compact list on the left; the device fills the rest with a live panel next to its +// settings and readings. backend: DevicesBackend. Rectangle { id: root required property QtObject backend readonly property var d: backend.detail - readonly property string mono: "JetBrains Mono, Menlo, DejaVu Sans Mono" + readonly property bool hasDevice: backend.detailName !== "" + readonly property string mono: "DejaVu Sans Mono" color: theme.bg + component Panel: Rectangle { + id: panel + property string title: "" + property string hint: "" + default property alias content: body.data + Layout.fillWidth: true + implicitHeight: body.implicitHeight + 28 + radius: 8 + color: theme.card + border.color: theme.border + ColumnLayout { + id: body + anchors { left: parent.left; right: parent.right; top: parent.top; margins: 16; topMargin: 12 } + spacing: 8 + RowLayout { + Layout.fillWidth: true + SectionTitle { text: panel.title; Layout.topMargin: 0 } + Item { Layout.fillWidth: true } + Text { text: panel.hint; color: theme.fgSubtle; font.pixelSize: 11 } + } + } + } + + component SignalRow: ColumnLayout { + id: sig + required property string key + required property bool settable + required property string doc + required property string valueText + required property string statusText + required property string statusTone + Layout.fillWidth: true + spacing: 2 + RowLayout { + Layout.fillWidth: true + spacing: 10 + Text { + text: sig.key + color: theme.fg + font.family: root.mono + font.pixelSize: 12 + Layout.preferredWidth: 140 + elide: Text.ElideRight + HoverHandler { id: keyHover } + ToolTip.visible: keyHover.hovered && sig.doc !== "" + ToolTip.text: sig.doc + } + Text { + text: sig.valueText + color: theme.fg + font.family: root.mono + font.pixelSize: 12 + Layout.fillWidth: true + elide: Text.ElideRight + } + InputField { + id: newValue + visible: sig.settable + Layout.preferredWidth: 120 + placeholderText: "New value" + Accessible.name: "New value for " + sig.key + onAccepted: root.backend.setSignal(sig.key, text) + } + TextButton { + visible: sig.settable + text: "Set" + onClicked: root.backend.setSignal(sig.key, newValue.text) + } + } + Text { + visible: sig.statusText !== "" + text: sig.statusText + color: Tones.color(theme, sig.statusTone) + font.pixelSize: 12 + wrapMode: Text.WordWrap + Layout.fillWidth: true + onTextChanged: if (sig.statusTone === "ok") newValue.clear() + } + } + ColumnLayout { anchors.fill: parent spacing: 0 - // ---- toolbar ------------------------------------------------------------------------- + // ---- top bar --------------------------------------------------------------------------- Rectangle { Layout.fillWidth: true implicitHeight: 46 @@ -26,19 +108,12 @@ Rectangle { Rectangle { anchors.bottom: parent.bottom; width: parent.width; height: 1; color: theme.border } RowLayout { anchors.fill: parent - anchors.leftMargin: 12 - anchors.rightMargin: 12 + anchors.leftMargin: 14 + anchors.rightMargin: 14 spacing: 10 Icon { name: "memory"; size: 18; color: theme.fgMuted } Text { text: "Devices"; color: theme.fg; font.pixelSize: 13; font.weight: Font.Bold } - InputField { - id: search - Layout.preferredWidth: 300 - placeholderText: "Search devices by name, tag or description" - Accessible.name: "Search devices" - onTextChanged: debounce.restart() - Timer { id: debounce; interval: 120; onTriggered: root.backend.setQuery(search.text) } - } + Item { implicitWidth: 8 } Segmented { model: root.backend.kinds current: root.backend.kind @@ -62,35 +137,28 @@ Rectangle { // ---- device list ------------------------------------------------------------------- Rectangle { - Layout.fillWidth: true - Layout.preferredWidth: 1 + Layout.preferredWidth: 360 Layout.fillHeight: true color: theme.card ColumnLayout { anchors.fill: parent - spacing: 0 + anchors.topMargin: 10 + spacing: 6 + InputField { + id: search + Layout.fillWidth: true + Layout.leftMargin: 12 + Layout.rightMargin: 12 + placeholderText: "Search name, tag or description" + Accessible.name: "Search devices" + onTextChanged: debounce.restart() + Timer { id: debounce; interval: 120; onTriggered: root.backend.setQuery(search.text) } + } Text { text: root.backend.shownText - color: theme.fgMuted - font.pixelSize: 12 - Layout.margins: 10 - Layout.topMargin: 6 - Layout.bottomMargin: 6 - } - Rectangle { - Layout.fillWidth: true - implicitHeight: 26 - color: theme.card - Rectangle { anchors.bottom: parent.bottom; width: parent.width; height: 1; color: theme.border } - RowLayout { - anchors.fill: parent - anchors.leftMargin: 8 - anchors.rightMargin: 16 - spacing: 0 - Text { text: "Name"; color: theme.fgMuted; font.pixelSize: 11; font.weight: Font.Bold; Layout.preferredWidth: 150 } - Text { text: "What it is"; color: theme.fgMuted; font.pixelSize: 11; font.weight: Font.Bold; Layout.fillWidth: true } - Text { text: "Value"; color: theme.fgMuted; font.pixelSize: 11; font.weight: Font.Bold; Layout.preferredWidth: 140; horizontalAlignment: Text.AlignRight } - } + color: theme.fgSubtle + font.pixelSize: 11 + Layout.leftMargin: 14 } ListView { id: list @@ -117,20 +185,25 @@ Rectangle { required property bool selected readonly property string rowName: name width: ListView.view.width - height: 32 + height: 48 color: selected ? Qt.rgba(theme.primary.r, theme.primary.g, theme.primary.b, 0.18) : hover.hovered ? theme.hover : "transparent" + Rectangle { visible: rowItem.selected; width: 3; height: parent.height; color: theme.primary } Rectangle { anchors.bottom: parent.bottom; width: parent.width; height: 1; color: theme.border; opacity: 0.6 } HoverHandler { id: hover } TapHandler { onTapped: { list.currentIndex = rowItem.index; root.backend.select(rowItem.name) } } RowLayout { anchors.fill: parent - anchors.leftMargin: 8 - anchors.rightMargin: 16 - spacing: 0 - Text { text: rowItem.name; color: theme.fg; font.family: root.mono; font.pixelSize: 12; font.weight: Font.DemiBold; Layout.preferredWidth: 150; elide: Text.ElideRight } - Text { text: rowItem.what; color: theme.fgMuted; font.pixelSize: 12; Layout.fillWidth: true; elide: Text.ElideRight } - Text { text: rowItem.valueText; color: theme.fg; font.family: root.mono; font.pixelSize: 12; Layout.preferredWidth: 140; horizontalAlignment: Text.AlignRight } + anchors.leftMargin: 14 + anchors.rightMargin: 14 + spacing: 12 + ColumnLayout { + Layout.fillWidth: true + spacing: 1 + Text { text: rowItem.name; color: theme.fg; font.family: root.mono; font.pixelSize: 13; font.weight: Font.DemiBold; Layout.fillWidth: true; elide: Text.ElideRight } + Text { text: rowItem.what; color: theme.fgMuted; font.pixelSize: 11; Layout.fillWidth: true; elide: Text.ElideRight } + } + Text { text: rowItem.valueText; color: theme.fg; font.family: root.mono; font.pixelSize: 12; Layout.maximumWidth: 150; elide: Text.ElideRight } } } } @@ -138,206 +211,193 @@ Rectangle { } Rectangle { Layout.fillHeight: true; implicitWidth: 1; color: theme.border } - // ---- detail ------------------------------------------------------------------------ + // ---- device ------------------------------------------------------------------------ ScrollView { id: detailScroll Layout.fillWidth: true - Layout.preferredWidth: 1 Layout.fillHeight: true contentWidth: availableWidth clip: true Item { width: detailScroll.availableWidth - implicitHeight: detail.implicitHeight + 24 + implicitHeight: detail.implicitHeight + 36 ColumnLayout { id: detail - width: Math.min(440, parent.width - 32) - anchors.horizontalCenter: parent.horizontalCenter - y: 12 - spacing: 8 + x: 24 + y: 18 + width: parent.width - 48 + spacing: 16 Text { - visible: root.backend.detailName === "" + visible: !root.hasDevice text: "Select a device to see it here." color: theme.fgMuted font.pixelSize: 12 } - ColumnLayout { - visible: root.backend.detailName !== "" - spacing: 4 - Layout.fillWidth: true - Text { visible: root.d.kind !== "positioner"; text: root.d.name || ""; color: theme.fg; font.family: root.mono; font.pixelSize: 15; font.weight: Font.Bold } - Text { text: root.d.description || ""; color: theme.fgMuted; font.pixelSize: 12; wrapMode: Text.WordWrap; Layout.fillWidth: true } - Text { - visible: root.d.kind === "monitor" - textFormat: Text.StyledText - text: (root.d.valueText || "") + " " + (root.d.units || "") + "" - color: theme.fg - font.family: root.mono - font.pixelSize: 28 - font.weight: Font.DemiBold - } - Canvas { - id: spark - visible: root.d.kind === "monitor" - Layout.fillWidth: true - Layout.maximumWidth: 380 - Layout.preferredHeight: 70 - property var points: root.backend.spark - onPointsChanged: requestPaint() - onPaint: { - const ctx = getContext("2d") - ctx.clearRect(0, 0, width, height) - if (!points || points.length < 2) return - let lo = Math.min.apply(null, points), hi = Math.max.apply(null, points) - const span = (hi - lo) || 1 - ctx.strokeStyle = theme.primary - ctx.lineWidth = 1.6 - ctx.beginPath() - for (let i = 0; i < points.length; i++) { - const x = i / 89 * width, y = height - 5 - (points[i] - lo) / span * (height - 10) - i ? ctx.lineTo(x, y) : ctx.moveTo(x, y) - } - ctx.stroke() - } - } - Text { visible: root.d.kind === "monitor"; text: "last ~20 s, live"; color: theme.fgSubtle; font.pixelSize: 11 } - } - Loader { - id: motor - active: root.d.kind === "positioner" && root.backend.motor !== null - visible: active - Layout.fillWidth: true - Layout.preferredHeight: item ? item.implicitHeight : 0 - onActiveChanged: if (active) setSource(root.backend.motorSource, { backend: root.backend.motor }) - Component.onCompleted: if (active) setSource(root.backend.motorSource, { backend: root.backend.motor }) - } - TextButton { - visible: root.backend.detailName !== "" - text: root.d.actionText || "" - iconName: root.d.kind === "detector" ? "image" : root.d.kind === "monitor" ? "show_chart" : "add" - variant: root.d.kind === "detector" ? "primary" : "neutral" - onClicked: root.backend.openInWorkspace() - } - SectionTitle { text: "Settings"; visible: root.backend.settings.count > 0 } - Repeater { - model: root.backend.settings - delegate: ColumnLayout { - id: setting - required property string key - required property bool settable - required property string doc - required property string valueText - required property string statusText - required property string statusTone + // header: name, kind, description and the main action + RowLayout { + visible: root.hasDevice + Layout.fillWidth: true + spacing: 12 + ColumnLayout { Layout.fillWidth: true spacing: 2 RowLayout { - Layout.fillWidth: true - spacing: 8 - Text { - text: setting.key - color: theme.fg - font.family: root.mono - font.pixelSize: 12 - Layout.preferredWidth: 130 - elide: Text.ElideRight - HoverHandler { id: keyHover } - ToolTip.visible: keyHover.hovered && setting.doc !== "" - ToolTip.text: setting.doc - } - Text { - text: setting.valueText - color: theme.fg - font.family: root.mono - font.pixelSize: 12 - Layout.fillWidth: true - elide: Text.ElideRight - } - InputField { - id: newValue - visible: setting.settable - Layout.preferredWidth: 110 - placeholderText: "New value" - Accessible.name: "New value for " + setting.key - onAccepted: root.backend.setSignal(setting.key, text) - } - TextButton { - visible: setting.settable - text: "Set" - onClicked: root.backend.setSignal(setting.key, newValue.text) - } - } - Text { - visible: setting.statusText !== "" - text: setting.statusText - color: Tones.color(theme, setting.statusTone) - font.pixelSize: 12 - wrapMode: Text.WordWrap - Layout.fillWidth: true - onTextChanged: if (setting.statusTone === "ok") newValue.clear() - } - } - } - SectionTitle { text: "Readings"; visible: root.backend.readings.count > 0 } - Repeater { - model: root.backend.readings - delegate: RowLayout { - id: reading - required property string key - required property string doc - required property string valueText - Layout.fillWidth: true - spacing: 8 - Text { - text: reading.key - color: theme.fg - font.family: root.mono - font.pixelSize: 12 - Layout.preferredWidth: 130 - elide: Text.ElideRight - } - Text { - text: reading.valueText - color: theme.fg - font.family: root.mono - font.pixelSize: 12 - Layout.fillWidth: true - elide: Text.ElideRight + spacing: 10 + Text { text: root.d.name || ""; color: theme.fg; font.family: root.mono; font.pixelSize: 20; font.weight: Font.Bold } + Chip { text: root.d.kindLabel || "" } + Chip { visible: root.d.enabled === false; text: "Disabled in this session"; tone: "warn"; iconName: "block" } } + Text { text: root.d.description || ""; color: theme.fgMuted; font.pixelSize: 12; wrapMode: Text.WordWrap; Layout.fillWidth: true } + } + TextButton { + Layout.alignment: Qt.AlignTop + text: root.d.actionText || "" + iconName: root.d.kind === "detector" ? "image" : root.d.kind === "monitor" ? "show_chart" : "add" + variant: "primary" + onClicked: root.backend.openInWorkspace() } } + GridLayout { - visible: root.backend.detailName !== "" - Layout.topMargin: 10 - columns: 2 - columnSpacing: 12 - rowSpacing: 3 + visible: root.hasDevice Layout.fillWidth: true - Repeater { - model: root.d.facts || [] - delegate: Text { - required property var modelData - required property int index - text: modelData.label - color: theme.fgMuted - font.pixelSize: 12 - Layout.row: index - Layout.column: 0 + columns: detail.width < 860 ? 1 : 2 + columnSpacing: 16 + rowSpacing: 16 + + // left column: live panel (motor card or value and trend) and about + ColumnLayout { + Layout.fillWidth: true + Layout.preferredWidth: 1 + Layout.alignment: Qt.AlignTop + spacing: 16 + Loader { + id: motor + active: root.d.kind === "positioner" && root.backend.motor !== null + visible: active + Layout.fillWidth: true + Layout.preferredHeight: item ? item.implicitHeight : 0 + onActiveChanged: if (active) setSource(root.backend.motorSource, { backend: root.backend.motor }) + Component.onCompleted: if (active) setSource(root.backend.motorSource, { backend: root.backend.motor }) + } + Panel { + visible: root.d.kind !== "positioner" + title: "Live value" + hint: root.d.numeric ? "last ~20 s" : "" + Text { + textFormat: Text.StyledText + text: (root.d.valueText || "") + " " + (root.d.units || "") + "" + color: theme.fg + font.family: root.mono + font.pixelSize: 28 + font.weight: Font.DemiBold + } + Canvas { + id: spark + visible: root.d.numeric === true + Layout.fillWidth: true + Layout.preferredHeight: 120 + property var points: root.backend.spark + onPointsChanged: requestPaint() + onWidthChanged: requestPaint() + onPaint: { + const ctx = getContext("2d") + ctx.clearRect(0, 0, width, height) + ctx.strokeStyle = theme.border + ctx.lineWidth = 1 + ctx.setLineDash([3, 3]) + for (const f of [0, 0.5, 1]) { + const gy = Math.round(5 + f * (height - 10)) + 0.5 + ctx.beginPath(); ctx.moveTo(0, gy); ctx.lineTo(width, gy); ctx.stroke() + } + ctx.setLineDash([]) + if (!points || points.length < 2) return + const lo = Math.min.apply(null, points), hi = Math.max.apply(null, points) + const span = (hi - lo) || 1 + const px = i => i / 89 * width + const py = v => height - 5 - (v - lo) / span * (height - 10) + ctx.beginPath() + for (let i = 0; i < points.length; i++) i ? ctx.lineTo(px(i), py(points[i])) : ctx.moveTo(px(i), py(points[i])) + const last = points.length - 1 + ctx.lineTo(px(last), height); ctx.lineTo(0, height); ctx.closePath() + ctx.fillStyle = Qt.rgba(theme.primary.r, theme.primary.g, theme.primary.b, 0.12) + ctx.fill() + ctx.beginPath() + for (let i = 0; i < points.length; i++) i ? ctx.lineTo(px(i), py(points[i])) : ctx.moveTo(px(i), py(points[i])) + ctx.strokeStyle = theme.primary + ctx.lineWidth = 1.8 + ctx.stroke() + ctx.fillStyle = theme.primary + ctx.beginPath(); ctx.arc(px(last), py(points[last]), 3, 0, 2 * Math.PI); ctx.fill() + ctx.fillStyle = theme.fgSubtle + ctx.font = "10px sans-serif" + ctx.textAlign = "right" + ctx.fillText(Number(hi.toPrecision(6)).toString(), width - 4, 11) + ctx.fillText(Number(lo.toPrecision(6)).toString(), width - 4, height - 3) + } + } + } + Panel { + title: "About" + GridLayout { + columns: 2 + columnSpacing: 16 + rowSpacing: 4 + Layout.fillWidth: true + Repeater { + model: root.d.facts || [] + delegate: Text { + required property var modelData + required property int index + text: modelData.label + color: theme.fgMuted + font.pixelSize: 12 + Layout.row: index + Layout.column: 0 + } + } + Repeater { + model: root.d.facts || [] + delegate: Text { + required property var modelData + required property int index + text: modelData.value + color: theme.fg + font.pixelSize: 13 + Layout.row: index + Layout.column: 1 + Layout.fillWidth: true + } + } + } } } - Repeater { - model: root.d.facts || [] - delegate: Text { - required property var modelData - required property int index - text: modelData.value - color: theme.fg - font.pixelSize: 13 - Layout.row: index - Layout.column: 1 - Layout.fillWidth: true + + // right column: settings and readings + ColumnLayout { + Layout.fillWidth: true + Layout.preferredWidth: 1 + Layout.alignment: Qt.AlignTop + spacing: 16 + Panel { + visible: root.backend.settings.count > 0 + title: "Settings" + hint: "Applied to the device at once" + Repeater { + model: root.backend.settings + delegate: SignalRow {} + } + } + Panel { + visible: root.backend.readings.count > 0 + title: "Readings" + hint: "live" + Repeater { + model: root.backend.readings + delegate: SignalRow {} + } } } } diff --git a/bec_widgets/applications/views/devices_views/qml/LockScreen.qml b/bec_widgets/applications/views/devices_views/qml/LockScreen.qml new file mode 100644 index 00000000..f29bb379 --- /dev/null +++ b/bec_widgets/applications/views/devices_views/qml/LockScreen.qml @@ -0,0 +1,103 @@ +import QtQuick +import QtQuick.Controls.Basic +import QtQuick.Layouts +import BecUi + +// Staff sign-in in front of Device Config (BEC Atlas, like Admin View). backend: StaffBackend. +Rectangle { + id: root + required property QtObject backend + color: theme.bg + + function submit() { root.backend.signIn(user.text, password.text) } + + Rectangle { + anchors.centerIn: parent + width: Math.min(420, parent.width - 32) + implicitHeight: form.implicitHeight + 48 + radius: 10 + color: theme.card + border.color: theme.border + + ColumnLayout { + id: form + anchors { left: parent.left; right: parent.right; top: parent.top; margins: 24 } + spacing: 12 + + Rectangle { + implicitWidth: 44; implicitHeight: 44; radius: 22 + color: Qt.rgba(theme.primary.r, theme.primary.g, theme.primary.b, 0.14) + Icon { anchors.centerIn: parent; name: "lock"; size: 22; color: theme.primary } + } + Text { + text: "Device Config is for beamline staff" + color: theme.fg + font.pixelSize: 17 + font.weight: Font.Bold + Layout.fillWidth: true + wrapMode: Text.WordWrap + } + Text { + text: "Sign in with your BEC Atlas account to add, remove and configure devices. Everyone can watch and operate devices in Devices." + color: theme.fgMuted + font.pixelSize: 12 + wrapMode: Text.WordWrap + Layout.fillWidth: true + } + ColumnLayout { + spacing: 4 + Layout.fillWidth: true + Layout.topMargin: 4 + Text { text: "User name"; color: theme.fg; font.pixelSize: 12; font.weight: Font.DemiBold } + InputField { + id: user + Layout.fillWidth: true + enabled: !root.backend.busy + placeholderText: "e.g. e12345" + Accessible.name: "User name" + focus: true + onAccepted: password.forceActiveFocus() + } + } + ColumnLayout { + spacing: 4 + Layout.fillWidth: true + Text { text: "Password"; color: theme.fg; font.pixelSize: 12; font.weight: Font.DemiBold } + InputField { + id: password + Layout.fillWidth: true + enabled: !root.backend.busy + echoMode: TextInput.Password + Accessible.name: "Password" + onAccepted: root.submit() + } + } + Text { + visible: root.backend.error !== "" + text: root.backend.error + color: theme.danger + font.pixelSize: 12 + wrapMode: Text.WordWrap + Layout.fillWidth: true + } + RowLayout { + Layout.fillWidth: true + Layout.topMargin: 4 + Text { + text: root.backend.deployment !== "" ? "Deployment " + root.backend.deployment : "" + color: theme.fgSubtle + font.pixelSize: 11 + Layout.fillWidth: true + elide: Text.ElideRight + } + TextButton { + text: root.backend.busy ? "Signing in…" : "Sign in" + variant: "primary" + iconName: "login" + enabled: !root.backend.busy + onClicked: root.submit() + } + } + } + } +} diff --git a/bec_widgets/applications/views/devices_views/staff_access.py b/bec_widgets/applications/views/devices_views/staff_access.py new file mode 100644 index 00000000..c9cef373 --- /dev/null +++ b/bec_widgets/applications/views/devices_views/staff_access.py @@ -0,0 +1,147 @@ +"""Staff sign-in for *Device Config*, through the same BEC Atlas login as Admin View. + +:class:`StaffAccess` is toolkit-free state shared by the QWidget and QML lock screens: it signs in +against BEC Atlas, and only unlocks when the account owns the running deployment. +""" + +from __future__ import annotations + +from bec_lib.endpoints import MessageEndpoints +from bec_lib.logger import bec_logger +from bec_lib.messages import DeploymentInfoMessage +from qtpy.QtCore import QObject, QTimer, Signal +from qtpy.QtWidgets import QWidget + +from bec_widgets.widgets.services.bec_atlas_admin_view.bec_atlas_http_service import ( + BECAtlasHTTPError, + BECAtlasHTTPService, +) + +logger = bec_logger.logger + +ATLAS_URL = "https://bec-atlas-prod.psi.ch/api/v1" +SIGN_IN_TIMEOUT_MS = 20_000 + + +class _GateAtlasService(BECAtlasHTTPService): + """Atlas service that reports failures as a signal instead of message boxes.""" + + failed = Signal(str) + + def _handle_response(self, reply): # pylint: disable=invalid-name + try: + super()._handle_response(reply) + except BECAtlasHTTPError as exc: + text = str(exc) + if "status code 401" in text or "status code 403" in text: + self.failed.emit("User name or password is not correct.") + else: + self.failed.emit("BEC Atlas did not accept the request. Try again.") + logger.warning(f"Device Config sign-in failed: {text}") + + def _show_warning(self, text: str): + self.failed.emit(text) + + +class StaffAccess(QObject): + """Sign-in state of the staff-only *Device Config* view. + + Args: + dispatcher: The BEC dispatcher, for the deployment-info subscription. + parent: Parent, normally the gate widget. + atlas_url: BEC Atlas API base URL. + """ + + changed = Signal() + + def __init__(self, dispatcher=None, parent: QObject | None = None, atlas_url: str = ATLAS_URL): + super().__init__(parent) + self.dispatcher = dispatcher + self.user = "" + self.error = "" + self.busy = False + self.deployment = "" + # The Atlas service is a QWidget; keep it a hidden child of the gate widget. + self.service = _GateAtlasService( + parent=parent if isinstance(parent, QWidget) else None, base_url=atlas_url + ) + self.service.hide() + self.service.authenticated.connect(self._on_authenticated) + self.service.failed.connect(self._on_failed) + self._timeout = QTimer(self, singleShot=True, interval=SIGN_IN_TIMEOUT_MS) + self._timeout.timeout.connect( + lambda: self._on_failed("BEC Atlas did not answer. Check the network and try again.") + ) + if dispatcher is not None: + dispatcher.connect_slot( + self.on_deployment, MessageEndpoints.deployment_info(), from_start=True + ) + + @property + def unlocked(self) -> bool: + """True while a staff account of this deployment is signed in.""" + return bool(self.user) + + def on_deployment(self, msg: dict, _meta: dict | None = None) -> None: + """Track the running deployment; sign-in checks ownership against it.""" + try: + info = DeploymentInfoMessage.model_validate(msg) + except Exception: # pylint: disable=broad-except + return + self.service._set_current_deployment_info(info) # pylint: disable=protected-access + self.deployment = str(info.name or "") + self.changed.emit() + + def sign_in(self, username: str, password: str) -> None: + """Ask BEC Atlas to sign in; the view unlocks once ownership is confirmed.""" + if self.busy: + return + if not username.strip() or not password: + self.error = "Enter your user name and password." + self.changed.emit() + return + if not self.deployment: + self.error = "This BEC session has no deployment registered with BEC Atlas." + self.changed.emit() + return + self.busy = True + self.error = "" + self._timeout.start() + self.changed.emit() + self.service.login(username=username.strip(), password=password) + + def sign_out(self) -> None: + """Lock the view again and end the Atlas session.""" + was_signed_in = self.unlocked + self.user = "" + self.busy = False + self._timeout.stop() + self.changed.emit() + if was_signed_in: + self.service.logout() + + def _on_authenticated(self, info: dict) -> None: + self._timeout.stop() + self.busy = False + self.user = str((info or {}).get("email", "")) if info else "" + if info: + self.error = "" + self.changed.emit() + + def _on_failed(self, text: str) -> None: + self._timeout.stop() + self.busy = False + self.user = "" + self.error = text + self.changed.emit() + + def cleanup(self) -> None: + """Drop the subscription and end the Atlas session.""" + self._timeout.stop() + if self.dispatcher is not None: + self.dispatcher.disconnect_slot(self.on_deployment, MessageEndpoints.deployment_info()) + if self.unlocked: + self.service.cleanup() # also ends the session on the Atlas server + else: + self.service._auth_timer.stop() # pylint: disable=protected-access + self.service.deleteLater() diff --git a/tests/unit_tests/test_devices_views.py b/tests/unit_tests/test_devices_views.py index 83167385..273029eb 100644 --- a/tests/unit_tests/test_devices_views.py +++ b/tests/unit_tests/test_devices_views.py @@ -21,10 +21,12 @@ from bec_widgets.applications.views.devices_views.devices_core import ( signal_entries, ) from bec_widgets.applications.views.devices_views.devices_qml import ( + DeviceConfigGateQML, DeviceConfigViewQML, DevicesViewQML, ) from bec_widgets.applications.views.devices_views.devices_qwidget import ( + DeviceConfigGateQWidget, DeviceConfigViewQWidget, DevicesViewQWidget, ) @@ -454,3 +456,59 @@ def test_devices_view_lets_users_change_a_setting(devices_view, qtbot, sync_pool widgets["edit"].returnPressed.emit() qtbot.waitUntil(lambda: widgets["status"].text() == "Set to 3", timeout=1000) velocity.set.assert_called_once_with(3) + + +# ------------------------------------------------------------------------------------ staff gate +@pytest.fixture(params=[DeviceConfigGateQWidget, DeviceConfigGateQML]) +def gate(request, qtbot, mocked_client): + with mock.patch.object( + mocked_client.device_manager, + "_get_redis_device_config", + return_value=[{"name": k, **copy.deepcopy(v)} for k, v in SESSION.items()], + ): + widget = create_widget(qtbot, request.param, client=mocked_client) + service = widget.access.service + with ( + mock.patch.object(service, "login") as login, + mock.patch.object(service, "logout") as logout, + ): + widget.login, widget.logout = login, logout + yield widget + + +def test_gate_starts_locked_and_explains_missing_input(gate): + assert gate.config is None + gate.access.sign_in("", "") + assert gate.access.error == "Enter your user name and password." + gate.access.sign_in("e12345", "secret") + assert "no deployment" in gate.access.error + gate.login.assert_not_called() + + +def test_gate_unlocks_for_deployment_owner_and_locks_on_sign_out(gate, qtbot): + gate.access.deployment = "x05la" + gate.access.sign_in("e12345", "secret") + gate.login.assert_called_once_with(username="e12345", password="secret") + assert gate.access.busy + gate.access.service.authenticated.emit({"email": "jan@psi.ch"}) + assert gate.access.unlocked + assert gate.config is not None + assert gate.stack.currentWidget() is gate.config + if isinstance(gate, DeviceConfigGateQWidget): + assert gate.config.staff_label.text() == "Signed in as jan@psi.ch" + else: + assert gate.config.backend.staffUser == "jan@psi.ch" + gate.config.sign_out_requested.emit() + assert not gate.access.unlocked + gate.logout.assert_called_once() + assert gate.stack.currentWidget() is not gate.config + + +def test_gate_shows_atlas_refusal(gate): + gate.access.deployment = "x05la" + gate.access.sign_in("e12345", "wrong") + gate.access.service.failed.emit("User name or password is not correct.") + assert not gate.access.busy + assert not gate.access.unlocked + assert gate.access.error == "User name or password is not correct." + assert gate.config is None