diff --git a/bec_widgets/applications/views/admin_view/admin_view.py b/bec_widgets/applications/views/admin_view/admin_view.py
index 7c424565..3787b336 100644
--- a/bec_widgets/applications/views/admin_view/admin_view.py
+++ b/bec_widgets/applications/views/admin_view/admin_view.py
@@ -1,11 +1,39 @@
"""Module for Admin View."""
+import os
+
from qtpy.QtWidgets import QWidget
from bec_widgets.applications.views.view import ViewBase
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.widgets.services.bec_atlas_admin_view.bec_atlas_admin_view import BECAtlasAdminView
+ADMIN_UI_ENV = "BEC_ADMIN_UI"
+
+
+def create_admin_widget(parent: QWidget | None = None) -> QWidget:
+ """Create the admin panel selected by ``BEC_ADMIN_UI``.
+
+ ``qml`` and ``qwidget`` select the reworked view with the guided experiment switch; anything
+ else keeps the current :class:`BECAtlasAdminView`.
+ """
+ choice = os.environ.get(ADMIN_UI_ENV, "").strip().lower()
+ if choice == "qml":
+ # pylint: disable=import-outside-toplevel
+ from bec_widgets.widgets.services.bec_atlas_admin_view.admin_ux.admin_view_qml import (
+ AdminViewQML,
+ )
+
+ return AdminViewQML(parent=parent)
+ if choice == "qwidget":
+ # pylint: disable=import-outside-toplevel
+ from bec_widgets.widgets.services.bec_atlas_admin_view.admin_ux.admin_view_qwidget import (
+ AdminViewQWidget,
+ )
+
+ return AdminViewQWidget(parent=parent)
+ return BECAtlasAdminView(parent=parent)
+
class AdminView(ViewBase):
"""
@@ -23,7 +51,7 @@ class AdminView(ViewBase):
**kwargs,
):
super().__init__(parent=parent, content=content, view_id=view_id, title=title)
- self.admin_widget = BECAtlasAdminView(parent=self)
+ self.admin_widget = create_admin_widget(parent=self)
self.set_content(self.admin_widget)
@SafeSlot()
diff --git a/bec_widgets/widgets/services/bec_atlas_admin_view/admin_ux/__init__.py b/bec_widgets/widgets/services/bec_atlas_admin_view/admin_ux/__init__.py
new file mode 100644
index 00000000..eb00f20b
--- /dev/null
+++ b/bec_widgets/widgets/services/bec_atlas_admin_view/admin_ux/__init__.py
@@ -0,0 +1 @@
+"""Reworked admin view with QML and QWidget renderings of the same experiment-switch flow."""
diff --git a/bec_widgets/widgets/services/bec_atlas_admin_view/admin_ux/admin_demo.py b/bec_widgets/widgets/services/bec_atlas_admin_view/admin_ux/admin_demo.py
new file mode 100644
index 00000000..49daf0db
--- /dev/null
+++ b/bec_widgets/widgets/services/bec_atlas_admin_view/admin_ux/admin_demo.py
@@ -0,0 +1,79 @@
+"""Demo data for the reworked admin view, used by its tests and screenshots.
+
+Beamtimes are placed relative to today, so "next" and "past" stay meaningful.
+"""
+
+from __future__ import annotations
+
+from datetime import datetime, timedelta
+
+from bec_lib.messages import (
+ DeploymentInfoMessage,
+ ExperimentInfoMessage,
+ MessagingConfig,
+ MessagingServiceScopeConfig,
+ SessionInfoMessage,
+)
+
+_PEOPLE = {
+ "p22622": ("Jane", "Smith", "In-situ SAXS of lipid nanoparticle self-assembly", -2, 3),
+ "p22623": ("Marco", "Rossi", "Time-resolved WAXS on cycling battery cathodes", 3, 3),
+ "p22631": ("Aiko", "Tanaka", "Ptychographic X-ray tomography of trabecular bone", 11, 4),
+ "p22652": ("Lena", "Fischer", "Coherent diffraction imaging of strained nanocrystals", 33, 2),
+ "p22604": ("Klaus", "Weber", "Detector distance calibration and flat-field update", -24, 1),
+ "p22640": ("Beamline", "Staff", "Staff test and alignment account", None, None),
+}
+
+
+def _schedule(start_offset: int | None, length: int | None, now: datetime) -> list[dict]:
+ if start_offset is None:
+ return []
+ start = (now + timedelta(days=start_offset)).replace(hour=8, minute=0, second=0)
+ end = (start + timedelta(days=length)).replace(hour=8)
+ fmt = "%d/%m/%Y %H:%M:%S"
+ return [{"start": start.strftime(fmt), "end": end.strftime(fmt)}]
+
+
+def demo_experiments(realm: str = "X12SA", now: datetime | None = None) -> list[dict]:
+ """Experiments of one realm as BEC Atlas lists them."""
+ now = now or datetime.now()
+ infos = []
+ for pgroup, (first, last, title, offset, length) in _PEOPLE.items():
+ number = pgroup[1:]
+ infos.append(
+ ExperimentInfoMessage(
+ realm_id=realm,
+ proposal="" if pgroup == "p22640" else f"2026{number}",
+ title=title,
+ firstname=first,
+ lastname=last,
+ email=f"{first.lower()}.{last.lower()}@psi.ch",
+ account=f"{last.lower()}_{first[0].lower()}",
+ pi_firstname=first,
+ pi_lastname=last,
+ pi_email=f"{first.lower()}.{last.lower()}@psi.ch",
+ pi_account=f"{last.lower()}_{first[0].lower()}",
+ eaccount=f"e{number}",
+ pgroup=pgroup,
+ abstract=(
+ f"{title}. The group combines scanning and full-field measurements to follow "
+ "structural changes in situ, with automated data reduction during the beamtime."
+ ),
+ schedule=_schedule(offset, length, now),
+ ).model_dump()
+ )
+ return infos
+
+
+def demo_deployment(active_pgroup: str = "p22622", realm: str = "X12SA") -> DeploymentInfoMessage:
+ """Deployment info with ``active_pgroup`` as the active experiment."""
+ experiment = next(e for e in demo_experiments(realm) if e["pgroup"] == active_pgroup)
+ scope = MessagingServiceScopeConfig(enabled=False)
+ return DeploymentInfoMessage(
+ deployment_id="dep-x12sa",
+ name="cSAXS",
+ active_session=SessionInfoMessage(
+ name="default", experiment=ExperimentInfoMessage.model_validate(experiment)
+ ),
+ messaging_config=MessagingConfig(signal=scope, teams=scope, scilog=scope),
+ )
diff --git a/bec_widgets/widgets/services/bec_atlas_admin_view/admin_ux/admin_ux_common.py b/bec_widgets/widgets/services/bec_atlas_admin_view/admin_ux/admin_ux_common.py
new file mode 100644
index 00000000..b5f9af73
--- /dev/null
+++ b/bec_widgets/widgets/services/bec_atlas_admin_view/admin_ux/admin_ux_common.py
@@ -0,0 +1,787 @@
+"""State and behaviour shared by the QML and QWidget versions of the reworked admin view.
+
+The admin view lets beamline staff sign in to BEC Atlas and switch the active experiment.
+Switching the experiment changes the BEC account (the p-group) that new scans are written to,
+and the experiment's group works on the beamline computers under its own Linux account (the
+e-account). The old view did not say any of this; this module turns it into an explicit
+three-step flow (review, confirm, log in as the new account) and a notice that compares the
+Linux login of this computer with the account of the active experiment.
+
+:class:`AdminViewBase` owns the Atlas connection and all state and renders it as one plain
+dictionary (:meth:`AdminViewBase.view_state`). The QML and QWidget subclasses only draw that
+dictionary and call the public slots, so both show exactly the same UX.
+"""
+
+from __future__ import annotations
+
+import getpass
+import re
+import time
+from datetime import datetime
+
+from bec_lib.endpoints import MessageEndpoints
+from bec_lib.logger import bec_logger
+from bec_lib.messages import DeploymentInfoMessage, ExperimentInfoMessage
+from qtpy.QtCore import QTimer, Signal
+from qtpy.QtNetwork import QNetworkReply, QNetworkRequest
+from qtpy.QtWidgets import QWidget
+
+from bec_widgets.utils.bec_widget import BECWidget
+from bec_widgets.utils.error_popups import SafeSlot
+from bec_widgets.widgets.services.bec_atlas_admin_view.bec_atlas_http_service import (
+ AtlasEndpoints,
+ AuthenticatedUserInfo,
+ BECAtlasHTTPService,
+ HTTPResponse,
+)
+from bec_widgets.widgets.services.bec_atlas_admin_view.experiment_selection.utils import (
+ format_name,
+ format_schedule,
+)
+
+logger = bec_logger.logger
+
+DEFAULT_ATLAS_URL = "https://bec-atlas-prod.psi.ch/api/v1"
+
+SECTIONS = [
+ {"id": "experiment", "label": "Experiment", "icon": "science", "enabled": True, "badge": ""},
+ {"id": "messaging", "label": "Messaging", "icon": "chat", "enabled": False, "badge": "Soon"},
+]
+"""Sections of the signed-in admin view. Later staff tools are added here."""
+
+WIZARD_STEPS = ["Review", "Confirm", "Log in"]
+
+HOW_IT_WORKS = [
+ "Pick the experiment from the list. Nothing changes yet.",
+ "Review what changes and confirm. New data then goes to the new p-group.",
+ "Everyone on this computer logs out and logs in as the experiment's Linux account.",
+]
+
+_EACCOUNT = re.compile(r"^e\d{5}$")
+
+STATUS_LABELS = {
+ "active": "Active",
+ "now": "Beamtime now",
+ "next": "Next",
+ "upcoming": "Upcoming",
+ "past": "Past",
+ "unscheduled": "No beamtime",
+}
+
+
+########################
+## Pure helpers
+########################
+
+
+def linux_account_for(info: ExperimentInfoMessage | dict | None) -> str:
+ """Return the Linux account the experiment's group logs in with, e.g. ``e22622``.
+
+ Args:
+ info(ExperimentInfoMessage | dict | None): Experiment information.
+
+ Returns:
+ str: The e-account, derived from the p-group if Atlas did not send one, else ``""``.
+ """
+ if info is None:
+ return ""
+ if isinstance(info, dict):
+ eaccount = info.get("eaccount") or ""
+ pgroup = info.get("pgroup") or ""
+ else:
+ eaccount = info.eaccount or ""
+ pgroup = info.pgroup or ""
+ if eaccount:
+ return eaccount
+ if re.match(r"^p\d{5}$", pgroup):
+ return f"e{pgroup[1:]}"
+ return ""
+
+
+def format_beamtime(start: datetime | None, end: datetime | None) -> str:
+ """Format a beamtime as ``12 Oct 2026, 08:00 – 14 Oct, 18:00``."""
+ if start is None:
+ return "No beamtime scheduled"
+ text = f"{start:%d %b %Y, %H:%M}"
+ if end is None:
+ return text
+ if end.date() == start.date():
+ return f"{text} – {end:%H:%M}"
+ end_format = "%d %b, %H:%M" if end.year == start.year else "%d %b %Y, %H:%M"
+ return f"{text} – {end.strftime(end_format)}"
+
+
+def select_next_pgroup(experiments: list[dict], now: datetime | None = None) -> str:
+ """Return the p-group of the experiment whose beamtime starts next (or runs now)."""
+ now = now or datetime.now()
+ best = None
+ for info in experiments:
+ start, end = format_schedule(info.get("schedule"), as_datetime=True)
+ if start is None:
+ continue
+ running = end is not None and start <= now <= end
+ if start < now and not running:
+ continue
+ key = (0 if running else 1, abs((start - now).total_seconds()))
+ if best is None or key < best[0]:
+ best = (key, info.get("pgroup", ""))
+ return best[1] if best else ""
+
+
+def experiment_summary(
+ info: ExperimentInfoMessage | dict | None,
+ *,
+ active_pgroup: str = "",
+ next_pgroup: str = "",
+ now: datetime | None = None,
+) -> dict:
+ """Flatten experiment information into the fields both views display.
+
+ Args:
+ info(ExperimentInfoMessage | dict | None): Experiment information from Atlas.
+ active_pgroup(str): P-group of the active experiment, to mark it.
+ next_pgroup(str): P-group of the next scheduled experiment, to mark it.
+ now(datetime | None): Reference time, for tests.
+
+ Returns:
+ dict: Display fields; empty if ``info`` is None.
+ """
+ if info is None:
+ return {}
+ if isinstance(info, ExperimentInfoMessage):
+ info = info.model_dump()
+ now = now or datetime.now()
+ pgroup = info.get("pgroup") or ""
+ start, end = format_schedule(info.get("schedule"), as_datetime=True)
+ if pgroup and pgroup == active_pgroup:
+ status = "active"
+ elif start is None:
+ status = "unscheduled"
+ elif end is not None and start <= now <= end:
+ status = "now"
+ elif pgroup and pgroup == next_pgroup:
+ status = "next"
+ elif start > now:
+ status = "upcoming"
+ else:
+ status = "past"
+ try:
+ name = format_name(info)
+ except Exception: # pylint: disable=broad-except
+ name = " ".join(p for p in [info.get("firstname"), info.get("lastname")] if p)
+ pi = " ".join(p for p in [info.get("pi_firstname"), info.get("pi_lastname")] if p)
+ return {
+ "pgroup": pgroup,
+ "title": info.get("title") or "Untitled experiment",
+ "pi": pi or name or "—",
+ "contact": name or "—",
+ "email": info.get("pi_email") or info.get("email") or "",
+ "proposal": info.get("proposal") or "",
+ "hasProposal": bool(info.get("proposal")),
+ "linuxAccount": linux_account_for(info),
+ "dataAccount": pgroup,
+ "beamtime": format_beamtime(start, end),
+ "startSort": start.timestamp() if start else float("inf"),
+ "status": status,
+ "statusLabel": STATUS_LABELS[status],
+ "isActive": status == "active",
+ "isNext": pgroup != "" and pgroup == next_pgroup,
+ "abstract": (info.get("abstract") or "").strip(),
+ }
+
+
+def filter_experiments(rows: list[dict], query: str = "", scope: str = "upcoming") -> list[dict]:
+ """Filter and order experiment summaries for the switch list.
+
+ Args:
+ rows(list[dict]): Summaries from :func:`experiment_summary`.
+ query(str): Case-insensitive text matched against p-group, title, people and accounts.
+ scope(str): ``"upcoming"`` hides past beamtimes, ``"all"`` shows everything.
+
+ Returns:
+ list[dict]: The active experiment first, then by beamtime start; unscheduled last.
+ """
+ words = query.lower().split()
+ result = []
+ for row in rows:
+ if scope == "upcoming" and row["status"] == "past":
+ continue
+ haystack = " ".join(
+ str(row.get(key, ""))
+ for key in ("pgroup", "title", "pi", "contact", "linuxAccount", "proposal")
+ ).lower()
+ if all(word in haystack for word in words):
+ result.append(row)
+ return sorted(result, key=lambda r: (not r["isActive"], r["startSort"], r["pgroup"]))
+
+
+def account_notice(system_user: str, active: dict) -> dict:
+ """Compare the Linux login of this computer with the active experiment's account.
+
+ Args:
+ system_user(str): Linux user running this BEC app.
+ active(dict): Summary of the active experiment.
+
+ Returns:
+ dict: ``tone`` (``ok``, ``info`` or ``warning``), ``title`` and ``text``; empty if there
+ is no active experiment.
+ """
+ account = active.get("linuxAccount", "") if active else ""
+ if not account:
+ return {}
+ if system_user == account:
+ return {
+ "tone": "ok",
+ "title": f"Logged in as {account}",
+ "text": f"This computer runs under the account of the active experiment {active['pgroup']}.",
+ }
+ if _EACCOUNT.match(system_user or ""):
+ return {
+ "tone": "warning",
+ "title": f"Still logged in as {system_user}",
+ "text": (
+ f"{system_user} belongs to a different experiment. Close BEC, log out and log in "
+ f"as {account} before the group of {active['pgroup']} continues."
+ ),
+ }
+ return {
+ "tone": "info",
+ "title": f"Logged in as {system_user or 'unknown'} (staff)",
+ "text": f"The group of {active['pgroup']} works on this computer as {account}.",
+ }
+
+
+def switch_plan(current: dict, target: dict, system_user: str, queue_busy: bool = False) -> dict:
+ """Describe what switching from ``current`` to ``target`` changes, for the confirmation flow.
+
+ Args:
+ current(dict): Summary of the active experiment (may be empty).
+ target(dict): Summary of the experiment to switch to.
+ system_user(str): Linux user running this BEC app.
+ queue_busy(bool): Whether a scan is running or queued.
+
+ Returns:
+ dict: ``changes`` (before/after rows), ``consequences`` (what happens),
+ ``loginSteps`` (what to do afterwards), ``ackText`` and ``linuxAccount``.
+ """
+ old_pgroup = current.get("pgroup") or "none"
+ new_pgroup = target["pgroup"]
+ new_account = target.get("linuxAccount") or "the experiment account"
+ old_account = current.get("linuxAccount") or "—"
+ changes = [
+ {"label": "Experiment", "before": old_pgroup, "after": new_pgroup, "key": False},
+ {
+ "label": "Title",
+ "before": current.get("title", "—"),
+ "after": target["title"],
+ "key": False,
+ },
+ {"label": "PI", "before": current.get("pi", "—"), "after": target["pi"], "key": False},
+ {"label": "Linux login", "before": old_account, "after": new_account, "key": True},
+ {"label": "Data saved to", "before": old_pgroup, "after": new_pgroup, "key": True},
+ ]
+ consequences = [
+ {
+ "icon": "save",
+ "tone": "info",
+ "title": f"New scans are saved for {new_pgroup}",
+ "text": "From the next scan on, file paths and scan numbers follow the new experiment.",
+ },
+ {
+ "icon": "folder_shared",
+ "tone": "info",
+ "title": f"Data already recorded stays with {old_pgroup}",
+ "text": "Nothing is moved or deleted. The previous group keeps access to its data.",
+ },
+ {
+ "icon": "terminal",
+ "tone": "warning",
+ "title": "Open BEC sessions keep their Linux login",
+ "text": (
+ f"This app and any bec terminal keep running as {system_user or 'the current user'}"
+ f". Close them so nobody keeps working under the previous login."
+ ),
+ },
+ ]
+ if queue_busy:
+ consequences.append(
+ {
+ "icon": "pending_actions",
+ "tone": "warning",
+ "title": "A scan is running or queued",
+ "text": (
+ f"The running scan finishes in {old_pgroup}'s folder; scans that start after "
+ f"the switch go to {new_pgroup}. Wait for the queue to be idle if unsure."
+ ),
+ }
+ )
+ else:
+ consequences.append(
+ {
+ "icon": "check_circle",
+ "tone": "ok",
+ "title": "The scan queue is idle",
+ "text": "No scan is affected by switching now.",
+ }
+ )
+ login_steps = [
+ {"title": "Close BEC", "text": "Quit this app and every bec terminal on this computer."},
+ {
+ "title": "Log out",
+ "text": f"Log out of the desktop session of {system_user or 'the current user'}.",
+ },
+ {
+ "title": f"Log in as {new_account}",
+ "text": "Use the password of the experiment account, as given to the user group.",
+ },
+ {
+ "title": "Start BEC again",
+ "text": f"The bec prompt then starts with {new_pgroup}, confirming the new experiment.",
+ },
+ ]
+ return {
+ "changes": changes,
+ "consequences": consequences,
+ "loginSteps": login_steps,
+ "ackText": f"After switching, everyone on this computer logs in as {new_account}.",
+ "linuxAccount": new_account,
+ }
+
+
+def format_remaining(seconds: float) -> str:
+ """Format a remaining session time as ``14:32`` or ``1:02:03``."""
+ seconds = max(0, int(seconds))
+ hours, rest = divmod(seconds, 3600)
+ minutes, secs = divmod(rest, 60)
+ return f"{hours}:{minutes:02d}:{secs:02d}" if hours else f"{minutes}:{secs:02d}"
+
+
+########################
+## Atlas service
+########################
+
+
+class AdminAtlasService(BECAtlasHTTPService):
+ """Atlas HTTP service that reports failed sign-ins and switches inline instead of in popups."""
+
+ request_failed = Signal(str, str) # endpoint kind ("login", "switch", "experiments"), message
+
+ _KINDS = {
+ AtlasEndpoints.LOGIN.value: "login",
+ AtlasEndpoints.SET_EXPERIMENT.value: "switch",
+ AtlasEndpoints.REALMS_EXPERIMENTS.value: "experiments",
+ }
+
+ @SafeSlot(QNetworkReply, popup_error=True)
+ def _handle_response(self, reply: QNetworkReply):
+ status = reply.attribute(QNetworkRequest.Attribute.HttpStatusCodeAttribute)
+ url = reply.url().toString()
+ kind = next((k for endpoint, k in self._KINDS.items() if endpoint in url), None)
+ if kind is None or status == 200:
+ super()._handle_response(reply)
+ return
+ reply.deleteLater()
+ self.request_failed.emit(kind, self._failure_text(kind, status))
+
+ def _failure_text(self, kind: str, status: int | None) -> str:
+ if not status:
+ return f"BEC Atlas is not reachable at {self._base_url}."
+ if kind == "login" and status in (401, 403):
+ return "Wrong username or password."
+ if kind == "switch":
+ return f"BEC Atlas refused the switch (HTTP {status}). The experiment was not changed."
+ if kind == "experiments":
+ return f"Could not load the experiments of this beamline (HTTP {status})."
+ return f"Request failed (HTTP {status})."
+
+ def _show_warning(self, text: str):
+ self.request_failed.emit("login", text)
+
+ def cleanup(self):
+ """Close the Atlas session once; the widget and its close event may both ask."""
+ if getattr(self, "_cleaned_up", False):
+ return
+ self._cleaned_up = True
+ super().cleanup()
+
+
+########################
+## Shared view base
+########################
+
+
+class AdminViewBase(BECWidget, QWidget):
+ """Admin view logic shared by the QML and QWidget versions.
+
+ Subclasses implement :meth:`_render`, which receives :meth:`view_state` after every change.
+ """
+
+ RPC = False
+
+ authenticated = Signal(bool)
+ experiment_switched = Signal(str)
+
+ def __init__(
+ self,
+ parent=None,
+ atlas_url: str = DEFAULT_ATLAS_URL,
+ client=None,
+ system_user: str | None = None,
+ **kwargs,
+ ):
+ super().__init__(parent=parent, client=client, **kwargs)
+ self._atlas_url = atlas_url
+ self._system_user = system_user if system_user is not None else getpass.getuser()
+ self._deployment: DeploymentInfoMessage | None = None
+ self._active_info: ExperimentInfoMessage | None = None
+ self._experiments: list[dict] = []
+ self._loading_experiments = False
+ self._auth: AuthenticatedUserInfo | None = None
+ self._signing_in = False
+ self._sign_in_error = ""
+ self._section = "experiment"
+ self._query = ""
+ self._scope = "upcoming"
+ self._selected = ""
+ self._queue_busy = False
+ self._wizard = self._closed_wizard()
+
+ self.atlas_http_service = AdminAtlasService(
+ parent=self, base_url=atlas_url, headers={"accept": "application/json"}
+ )
+ self.atlas_http_service.hide()
+ self.atlas_http_service.http_response.connect(self._on_http_response)
+ self.atlas_http_service.authenticated.connect(self._on_authenticated)
+ self.atlas_http_service.request_failed.connect(self._on_request_failed)
+
+ self._tick = QTimer(self)
+ self._tick.setInterval(1000)
+ self._tick.timeout.connect(self._on_tick)
+
+ self.bec_dispatcher.connect_slot(
+ self._on_deployment_info, MessageEndpoints.deployment_info(), from_start=True
+ )
+ self.bec_dispatcher.connect_slot(
+ self._on_queue_status, MessageEndpoints.scan_queue_status()
+ )
+
+ @staticmethod
+ def _closed_wizard() -> dict:
+ return {
+ "open": False,
+ "step": 0,
+ "target": "",
+ "ack": False,
+ "phase": "review",
+ "error": "",
+ "frozen": None,
+ "fromPgroup": "",
+ }
+
+ ########################
+ ## Rendering
+ ########################
+
+ def _render(self, state: dict) -> None: # pragma: no cover - implemented by subclasses
+ raise NotImplementedError
+
+ def refresh(self) -> None:
+ """Re-render the view from the current state."""
+ self._render(self.view_state())
+
+ def _summaries(self) -> tuple[dict, list[dict]]:
+ active_pgroup = self._active_info.pgroup if self._active_info else ""
+ next_pgroup = select_next_pgroup(
+ [e for e in self._experiments if e.get("pgroup") != active_pgroup]
+ )
+ active = experiment_summary(
+ self._active_info, active_pgroup=active_pgroup, next_pgroup=next_pgroup
+ )
+ rows = [
+ experiment_summary(info, active_pgroup=active_pgroup, next_pgroup=next_pgroup)
+ for info in self._experiments
+ ]
+ return active, rows
+
+ def view_state(self) -> dict:
+ """Return everything the views display as one plain dictionary."""
+ active, all_rows = self._summaries()
+ rows = filter_experiments(all_rows, self._query, self._scope)
+ by_pgroup = {row["pgroup"]: row for row in all_rows}
+ selected = by_pgroup.get(self._selected, {})
+ wizard = dict(self._wizard)
+ target = by_pgroup.get(wizard["target"], {})
+ wizard["targetInfo"] = target
+ frozen = wizard.pop("frozen")
+ if frozen is not None:
+ # Keep showing the change as confirmed, even after the active experiment moved.
+ wizard["plan"], wizard["targetInfo"] = frozen
+ else:
+ wizard["plan"] = (
+ switch_plan(active, target, self._system_user, self._queue_busy) if target else {}
+ )
+ wizard["steps"] = WIZARD_STEPS
+ wizard["canConfirm"] = (
+ bool(wizard["targetInfo"]) and wizard["ack"] and wizard["phase"] != "switching"
+ )
+ deployment = self._deployment
+ remaining = (self._auth.exp - time.time()) if self._auth else 0
+ return {
+ "deploymentName": deployment.name if deployment else "No deployment",
+ "realm": (self._active_info.realm_id if self._active_info else "") or "",
+ "atlasUrl": self._atlas_url,
+ "signedIn": self._auth is not None,
+ "email": self._auth.email if self._auth else "",
+ "remaining": format_remaining(remaining) if self._auth else "",
+ "signingIn": self._signing_in,
+ "signInError": self._sign_in_error,
+ "systemUser": self._system_user,
+ "section": self._section,
+ "sections": SECTIONS,
+ "active": active,
+ "hasActive": bool(active),
+ "notice": account_notice(self._system_user, active),
+ "query": self._query,
+ "scope": self._scope,
+ "rows": rows,
+ "totalCount": len(all_rows),
+ "loadingExperiments": self._loading_experiments,
+ "selected": selected,
+ "canSwitch": bool(selected) and not selected.get("isActive", False),
+ "queueBusy": self._queue_busy,
+ "wizard": wizard,
+ }
+
+ ########################
+ ## User actions
+ ########################
+
+ @SafeSlot(str, str)
+ def login(self, username: str, password: str) -> None:
+ """Sign in to BEC Atlas with a PSI account."""
+ if not username or not password:
+ self._sign_in_error = "Enter your PSI username and password."
+ self.refresh()
+ return
+ if self._auth is not None:
+ self.atlas_http_service.logout()
+ self._signing_in = True
+ self._sign_in_error = ""
+ self.refresh()
+ self.atlas_http_service.login(username=username, password=password)
+
+ @SafeSlot()
+ def logout(self) -> None:
+ """Sign out of BEC Atlas."""
+ self.atlas_http_service.logout()
+
+ @SafeSlot(str, str, popup_error=True)
+ def set_experiment(self, experiment_id: str, deployment_id: str) -> None:
+ """Ask BEC Atlas to make ``experiment_id`` the active experiment of the deployment."""
+ self.atlas_http_service.set_experiment(experiment_id, deployment_id)
+
+ @SafeSlot(str)
+ def open_section(self, section: str) -> None:
+ """Show a section of the signed-in view."""
+ if any(s["id"] == section and s["enabled"] for s in SECTIONS):
+ self._section = section
+ self.refresh()
+
+ @SafeSlot(str)
+ def set_query(self, query: str) -> None:
+ """Filter the experiment list."""
+ self._query = query
+ self.refresh()
+
+ @SafeSlot(str)
+ def set_scope(self, scope: str) -> None:
+ """Show only upcoming beamtimes (``upcoming``) or all experiments (``all``)."""
+ self._scope = "all" if scope == "all" else "upcoming"
+ self.refresh()
+
+ @SafeSlot(str)
+ def select_experiment(self, pgroup: str) -> None:
+ """Select an experiment in the list to see its details."""
+ self._selected = pgroup
+ self.refresh()
+
+ @SafeSlot(str)
+ def start_switch(self, pgroup: str = "") -> None:
+ """Open the switch flow for ``pgroup`` (default: the selected experiment)."""
+ pgroup = pgroup or self._selected
+ if not pgroup or self._auth is None:
+ return
+ self._wizard = self._closed_wizard()
+ active = self._active_info.pgroup if self._active_info else ""
+ self._wizard.update({"open": True, "target": pgroup, "fromPgroup": active or "none"})
+ self.refresh()
+
+ @SafeSlot()
+ def wizard_next(self) -> None:
+ """Go from reviewing the change to confirming it."""
+ if self._wizard["open"] and self._wizard["step"] == 0:
+ self._wizard["step"] = 1
+ self.refresh()
+
+ @SafeSlot()
+ def wizard_back(self) -> None:
+ """Go back one step, or close the flow from the first step."""
+ if self._wizard["phase"] in ("switching", "done"):
+ return
+ if self._wizard["step"] == 0:
+ self.close_wizard()
+ return
+ self._wizard["step"] -= 1
+ self._wizard["error"] = ""
+ self.refresh()
+
+ @SafeSlot(bool)
+ def set_acknowledged(self, acknowledged: bool) -> None:
+ """Tick or untick the confirmation that everyone logs in as the new account."""
+ self._wizard["ack"] = bool(acknowledged)
+ self.refresh()
+
+ @SafeSlot()
+ def confirm_switch(self) -> None:
+ """Send the switch to BEC Atlas once the change was confirmed."""
+ wizard = self._wizard
+ if not (wizard["open"] and wizard["ack"] and wizard["phase"] in ("review", "error")):
+ return
+ if self._deployment is None:
+ wizard.update({"phase": "error", "error": "No deployment information from BEC."})
+ self.refresh()
+ return
+ state = self.view_state()["wizard"]
+ wizard.update(
+ {"phase": "switching", "error": "", "frozen": (state["plan"], state["targetInfo"])}
+ )
+ self.refresh()
+ self.set_experiment(wizard["target"], self._deployment.deployment_id)
+
+ @SafeSlot()
+ def close_wizard(self) -> None:
+ """Leave the switch flow."""
+ if self._wizard["phase"] == "switching":
+ return
+ self._wizard = self._closed_wizard()
+ self.refresh()
+
+ ########################
+ ## Incoming data
+ ########################
+
+ @SafeSlot(dict, dict)
+ def _on_deployment_info(self, msg: dict, _meta: dict) -> None:
+ deployment = DeploymentInfoMessage.model_validate(msg)
+ self._deployment = deployment
+ session = deployment.active_session
+ if session is not None and session.experiment is not None:
+ self._active_info = session.experiment
+ self.atlas_http_service._set_current_deployment_info( # pylint: disable=protected-access
+ deployment
+ )
+ self.refresh()
+
+ @SafeSlot(dict, dict)
+ def _on_queue_status(self, content: dict, _meta: dict) -> None:
+ primary = (content.get("queue") or {}).get("primary")
+ info = getattr(primary, "info", None) or []
+ busy = any(
+ getattr(item, "status", "") not in ("STOPPED", "COMPLETED", "IDLE") for item in info
+ )
+ if busy != self._queue_busy:
+ self._queue_busy = busy
+ self.refresh()
+
+ def _on_http_response(self, response: dict) -> None:
+ response = HTTPResponse(**response)
+ if AtlasEndpoints.REALMS_EXPERIMENTS in response.request_url:
+ self._experiments = response.data if isinstance(response.data, list) else []
+ self._loading_experiments = False
+ if not self._selected:
+ _active, rows = self._summaries()
+ upcoming = filter_experiments(rows, "", "upcoming")
+ choice = next((r for r in rows if r["isNext"]), None) or next(
+ (r for r in upcoming if not r["isActive"]), None
+ )
+ self._selected = choice["pgroup"] if choice else ""
+ self.refresh()
+ elif AtlasEndpoints.SET_EXPERIMENT in response.request_url:
+ if self._wizard["open"]:
+ self._wizard.update({"phase": "done", "step": 2, "error": ""})
+ target = self._wizard.get("target", "")
+ for info in self._experiments:
+ if info.get("pgroup") == target:
+ # Show the new experiment right away; deployment info confirms it shortly.
+ self._active_info = ExperimentInfoMessage.model_validate(info)
+ break
+ self.refresh()
+ self.experiment_switched.emit(target)
+
+ @SafeSlot(str, str)
+ def _on_request_failed(self, kind: str, message: str) -> None:
+ if kind == "login":
+ self._signing_in = False
+ self._sign_in_error = message
+ elif kind == "switch" and self._wizard["open"]:
+ self._wizard.update({"phase": "error", "error": message})
+ elif kind == "experiments":
+ self._loading_experiments = False
+ self._sign_in_error = message
+ self.refresh()
+
+ @SafeSlot(dict)
+ def _on_authenticated(self, auth_info: dict) -> None:
+ self._signing_in = False
+ info = AuthenticatedUserInfo.model_validate(auth_info) if auth_info else None
+ if info is not None and (
+ self._deployment is None or info.deployment_id != self._deployment.deployment_id
+ ):
+ self._sign_in_error = "This account has no owner rights on this beamline deployment."
+ info = None
+ self._auth = info
+ if info is not None:
+ self._sign_in_error = ""
+ self._tick.start()
+ self._fetch_experiments()
+ else:
+ self._tick.stop()
+ self._experiments = []
+ self._selected = ""
+ self._wizard = self._closed_wizard()
+ self.refresh()
+ self.authenticated.emit(info is not None)
+
+ def _fetch_experiments(self) -> None:
+ realm = self._active_info.realm_id if self._active_info else None
+ if not realm:
+ self._sign_in_error = "The active experiment has no realm; cannot list experiments."
+ return
+ self._loading_experiments = True
+ self.atlas_http_service.get_experiments_for_realm(realm)
+
+ def _on_tick(self) -> None:
+ if self._auth is None:
+ self._tick.stop()
+ return
+ self._render_session_time(format_remaining(self._auth.exp - time.time()))
+
+ def _render_session_time(self, remaining: str) -> None:
+ """Show the remaining session time; views may override this with a cheaper update."""
+ self.refresh()
+
+ ########################
+ ## Cleanup
+ ########################
+
+ def cleanup(self):
+ """Stop the timer and close the Atlas session."""
+ self._tick.stop()
+ self.bec_dispatcher.disconnect_slot(
+ self._on_deployment_info, MessageEndpoints.deployment_info()
+ )
+ self.bec_dispatcher.disconnect_slot(
+ self._on_queue_status, MessageEndpoints.scan_queue_status()
+ )
+ self.atlas_http_service.cleanup()
+ super().cleanup()
diff --git a/bec_widgets/widgets/services/bec_atlas_admin_view/admin_ux/admin_view_qml.py b/bec_widgets/widgets/services/bec_atlas_admin_view/admin_ux/admin_view_qml.py
new file mode 100644
index 00000000..25e580b7
--- /dev/null
+++ b/bec_widgets/widgets/services/bec_atlas_admin_view/admin_ux/admin_view_qml.py
@@ -0,0 +1,135 @@
+"""Admin view rendered with Qt Quick (QML).
+
+Same UX as :mod:`admin_view_qwidget`; the state comes from
+:class:`~.admin_ux_common.AdminViewBase`.
+"""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+from qtpy.QtCore import Property, QObject, Signal, Slot
+from qtpy.QtWidgets import QVBoxLayout
+
+from bec_widgets.utils.quick import DictListModel, create_quick_widget, release_quick_widget
+from bec_widgets.widgets.services.bec_atlas_admin_view.admin_ux.admin_ux_common import (
+ DEFAULT_ATLAS_URL,
+ HOW_IT_WORKS,
+ AdminViewBase,
+)
+
+QML_DIR = Path(__file__).parent / "qml"
+
+ROW_ROLES = ["pgroup", "title", "pi", "beamtime", "status", "statusLabel", "isActive", "isNext"]
+
+
+class AdminBackend(QObject):
+ """Bridge between :class:`AdminViewQML` and ``AdminView.qml``."""
+
+ changed = Signal()
+
+ def __init__(self, view: AdminViewQML):
+ super().__init__(view)
+ self._view = view
+ self._state: dict = {}
+ self._rows = DictListModel(ROW_ROLES, self)
+
+ def update_from(self, state: dict) -> None:
+ """Take a new view state from the widget."""
+ state = dict(state)
+ rows = state.pop("rows")
+ if [r["pgroup"] for r in rows] != [r["pgroup"] for r in self._rows.items] or any(
+ new["status"] != old["status"] for new, old in zip(rows, self._rows.items)
+ ):
+ self._rows.set_items([{key: row[key] for key in ROW_ROLES} for row in rows])
+ state["rowCount"] = len(rows)
+ state["howItWorks"] = HOW_IT_WORKS
+ self._state = state
+ self.changed.emit()
+
+ # pylint: disable=invalid-name,missing-function-docstring
+ @Slot(str, str)
+ def login(self, username: str, password: str) -> None:
+ self._view.login(username.strip(), password)
+
+ @Slot()
+ def logout(self) -> None:
+ self._view.logout()
+
+ @Slot(str)
+ def openSection(self, section: str) -> None:
+ self._view.open_section(section)
+
+ @Slot(str)
+ def setQuery(self, query: str) -> None:
+ self._view.set_query(query)
+
+ @Slot(str)
+ def setScope(self, scope: str) -> None:
+ self._view.set_scope(scope)
+
+ @Slot(str)
+ def selectExperiment(self, pgroup: str) -> None:
+ self._view.select_experiment(pgroup)
+
+ @Slot(str)
+ def startSwitch(self, pgroup: str) -> None:
+ self._view.start_switch(pgroup)
+
+ @Slot()
+ def next(self) -> None:
+ self._view.wizard_next()
+
+ @Slot()
+ def back(self) -> None:
+ self._view.wizard_back()
+
+ @Slot(bool)
+ def setAcknowledged(self, value: bool) -> None:
+ self._view.set_acknowledged(value)
+
+ @Slot()
+ def confirm(self) -> None:
+ self._view.confirm_switch()
+
+ @Slot()
+ def closeWizard(self) -> None:
+ self._view.close_wizard()
+
+ def _get_state(self) -> dict:
+ return self._state
+
+ def _get_rows(self) -> DictListModel:
+ return self._rows
+
+ state = Property("QVariantMap", _get_state, notify=changed)
+ rows = Property(QObject, _get_rows, constant=True)
+
+
+class AdminViewQML(AdminViewBase):
+ """Admin view: sign in, see the active experiment and switch it with step-by-step guidance."""
+
+ def __init__(self, parent=None, atlas_url: str = DEFAULT_ATLAS_URL, client=None, **kwargs):
+ self.backend = None
+ self.view = None
+ super().__init__(parent=parent, atlas_url=atlas_url, client=client, **kwargs)
+ self.backend = AdminBackend(self)
+ self.backend.update_from(self.view_state())
+ layout = QVBoxLayout(self)
+ layout.setContentsMargins(0, 0, 0, 0)
+ self.view = create_quick_widget(self, QML_DIR / "AdminView.qml", {"backend": self.backend})
+ layout.addWidget(self.view)
+
+ def _render(self, state: dict) -> None:
+ if self.backend is not None:
+ self.backend.update_from(state)
+
+ def apply_theme(self, theme: str):
+ """Match the QML clear colour to the new theme."""
+ if self.view is not None:
+ self.view.setClearColor(self.palette().window().color())
+
+ def cleanup(self):
+ """Unload the QML scene before the backend goes away."""
+ release_quick_widget(self.view)
+ super().cleanup()
diff --git a/bec_widgets/widgets/services/bec_atlas_admin_view/admin_ux/admin_view_qwidget.py b/bec_widgets/widgets/services/bec_atlas_admin_view/admin_ux/admin_view_qwidget.py
new file mode 100644
index 00000000..be1b86af
--- /dev/null
+++ b/bec_widgets/widgets/services/bec_atlas_admin_view/admin_ux/admin_view_qwidget.py
@@ -0,0 +1,909 @@
+"""Admin view drawn with plain QWidgets.
+
+Same UX as :mod:`admin_view_qml`; the state comes from
+:class:`~.admin_ux_common.AdminViewBase`.
+"""
+
+from __future__ import annotations
+
+from bec_qthemes import material_icon
+from qtpy.QtCore import QSize, Qt, Signal
+from qtpy.QtWidgets import (
+ QButtonGroup,
+ QCheckBox,
+ QFrame,
+ QGridLayout,
+ QHBoxLayout,
+ QLabel,
+ QLineEdit,
+ QPushButton,
+ QScrollArea,
+ QSizePolicy,
+ QStackedWidget,
+ QVBoxLayout,
+ QWidget,
+)
+
+from bec_widgets.utils.quick.host import ThemeTokens
+from bec_widgets.utils.ux_kit import Card, StatusPill, TextButton, field_qss, refresh_kit_theme
+from bec_widgets.widgets.services.bec_atlas_admin_view.admin_ux.admin_ux_common import (
+ DEFAULT_ATLAS_URL,
+ HOW_IT_WORKS,
+ AdminViewBase,
+)
+
+
+def _label(text: str = "", role: str = "", parent: QWidget | None = None, wrap=False) -> QLabel:
+ label = QLabel(text, parent)
+ if role:
+ label.setProperty("role", role)
+ label.setWordWrap(wrap)
+ label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
+ return label
+
+
+def _clear(layout) -> None:
+ while layout.count():
+ item = layout.takeAt(0)
+ if item.widget() is not None:
+ item.widget().deleteLater()
+ elif item.layout() is not None:
+ _clear(item.layout())
+
+
+def tone_color(tokens: ThemeTokens, tone: str):
+ """Colour of a tone used by banners, chips and step markers."""
+ return {
+ "ok": tokens.success,
+ "warning": tokens.warning,
+ "danger": tokens.danger,
+ "info": tokens.accent,
+ }.get(tone, tokens.fg_muted)
+
+
+class IconLabel(QLabel):
+ """Material icon as a label."""
+
+ def __init__(self, name: str, size: int = 18, parent: QWidget | None = None):
+ super().__init__(parent)
+ self._name = name
+ self._size = size
+ self.setFixedSize(size, size)
+
+ def set_icon(self, name: str, color) -> None:
+ """Draw ``name`` in ``color``."""
+ self._name = name
+ self.setPixmap(material_icon(name, size=(self._size, self._size), color=color))
+
+
+class Banner(QFrame):
+ """Tinted message box with an icon, a bold title and a text."""
+
+ def __init__(self, parent: QWidget | None = None):
+ super().__init__(parent)
+ self.setObjectName("banner")
+ layout = QHBoxLayout(self)
+ layout.setContentsMargins(12, 10, 12, 10)
+ layout.setSpacing(10)
+ self.icon = IconLabel("info", 20, self)
+ layout.addWidget(self.icon, 0, Qt.AlignmentFlag.AlignTop)
+ text = QVBoxLayout()
+ text.setSpacing(2)
+ self.title = _label("", "strong", self, wrap=True)
+ self.text = _label("", "muted", self, wrap=True)
+ text.addWidget(self.title)
+ text.addWidget(self.text)
+ layout.addLayout(text, 1)
+ self._tone = "info"
+ self._key = None
+
+ def set_message(self, tone: str, title: str, text: str, tokens: ThemeTokens) -> None:
+ """Show a message in the colours of ``tone``."""
+ key = (tone, title, text, tokens.name, tokens.card.name())
+ if key == self._key:
+ return
+ self._key = key
+ self._tone = tone
+ color = tone_color(tokens, tone)
+ icon = {"ok": "check_circle", "warning": "warning", "danger": "error"}.get(tone, "info")
+ self.icon.set_icon(icon, color)
+ self.title.setText(title)
+ self.text.setText(text)
+ self.text.setVisible(bool(text))
+ self.setStyleSheet(
+ f"QFrame#banner {{ background: {tokens.soft(color, 0.14).name()};"
+ f" border: 1px solid {tokens.soft(color, 0.45).name()}; border-radius: 8px; }}"
+ )
+
+
+class ExperimentRow(QFrame):
+ """One clickable experiment in the switch list."""
+
+ clicked = Signal(str)
+
+ def __init__(self, row: dict, selected: bool, tokens: ThemeTokens, parent=None):
+ super().__init__(parent)
+ self.pgroup = row["pgroup"]
+ self.setObjectName("expRow")
+ self.setCursor(Qt.CursorShape.PointingHandCursor)
+ self.setProperty("selected", selected)
+ layout = QGridLayout(self)
+ layout.setContentsMargins(12, 8, 12, 8)
+ layout.setHorizontalSpacing(10)
+ layout.setVerticalSpacing(2)
+ pgroup = _label(row["pgroup"], "rowKey", self)
+ pgroup.setTextInteractionFlags(Qt.TextInteractionFlag.NoTextInteraction)
+ title = _label(row["title"], "", self)
+ title.setTextInteractionFlags(Qt.TextInteractionFlag.NoTextInteraction)
+ title.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred)
+ meta = _label(f"{row['pi']} · {row['beamtime']}", "muted", self)
+ meta.setTextInteractionFlags(Qt.TextInteractionFlag.NoTextInteraction)
+ meta.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred)
+ layout.addWidget(pgroup, 0, 0)
+ layout.addWidget(title, 0, 1)
+ layout.addWidget(meta, 1, 1)
+ if row["status"] in ("active", "now", "next", "past"):
+ pill = StatusPill(self)
+ tone = {"active": tokens.success, "now": tokens.warning, "next": tokens.primary}.get(
+ row["status"], tokens.fg_subtle
+ )
+ pill.set_status(row["statusLabel"], tone)
+ layout.addWidget(pill, 0, 2, 2, 1, Qt.AlignmentFlag.AlignVCenter)
+ layout.setColumnStretch(1, 1)
+
+ def mousePressEvent(self, event): # pylint: disable=invalid-name
+ """Select this experiment."""
+ self.clicked.emit(self.pgroup)
+ super().mousePressEvent(event)
+
+
+class Stepper(QWidget):
+ """Three numbered steps with labels; done steps show a tick."""
+
+ def __init__(self, labels: list[str], parent=None):
+ super().__init__(parent)
+ layout = QHBoxLayout(self)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.setSpacing(8)
+ self._dots: list[QLabel] = []
+ self._labels: list[QLabel] = []
+ self._lines: list[QFrame] = []
+ for i, text in enumerate(labels):
+ if i:
+ line = QFrame(self)
+ line.setFixedHeight(2)
+ line.setMinimumWidth(24)
+ self._lines.append(line)
+ layout.addWidget(line, 1)
+ dot = QLabel(str(i + 1), self)
+ dot.setFixedSize(24, 24)
+ dot.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ label = QLabel(text, self)
+ self._dots.append(dot)
+ self._labels.append(label)
+ layout.addWidget(dot)
+ layout.addWidget(label)
+
+ def set_step(self, step: int, done: bool, tokens: ThemeTokens) -> None:
+ """Highlight ``step``; earlier steps (and ``step`` itself if ``done``) show a tick."""
+ for i, (dot, label) in enumerate(zip(self._dots, self._labels)):
+ finished = i < step or (done and i == step)
+ current = i == step and not done
+ if finished:
+ fill, fg, text = tokens.success, "white", "✓"
+ elif current:
+ fill, fg, text = tokens.primary, "white", str(i + 1)
+ else:
+ fill, fg, text = tokens.track, tokens.fg_muted.name(), str(i + 1)
+ dot.setText(text)
+ dot.setStyleSheet(
+ f"background: {fill.name()}; color: {fg}; border-radius: 12px;"
+ f" font-size: 12px; font-weight: 700;"
+ )
+ weight = 600 if current or finished else 400
+ color = tokens.fg if current or finished else tokens.fg_muted
+ label.setStyleSheet(f"color: {color.name()}; font-size: 13px; font-weight: {weight};")
+ for i, line in enumerate(self._lines):
+ color = tokens.success if i < step or (done and i + 1 <= step) else tokens.border
+ line.setStyleSheet(f"background: {color.name()};")
+
+
+class AdminViewQWidget(AdminViewBase):
+ """Admin view: sign in, see the active experiment and switch it with step-by-step guidance."""
+
+ def __init__(self, parent=None, atlas_url: str = DEFAULT_ATLAS_URL, client=None, **kwargs):
+ super().__init__(parent=parent, atlas_url=atlas_url, client=client, **kwargs)
+ self._tokens = ThemeTokens()
+ self._rows_key = None
+ self._wizard_key = None
+ self._nav_key = None
+
+ root = QVBoxLayout(self)
+ root.setContentsMargins(0, 0, 0, 0)
+ root.setSpacing(0)
+ root.addWidget(self._build_header())
+ self._pages = QStackedWidget(self)
+ self._pages.addWidget(self._build_signed_out())
+ self._pages.addWidget(self._build_signed_in())
+ root.addWidget(self._pages, 1)
+ self._apply_styles()
+ self.refresh()
+
+ ########################
+ ## Building
+ ########################
+
+ def _build_header(self) -> QWidget:
+ header = QFrame(self)
+ header.setObjectName("adminHeader")
+ layout = QHBoxLayout(header)
+ layout.setContentsMargins(16, 10, 16, 10)
+ layout.setSpacing(10)
+ self._header_icon = IconLabel("admin_panel_settings", 26, header)
+ layout.addWidget(self._header_icon)
+ titles = QVBoxLayout()
+ titles.setSpacing(0)
+ self._header_title = _label("Beamline admin", "h2", header)
+ self._header_sub = _label("", "muted", header)
+ titles.addWidget(self._header_title)
+ titles.addWidget(self._header_sub)
+ layout.addLayout(titles, 1)
+ self._session_pill = StatusPill(header)
+ layout.addWidget(self._session_pill)
+ self._sign_out = TextButton("Sign out", "neutral", "logout", header)
+ self._sign_out.clicked.connect(self.logout)
+ layout.addWidget(self._sign_out)
+ return header
+
+ def _active_card(self, parent: QWidget) -> tuple[Card, dict]:
+ card = Card("Active experiment", parent, padding=16)
+ widgets: dict = {}
+ top = QHBoxLayout()
+ widgets["pgroup"] = _label("", "h1", card)
+ widgets["pill"] = StatusPill(card)
+ top.addWidget(widgets["pgroup"])
+ top.addWidget(widgets["pill"])
+ top.addStretch(1)
+ card.body.addLayout(top)
+ widgets["title"] = _label("", "strong", card, wrap=True)
+ card.body.addWidget(widgets["title"])
+ grid = QGridLayout()
+ grid.setHorizontalSpacing(16)
+ grid.setVerticalSpacing(6)
+ for i, (key, text) in enumerate(
+ [
+ ("pi", "PI"),
+ ("linuxAccount", "Linux login"),
+ ("dataAccount", "Data saved to"),
+ ("beamtime", "Beamtime"),
+ ]
+ ):
+ grid.addWidget(_label(text, "muted", card), i, 0)
+ role = "chip" if key in ("linuxAccount", "dataAccount") else ""
+ widgets[key] = _label("", role, card)
+ row = QHBoxLayout()
+ row.addWidget(widgets[key])
+ row.addStretch(1)
+ grid.addLayout(row, i, 1)
+ grid.setColumnStretch(1, 1)
+ card.body.addLayout(grid)
+ widgets["empty"] = _label("BEC reports no active experiment.", "muted", card)
+ card.body.addWidget(widgets["empty"])
+ return card, widgets
+
+ def _build_signed_out(self) -> QWidget:
+ page = QWidget(self)
+ outer = QHBoxLayout(page)
+ outer.setContentsMargins(24, 24, 24, 24)
+ outer.addStretch(1)
+ column = QHBoxLayout()
+ column.setSpacing(16)
+ left = QVBoxLayout()
+ left.setSpacing(12)
+ self._out_notice = Banner(page)
+ card, self._out_active = self._active_card(page)
+ card.setMinimumWidth(380)
+ left.addWidget(card)
+ left.addWidget(self._out_notice)
+ left.addStretch(1)
+ column.addLayout(left, 1)
+
+ sign_in = Card("Staff sign-in", page, padding=20)
+ sign_in.setFixedWidth(360)
+ self._sign_in_text = _label("", "muted", sign_in, wrap=True)
+ sign_in.body.addWidget(self._sign_in_text)
+ sign_in.body.addSpacing(4)
+ self._username = QLineEdit(sign_in)
+ self._username.setPlaceholderText("PSI username")
+ self._password = QLineEdit(sign_in)
+ self._password.setPlaceholderText("Password")
+ self._password.setEchoMode(QLineEdit.EchoMode.Password)
+ self._username.returnPressed.connect(self._password.setFocus)
+ self._password.returnPressed.connect(self._submit_login)
+ sign_in.body.addWidget(self._username)
+ sign_in.body.addWidget(self._password)
+ self._sign_in_error_label = _label("", "error", sign_in, wrap=True)
+ sign_in.body.addWidget(self._sign_in_error_label)
+ self._sign_in_button = TextButton("Sign in", "primary", "login", sign_in)
+ self._sign_in_button.clicked.connect(self._submit_login)
+ sign_in.body.addWidget(self._sign_in_button)
+ hint = _label(
+ "Signing in does not change anything yet. Switching the experiment asks for "
+ "confirmation first.",
+ "subtle",
+ sign_in,
+ wrap=True,
+ )
+ sign_in.body.addWidget(hint)
+ right = QVBoxLayout()
+ right.addWidget(sign_in)
+ right.addStretch(1)
+ column.addLayout(right)
+
+ holder = QWidget(page)
+ holder.setLayout(column)
+ holder.setMaximumWidth(980)
+ outer.addWidget(holder, 100)
+ outer.addStretch(1)
+ return page
+
+ def _build_signed_in(self) -> QWidget:
+ page = QWidget(self)
+ layout = QHBoxLayout(page)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.setSpacing(0)
+ nav = QFrame(page)
+ nav.setObjectName("adminNav")
+ nav.setFixedWidth(184)
+ self._nav_layout = QVBoxLayout(nav)
+ self._nav_layout.setContentsMargins(10, 14, 10, 14)
+ self._nav_layout.setSpacing(4)
+ layout.addWidget(nav)
+
+ self._content = QStackedWidget(page)
+ self._content.addWidget(self._build_browse())
+ self._content.addWidget(self._build_wizard())
+ layout.addWidget(self._content, 1)
+ return page
+
+ def _build_browse(self) -> QWidget:
+ page = QWidget(self)
+ layout = QVBoxLayout(page)
+ layout.setContentsMargins(20, 16, 20, 16)
+ layout.setSpacing(12)
+ top = QHBoxLayout()
+ top.setSpacing(12)
+ card, self._in_active = self._active_card(page)
+ top.addWidget(card, 3, Qt.AlignmentFlag.AlignTop)
+ self._in_notice = Banner(page)
+ notice_col = QVBoxLayout()
+ notice_col.setSpacing(12)
+ notice_col.addWidget(self._in_notice)
+ notice_col.addWidget(self._how_card(page))
+ notice_col.addStretch(1)
+ top.addLayout(notice_col, 2)
+ layout.addLayout(top)
+
+ switch = Card("Switch experiment", page, padding=16)
+ switch.body.setSpacing(10)
+ tools = QHBoxLayout()
+ self._search = QLineEdit(switch)
+ self._search.setPlaceholderText("Search p-group, title, PI or account")
+ self._search.setClearButtonEnabled(True)
+ self._search.textChanged.connect(self.set_query)
+ tools.addWidget(self._search, 1)
+ self._scope_group = QButtonGroup(switch)
+ for scope, text in (("upcoming", "Upcoming"), ("all", "All")):
+ button = QPushButton(text, switch)
+ button.setCheckable(True)
+ button.setProperty("segment", scope)
+ button.setCursor(Qt.CursorShape.PointingHandCursor)
+ button.clicked.connect(lambda _=False, s=scope: self.set_scope(s))
+ self._scope_group.addButton(button)
+ tools.addWidget(button)
+ switch.body.addLayout(tools)
+
+ split = QHBoxLayout()
+ split.setSpacing(12)
+ self._list_area = QScrollArea(switch)
+ self._list_area.setWidgetResizable(True)
+ self._list_area.setFrameShape(QFrame.Shape.NoFrame)
+ holder = QWidget()
+ holder.setObjectName("listHolder")
+ self._list_layout = QVBoxLayout(holder)
+ self._list_layout.setContentsMargins(0, 0, 4, 0)
+ self._list_layout.setSpacing(4)
+ self._list_area.setWidget(holder)
+ split.addWidget(self._list_area, 3)
+
+ detail = QFrame(switch)
+ detail.setObjectName("detail")
+ detail_layout = QVBoxLayout(detail)
+ detail_layout.setContentsMargins(14, 12, 14, 12)
+ detail_layout.setSpacing(6)
+ self._detail = {
+ "pgroup": _label("", "h2", detail),
+ "title": _label("", "strong", detail, wrap=True),
+ "pi": _label("", "muted", detail),
+ "beamtime": _label("", "muted", detail, wrap=True),
+ "account": _label("", "", detail, wrap=True),
+ "abstract": _label("", "subtle", detail, wrap=True),
+ }
+ for widget in self._detail.values():
+ detail_layout.addWidget(widget)
+ detail_layout.addStretch(1)
+ self._switch_button = TextButton("Switch…", "primary", "swap_horiz", detail)
+ self._switch_button.clicked.connect(lambda: self.start_switch(""))
+ detail_layout.addWidget(self._switch_button)
+ self._detail_empty = _label("Select an experiment to see its details.", "muted", detail)
+ detail_layout.addWidget(self._detail_empty)
+ split.addWidget(detail, 2)
+ switch.body.addLayout(split, 1)
+ switch.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Expanding)
+ layout.addWidget(switch, 1)
+ return page
+
+ def _how_card(self, parent: QWidget) -> Card:
+ card = Card("How switching works", parent, padding=14)
+ card.body.setSpacing(6)
+ self._how_rows = []
+ for i, step in enumerate(HOW_IT_WORKS, start=1):
+ row = QHBoxLayout()
+ row.setSpacing(8)
+ number = QLabel(str(i), card)
+ number.setFixedSize(20, 20)
+ number.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ self._how_rows.append(number)
+ row.addWidget(number, 0, Qt.AlignmentFlag.AlignTop)
+ row.addWidget(_label(step, "muted", card, wrap=True), 1)
+ card.body.addLayout(row)
+ return card
+
+ def _build_wizard(self) -> QWidget:
+ page = QWidget(self)
+ outer = QHBoxLayout(page)
+ outer.setContentsMargins(20, 16, 20, 16)
+ outer.addStretch(1)
+ card = Card("", page, padding=20)
+ card.setMaximumWidth(780)
+ card.setMinimumWidth(560)
+ card.body.setSpacing(14)
+ head = QHBoxLayout()
+ head_text = QVBoxLayout()
+ head_text.setSpacing(2)
+ self._wiz_title = _label("Switch experiment", "h2", card)
+ self._wiz_sub = _label("", "muted", card)
+ head_text.addWidget(self._wiz_title)
+ head_text.addWidget(self._wiz_sub)
+ head.addLayout(head_text, 1)
+ card.body.addLayout(head)
+ self._stepper = Stepper(["Review", "Confirm", "Log in"], card)
+ card.body.addWidget(self._stepper)
+ line = QFrame(card)
+ line.setObjectName("rule")
+ line.setFixedHeight(1)
+ card.body.addWidget(line)
+ self._wiz_body = QVBoxLayout()
+ self._wiz_body.setSpacing(10)
+ card.body.addLayout(self._wiz_body)
+ self._ack = QCheckBox(card)
+ self._ack.toggled.connect(self.set_acknowledged)
+ card.body.addWidget(self._ack)
+ self._wiz_error = Banner(card)
+ card.body.addWidget(self._wiz_error)
+ card.body.addStretch(1)
+ footer = QHBoxLayout()
+ self._wiz_cancel = TextButton("Cancel", "ghost", "", card)
+ self._wiz_cancel.clicked.connect(self.close_wizard)
+ self._wiz_back = TextButton("Back", "neutral", "arrow_back", card)
+ self._wiz_back.clicked.connect(self.wizard_back)
+ self._wiz_primary = TextButton("Next", "primary", "arrow_forward", card)
+ self._wiz_primary.clicked.connect(self._wizard_primary)
+ footer.addWidget(self._wiz_cancel)
+ footer.addStretch(1)
+ footer.addWidget(self._wiz_back)
+ footer.addWidget(self._wiz_primary)
+ card.body.addLayout(footer)
+ column = QVBoxLayout()
+ column.addWidget(card)
+ column.addStretch(1)
+ outer.addLayout(column, 100)
+ outer.addStretch(1)
+ return page
+
+ ########################
+ ## Actions
+ ########################
+
+ def _submit_login(self) -> None:
+ self.login(self._username.text().strip(), self._password.text())
+ self._password.clear()
+
+ def _wizard_primary(self) -> None:
+ step = self._wizard["step"]
+ if step == 0:
+ self.wizard_next()
+ elif step == 1:
+ self.confirm_switch()
+ else:
+ self.close_wizard()
+
+ ########################
+ ## Rendering
+ ########################
+
+ def apply_theme(self, theme: str):
+ """Re-draw everything in the new theme."""
+ self._tokens = refresh_kit_theme(self)
+ self._rows_key = self._wizard_key = self._nav_key = None
+ self._apply_styles()
+ self.refresh()
+
+ def _apply_styles(self) -> None:
+ t = self._tokens
+ mono = "'JetBrains Mono', 'DejaVu Sans Mono', monospace"
+ self.setStyleSheet(field_qss(t) + f"""
+ QLabel {{ color: {t.fg.name()}; font-size: 13px; background: transparent; }}
+ QLabel[role="h1"] {{ font-size: 22px; font-weight: 700; }}
+ QLabel[role="h2"] {{ font-size: 16px; font-weight: 650; }}
+ QLabel[role="strong"] {{ font-weight: 600; }}
+ QLabel[role="muted"] {{ color: {t.fg_muted.name()}; }}
+ QLabel[role="subtle"] {{ color: {t.fg_subtle.name()}; font-size: 12px; }}
+ QLabel[role="error"] {{ color: {t.danger.name()}; }}
+ QLabel[role="rowKey"] {{ font-family: {mono}; font-weight: 600; }}
+ QLabel[role="chip"] {{ font-family: {mono}; font-weight: 600;
+ background: {t.soft(t.primary, 0.14).name()}; color: {t.fg.name()};
+ border-radius: 4px; padding: 1px 6px; }}
+ QLabel[role="bigChip"] {{ font-family: {mono}; font-weight: 700; font-size: 20px;
+ background: {t.soft(t.primary, 0.18).name()}; color: {t.fg.name()};
+ border: 1px solid {t.soft(t.primary, 0.5).name()};
+ border-radius: 6px; padding: 4px 12px; }}
+ QFrame#adminHeader {{ background: {t.card.name()};
+ border-bottom: 1px solid {t.border.name()}; }}
+ QFrame#adminNav {{ background: {t.card.name()};
+ border-right: 1px solid {t.border.name()}; }}
+ QFrame#detail {{ background: {t.field.name()}; border: 1px solid {t.border.name()};
+ border-radius: 8px; }}
+ QFrame#rule {{ background: {t.border.name()}; }}
+ QWidget#listHolder {{ background: transparent; }}
+ QScrollArea {{ background: transparent; }}
+ QFrame#expRow {{ background: transparent; border: 1px solid transparent;
+ border-radius: 8px; }}
+ QFrame#expRow:hover {{ background: {t.hover.name()}; }}
+ QFrame#expRow[selected="true"] {{ background: {t.soft(t.primary, 0.14).name()};
+ border: 1px solid {t.soft(t.primary, 0.6).name()}; }}
+ QPushButton[segment] {{ background: transparent; color: {t.fg_muted.name()};
+ border: 1px solid {t.border.name()}; padding: 0 12px; min-height: 30px;
+ font-size: 13px; }}
+ QPushButton[segment="upcoming"] {{ border-top-left-radius: 6px;
+ border-bottom-left-radius: 6px; }}
+ QPushButton[segment="all"] {{ border-top-right-radius: 6px;
+ border-bottom-right-radius: 6px; border-left: none; }}
+ QPushButton[segment]:checked {{ background: {t.soft(t.primary, 0.18).name()};
+ color: {t.fg.name()}; font-weight: 600; }}
+ QPushButton[nav] {{ text-align: left; padding: 0 10px; min-height: 34px;
+ border: none; border-radius: 6px; color: {t.fg.name()}; font-size: 13px;
+ background: transparent; }}
+ QPushButton[nav]:hover {{ background: {t.hover.name()}; }}
+ QPushButton[nav]:checked {{ background: {t.soft(t.primary, 0.18).name()};
+ font-weight: 600; }}
+ QPushButton[nav]:disabled {{ color: {t.fg_subtle.name()}; }}
+ QCheckBox {{ color: {t.fg.name()}; font-size: 13px; font-weight: 600;
+ spacing: 8px; }}
+ """)
+ self._header_icon.set_icon("admin_panel_settings", t.primary)
+ for number in getattr(self, "_how_rows", []):
+ number.setStyleSheet(
+ f"background: {t.soft(t.primary, 0.2).name()}; color: {t.fg.name()};"
+ " border-radius: 10px; font-weight: 700; font-size: 11px;"
+ )
+
+ def _render(self, state: dict) -> None:
+ if not hasattr(self, "_pages"):
+ return # called by the base class before the widgets exist
+ t = self._tokens
+ realm = f" · {state['realm']}" if state["realm"] else ""
+ self._header_sub.setText(f"{state['deploymentName']}{realm}")
+ if state["signedIn"]:
+ self._session_pill.set_status(
+ f"{state['email']} · {state['remaining']} left", t.success
+ )
+ else:
+ self._session_pill.set_status("Not signed in", t.fg_subtle)
+ self._sign_out.setVisible(state["signedIn"])
+ page = 1 if state["signedIn"] else 0
+ if page != self._pages.currentIndex():
+ self._pages.setCurrentIndex(page)
+ # do not drop the user into the search field (or keep focus on the password field)
+ self._pages.currentWidget().setFocus()
+
+ for widgets in (self._out_active, self._in_active):
+ self._fill_active(widgets, state)
+ for banner in (self._out_notice, self._in_notice):
+ notice = state["notice"]
+ banner.setVisible(bool(notice))
+ if notice:
+ banner.set_message(notice["tone"], notice["title"], notice["text"], t)
+
+ self._sign_in_text.setText(
+ "Sign in with your PSI account to switch the active experiment. Your account needs "
+ f"owner rights on {state['deploymentName']}."
+ )
+ self._sign_in_error_label.setText(state["signInError"])
+ self._sign_in_error_label.setVisible(bool(state["signInError"]))
+ self._sign_in_button.setEnabled(not state["signingIn"])
+ self._sign_in_button.setText("Signing in…" if state["signingIn"] else "Sign in")
+
+ if state["signedIn"]:
+ self._render_nav(state)
+ self._render_browse(state)
+ self._render_wizard(state)
+ self._content.setCurrentIndex(1 if state["wizard"]["open"] else 0)
+
+ def _render_session_time(self, remaining: str) -> None:
+ """Only the session pill changes every second."""
+ if self._auth is not None:
+ self._session_pill.set_status(
+ f"{self._auth.email} · {remaining} left", self._tokens.success
+ )
+
+ def _fill_active(self, widgets: dict, state: dict) -> None:
+ active = state["active"]
+ has = state["hasActive"]
+ for key in ("pgroup", "pill", "title", "pi", "linuxAccount", "dataAccount", "beamtime"):
+ widgets[key].setVisible(has)
+ widgets["empty"].setVisible(not has)
+ if not has:
+ return
+ widgets["pgroup"].setText(active["pgroup"])
+ widgets["pill"].set_status("Active", self._tokens.success)
+ widgets["title"].setText(active["title"])
+ widgets["pi"].setText(active["pi"])
+ widgets["linuxAccount"].setText(active["linuxAccount"] or "unknown")
+ widgets["dataAccount"].setText(active["dataAccount"])
+ widgets["beamtime"].setText(active["beamtime"])
+
+ def _render_nav(self, state: dict) -> None:
+ key = (state["section"], tuple(s["id"] for s in state["sections"]))
+ if key == self._nav_key:
+ return
+ self._nav_key = key
+ _clear(self._nav_layout)
+ for section in state["sections"]:
+ text = section["label"] + (f" · {section['badge']}" if section["badge"] else "")
+ button = QPushButton(text, self)
+ button.setProperty("nav", True)
+ button.setCheckable(True)
+ button.setChecked(section["id"] == state["section"])
+ button.setEnabled(section["enabled"])
+ color = self._tokens.fg if section["enabled"] else self._tokens.fg_subtle
+ button.setIcon(
+ material_icon(section["icon"], size=(32, 32), color=color, convert_to_pixmap=False)
+ )
+ button.setIconSize(QSize(18, 18))
+ button.clicked.connect(lambda _=False, s=section["id"]: self.open_section(s))
+ self._nav_layout.addWidget(button)
+ self._nav_layout.addStretch(1)
+
+ def _render_browse(self, state: dict) -> None:
+ for button in self._scope_group.buttons():
+ button.setChecked(button.property("segment") == state["scope"])
+ if self._search.text() != state["query"]:
+ self._search.setText(state["query"])
+ selected = state["selected"]
+ rows_key = (
+ tuple((r["pgroup"], r["status"]) for r in state["rows"]),
+ selected.get("pgroup", ""),
+ state["loadingExperiments"],
+ )
+ if rows_key != self._rows_key:
+ self._rows_key = rows_key
+ _clear(self._list_layout)
+ if state["loadingExperiments"]:
+ self._list_layout.addWidget(_label("Loading experiments…", "muted"))
+ elif not state["rows"]:
+ text = (
+ "No experiment matches the search."
+ if state["totalCount"]
+ else "Atlas lists no experiments for this beamline."
+ )
+ self._list_layout.addWidget(_label(text, "muted"))
+ for row in state["rows"]:
+ widget = ExperimentRow(
+ row, row["pgroup"] == selected.get("pgroup"), self._tokens, self
+ )
+ widget.clicked.connect(self.select_experiment)
+ self._list_layout.addWidget(widget)
+ self._list_layout.addStretch(1)
+
+ has = bool(selected)
+ for widget in self._detail.values():
+ widget.setVisible(has)
+ self._switch_button.setVisible(has)
+ self._detail_empty.setVisible(not has)
+ if has:
+ self._detail["pgroup"].setText(selected["pgroup"])
+ self._detail["title"].setText(selected["title"])
+ self._detail["pi"].setText(f"PI: {selected['pi']}")
+ self._detail["beamtime"].setText(selected["beamtime"])
+ self._detail["account"].setText(
+ f"Group logs in as {selected['linuxAccount'] or 'unknown'}"
+ )
+ abstract = selected["abstract"]
+ self._detail["abstract"].setText(
+ abstract if len(abstract) < 260 else abstract[:257] + "…"
+ )
+ self._detail["abstract"].setVisible(bool(abstract))
+ self._switch_button.setEnabled(state["canSwitch"])
+ self._switch_button.setText(
+ "This is the active experiment"
+ if selected["isActive"]
+ else f"Switch to {selected['pgroup']}…"
+ )
+
+ def _render_wizard(self, state: dict) -> None:
+ wizard = state["wizard"]
+ if not wizard["open"]:
+ self._wizard_key = None
+ return
+ t = self._tokens
+ target = wizard["targetInfo"]
+ plan = wizard["plan"]
+ step, phase = wizard["step"], wizard["phase"]
+ self._wiz_title.setText("Experiment switched" if phase == "done" else "Switch experiment")
+ self._wiz_sub.setText(f"{wizard['fromPgroup']} → {target.get('pgroup', '')}")
+ self._stepper.set_step(step, phase == "done", t)
+
+ key = (step, phase, target.get("pgroup"), state["queueBusy"])
+ if key != self._wizard_key:
+ self._wizard_key = key
+ _clear(self._wiz_body)
+ if step == 0:
+ self._fill_review(plan)
+ elif step == 1:
+ self._fill_confirm(plan)
+ else:
+ self._fill_login(plan, state)
+
+ self._ack.setVisible(step == 1)
+ self._ack.setText(plan.get("ackText", ""))
+ self._ack.blockSignals(True)
+ self._ack.setChecked(wizard["ack"])
+ self._ack.blockSignals(False)
+ self._ack.setEnabled(phase != "switching")
+ self._wiz_error.setVisible(bool(wizard["error"]))
+ if wizard["error"]:
+ self._wiz_error.set_message("danger", "The switch failed", wizard["error"], t)
+
+ self._wiz_cancel.setVisible(phase not in ("done", "switching"))
+ self._wiz_back.setVisible(step > 0 and phase not in ("done", "switching"))
+ if step == 0:
+ text, variant, icon, enabled = "Next", "primary", "arrow_forward", True
+ elif step == 1:
+ text = "Switching…" if phase == "switching" else f"Switch to {target.get('pgroup')}"
+ variant, icon, enabled = "danger", "swap_horiz", wizard["canConfirm"]
+ else:
+ text, variant, icon, enabled = "Done", "primary", "check", True
+ self._wiz_primary.setText(text)
+ self._wiz_primary._icon_name = icon # pylint: disable=protected-access
+ self._wiz_primary.set_variant(variant)
+ self._wiz_primary.setEnabled(enabled)
+
+ def _fill_review(self, plan: dict) -> None:
+ t = self._tokens
+ intro = _label(
+ "Check that this is the right experiment. Switching also changes which Linux account "
+ "the group uses on this computer.",
+ "muted",
+ wrap=True,
+ )
+ self._wiz_body.addWidget(intro)
+ grid = QGridLayout()
+ grid.setHorizontalSpacing(12)
+ grid.setVerticalSpacing(0)
+ for col, text in ((1, "Now"), (3, "After switching")):
+ grid.addWidget(_label(text, "subtle"), 0, col)
+ for i, change in enumerate(plan["changes"], start=1):
+ row_frame = QFrame()
+ row_frame.setStyleSheet(
+ f"background: {t.soft(t.warning, 0.16).name()}; border-radius: 6px;"
+ if change["key"]
+ else "background: transparent;"
+ )
+ grid.addWidget(row_frame, i, 0, 1, 4)
+ role = "chip" if change["key"] else ""
+ label = _label(change["label"], "strong" if change["key"] else "muted")
+ label.setContentsMargins(8, 7, 0, 7)
+ grid.addWidget(label, i, 0)
+ before = _label(change["before"], "muted")
+ before.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred)
+ grid.addWidget(before, i, 1)
+ arrow = IconLabel("arrow_forward", 16)
+ arrow.set_icon("arrow_forward", t.fg_subtle)
+ grid.addWidget(arrow, i, 2)
+ after = _label(change["after"], role or "strong")
+ after.setSizePolicy(
+ QSizePolicy.Policy.Maximum if role else QSizePolicy.Policy.Ignored,
+ QSizePolicy.Policy.Fixed,
+ )
+ holder = QHBoxLayout()
+ holder.setContentsMargins(0, 0, 8, 0)
+ holder.addWidget(after)
+ if role:
+ holder.addStretch(1)
+ grid.addLayout(holder, i, 3)
+ grid.setColumnStretch(1, 1)
+ grid.setColumnStretch(3, 1)
+ self._wiz_body.addLayout(grid)
+
+ def _fill_confirm(self, plan: dict) -> None:
+ t = self._tokens
+ self._wiz_body.addWidget(_label("What happens when you switch", "strong"))
+ for item in plan["consequences"]:
+ row = QHBoxLayout()
+ row.setSpacing(10)
+ icon = IconLabel(item["icon"], 20)
+ icon.set_icon(item["icon"], tone_color(t, item["tone"]))
+ row.addWidget(icon, 0, Qt.AlignmentFlag.AlignTop)
+ text = QVBoxLayout()
+ text.setSpacing(1)
+ text.addWidget(_label(item["title"], "strong", wrap=True))
+ text.addWidget(_label(item["text"], "muted", wrap=True))
+ row.addLayout(text, 1)
+ self._wiz_body.addLayout(row)
+ box = QFrame()
+ box.setObjectName("loginBox")
+ box.setStyleSheet(
+ f"QFrame#loginBox {{ background: {t.soft(t.warning, 0.14).name()};"
+ f" border: 1px solid {t.soft(t.warning, 0.5).name()}; border-radius: 8px; }}"
+ )
+ box_layout = QHBoxLayout(box)
+ box_layout.setContentsMargins(12, 10, 12, 10)
+ icon = IconLabel("login", 22, box)
+ icon.set_icon("login", t.warning)
+ box_layout.addWidget(icon)
+ box_layout.addWidget(
+ _label("Afterwards the group must log in to this computer as", "strong", box)
+ )
+ box_layout.addWidget(_label(plan["linuxAccount"], "chip", box))
+ box_layout.addStretch(1)
+ self._wiz_body.addWidget(box)
+
+ def _fill_login(self, plan: dict, state: dict) -> None:
+ t = self._tokens
+ banner = Banner()
+ banner.set_message(
+ "ok",
+ f"{state['wizard']['targetInfo'].get('pgroup', '')} is now the active experiment",
+ "New scans are saved for the new experiment. One step is left on this computer:",
+ t,
+ )
+ self._wiz_body.addWidget(banner)
+ account_row = QHBoxLayout()
+ account_row.addWidget(_label("Log in as", "h2"))
+ account_row.addWidget(_label(plan["linuxAccount"], "bigChip"))
+ account_row.addStretch(1)
+ self._wiz_body.addLayout(account_row)
+ for i, step in enumerate(plan["loginSteps"], start=1):
+ row = QHBoxLayout()
+ row.setSpacing(10)
+ number = QLabel(str(i))
+ number.setFixedSize(22, 22)
+ number.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ number.setStyleSheet(
+ f"background: {t.soft(t.primary, 0.2).name()}; color: {t.fg.name()};"
+ " border-radius: 11px; font-weight: 700; font-size: 12px;"
+ )
+ row.addWidget(number, 0, Qt.AlignmentFlag.AlignTop)
+ text = QVBoxLayout()
+ text.setSpacing(1)
+ text.addWidget(_label(step["title"], "strong", wrap=True))
+ text.addWidget(_label(step["text"], "muted", wrap=True))
+ row.addLayout(text, 1)
+ self._wiz_body.addLayout(row)
+
+ def cleanup(self):
+ """Stop the session pills and the Atlas connection."""
+ for pill in self.findChildren(StatusPill):
+ pill.cleanup()
+ super().cleanup()
diff --git a/bec_widgets/widgets/services/bec_atlas_admin_view/admin_ux/qml/AdminView.qml b/bec_widgets/widgets/services/bec_atlas_admin_view/admin_ux/qml/AdminView.qml
new file mode 100644
index 00000000..01810c5e
--- /dev/null
+++ b/bec_widgets/widgets/services/bec_atlas_admin_view/admin_ux/qml/AdminView.qml
@@ -0,0 +1,758 @@
+import QtQuick
+import QtQuick.Controls.Basic
+import QtQuick.Layouts
+import BecUi
+
+// Admin view: staff sign-in, the active experiment, and switching it in three guided steps
+// (review, confirm, log in as the experiment's Linux account). Mirrors admin_view_qwidget.py.
+Rectangle {
+ id: root
+ required property var backend
+ readonly property var s: backend.state
+ readonly property var wizard: s.wizard || ({})
+ readonly property var plan: wizard.plan || ({})
+ readonly property var target: wizard.targetInfo || ({})
+ readonly property string mono: "DejaVu Sans Mono"
+
+ color: theme.bg
+
+ function toneColor(tone) {
+ return tone === "ok" ? theme.success : tone === "warning" ? theme.warning
+ : tone === "danger" ? theme.danger : theme.accent
+ }
+ function tint(c, a) { return Qt.rgba(c.r, c.g, c.b, a) }
+
+ // ---- small building blocks ------------------------------------------------------------
+ component Txt: Text {
+ property string role: ""
+ color: role === "muted" ? theme.fgMuted : role === "subtle" ? theme.fgSubtle
+ : role === "error" ? theme.danger : theme.fg
+ font.pixelSize: role === "h1" ? 22 : role === "h2" ? 16 : role === "subtle" ? 12 : 13
+ font.weight: role === "h1" ? Font.Bold : role === "h2" ? Font.DemiBold
+ : role === "strong" ? Font.DemiBold : Font.Normal
+ wrapMode: Text.Wrap
+ }
+
+ component Chip: Rectangle {
+ property string text: ""
+ property bool big: false
+ implicitWidth: chipText.implicitWidth + (big ? 24 : 12)
+ implicitHeight: chipText.implicitHeight + (big ? 8 : 2)
+ radius: big ? 6 : 4
+ color: tint(theme.primary, big ? 0.18 : 0.14)
+ border.width: big ? 1 : 0
+ border.color: tint(theme.primary, 0.5)
+ Text {
+ id: chipText
+ anchors.centerIn: parent
+ text: parent.text
+ color: theme.fg
+ font.family: root.mono
+ font.pixelSize: parent.big ? 20 : 13
+ font.weight: parent.big ? Font.Bold : Font.DemiBold
+ }
+ }
+
+ component Banner: Rectangle {
+ id: banner
+ property string tone: "info"
+ property string title: ""
+ property string text: ""
+ readonly property color toneColor: root.toneColor(tone)
+ implicitHeight: bannerRow.implicitHeight + 20
+ radius: 8
+ color: tint(toneColor, 0.14)
+ border.color: tint(toneColor, 0.45)
+ RowLayout {
+ id: bannerRow
+ anchors.fill: parent
+ anchors.margins: 10
+ anchors.leftMargin: 12
+ anchors.rightMargin: 12
+ spacing: 10
+ Icon {
+ Layout.alignment: Qt.AlignTop
+ name: banner.tone === "ok" ? "check_circle" : banner.tone === "warning" ? "warning"
+ : banner.tone === "danger" ? "error" : "info"
+ color: banner.toneColor
+ size: 20
+ }
+ ColumnLayout {
+ spacing: 2
+ Layout.fillWidth: true
+ Txt { text: banner.title; role: "strong"; Layout.fillWidth: true }
+ Txt { text: banner.text; role: "muted"; visible: banner.text !== ""; Layout.fillWidth: true }
+ }
+ }
+ }
+
+ component NumberDot: Rectangle {
+ property string text: ""
+ property int size: 22
+ width: size; height: size; radius: size / 2
+ color: tint(theme.primary, 0.2)
+ Text {
+ anchors.centerIn: parent
+ text: parent.text
+ color: theme.fg
+ font.pixelSize: parent.size > 20 ? 12 : 11
+ font.weight: Font.Bold
+ }
+ }
+
+ component ActiveCard: Card {
+ title: "Active experiment"
+ padding: 16
+ readonly property var active: s.active || ({})
+ RowLayout {
+ visible: s.hasActive
+ spacing: 8
+ Txt { text: active.pgroup || ""; role: "h1" }
+ StatusPill { text: "Active"; tone: theme.success }
+ }
+ Txt { visible: s.hasActive; text: active.title || ""; role: "strong"; Layout.fillWidth: true }
+ GridLayout {
+ visible: s.hasActive
+ columns: 2
+ columnSpacing: 16
+ rowSpacing: 6
+ Layout.fillWidth: true
+ Txt { text: "PI"; role: "muted" }
+ Txt { text: active.pi || ""; Layout.fillWidth: true }
+ Txt { text: "Linux login"; role: "muted" }
+ Chip { text: active.linuxAccount || "unknown" }
+ Txt { text: "Data saved to"; role: "muted" }
+ Chip { text: active.dataAccount || "" }
+ Txt { text: "Beamtime"; role: "muted" }
+ Txt { text: active.beamtime || ""; Layout.fillWidth: true }
+ }
+ Txt { visible: !s.hasActive; text: "BEC reports no active experiment."; role: "muted" }
+ }
+
+ component NoticeBanner: Banner {
+ visible: !!s.notice && s.notice.tone !== undefined
+ tone: s.notice ? (s.notice.tone || "info") : "info"
+ title: s.notice ? (s.notice.title || "") : ""
+ text: s.notice ? (s.notice.text || "") : ""
+ }
+
+ component Segmented: Rectangle {
+ id: seg
+ property var options: []
+ property string value: ""
+ signal picked(string value)
+ implicitWidth: segRow.implicitWidth + 2
+ implicitHeight: 32
+ radius: 6
+ color: "transparent"
+ border.color: theme.border
+ Row {
+ id: segRow
+ x: 1; y: 1
+ height: parent.height - 2
+ Repeater {
+ model: seg.options
+ delegate: AbstractButton {
+ required property var modelData
+ readonly property bool selected: modelData[0] === seg.value
+ height: segRow.height
+ implicitWidth: segText.implicitWidth + 24
+ hoverEnabled: true
+ onClicked: seg.picked(modelData[0])
+ background: Rectangle {
+ radius: 5
+ color: parent.selected ? tint(theme.primary, 0.18) : parent.hovered ? theme.hover : "transparent"
+ }
+ contentItem: Text {
+ id: segText
+ text: parent.modelData[1]
+ horizontalAlignment: Text.AlignHCenter
+ verticalAlignment: Text.AlignVCenter
+ color: parent.selected ? theme.fg : theme.fgMuted
+ font.pixelSize: 13
+ font.weight: parent.selected ? Font.DemiBold : Font.Normal
+ }
+ }
+ }
+ }
+ }
+
+ // ---- layout -------------------------------------------------------------------------
+ ColumnLayout {
+ anchors.fill: parent
+ spacing: 0
+
+ Rectangle {
+ Layout.fillWidth: true
+ implicitHeight: 56
+ color: theme.card
+ Rectangle { anchors.bottom: parent.bottom; width: parent.width; height: 1; color: theme.border }
+ RowLayout {
+ anchors.fill: parent
+ anchors.leftMargin: 16
+ anchors.rightMargin: 16
+ spacing: 10
+ Icon { name: "admin_panel_settings"; color: theme.primary; size: 26 }
+ ColumnLayout {
+ spacing: 0
+ Txt { text: "Beamline admin"; role: "h2" }
+ Txt { text: s.deploymentName + (s.realm ? " · " + s.realm : ""); role: "muted" }
+ }
+ Item { Layout.fillWidth: true }
+ StatusPill {
+ text: s.signedIn ? s.email + " · " + s.remaining + " left" : "Not signed in"
+ tone: s.signedIn ? theme.success : theme.fgSubtle
+ }
+ TextButton {
+ visible: s.signedIn
+ text: "Sign out"
+ iconName: "logout"
+ onClicked: backend.logout()
+ }
+ }
+ }
+
+ StackLayout {
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ currentIndex: s.signedIn ? 1 : 0
+
+ // ---- signed out: the active experiment plus the sign-in card --------------------
+ Item {
+ RowLayout {
+ anchors.top: parent.top
+ anchors.topMargin: 24
+ anchors.horizontalCenter: parent.horizontalCenter
+ width: Math.min(parent.width - 48, 980)
+ spacing: 16
+ ColumnLayout {
+ Layout.fillWidth: true
+ Layout.alignment: Qt.AlignTop
+ spacing: 12
+ ActiveCard { Layout.fillWidth: true }
+ NoticeBanner { Layout.fillWidth: true }
+ }
+ Card {
+ id: signIn
+ title: "Staff sign-in"
+ padding: 20
+ Layout.preferredWidth: 360
+ Layout.alignment: Qt.AlignTop
+ function submit() {
+ backend.login(user.text, pass.text)
+ pass.text = ""
+ }
+ Txt {
+ text: "Sign in with your PSI account to switch the active experiment. "
+ + "Your account needs owner rights on " + s.deploymentName + "."
+ role: "muted"
+ Layout.fillWidth: true
+ }
+ Item { implicitHeight: 4 }
+ InputField {
+ id: user
+ placeholderText: "PSI username"
+ Layout.fillWidth: true
+ focus: true
+ onAccepted: pass.forceActiveFocus()
+ }
+ InputField {
+ id: pass
+ placeholderText: "Password"
+ echoMode: TextInput.Password
+ Layout.fillWidth: true
+ onAccepted: signIn.submit()
+ }
+ Txt { visible: s.signInError !== ""; text: s.signInError; role: "error"; Layout.fillWidth: true }
+ TextButton {
+ text: s.signingIn ? "Signing in…" : "Sign in"
+ variant: "primary"
+ iconName: "login"
+ enabled: !s.signingIn
+ Layout.fillWidth: true
+ onClicked: signIn.submit()
+ }
+ Txt {
+ text: "Signing in does not change anything yet. Switching the experiment asks for confirmation first."
+ role: "subtle"
+ Layout.fillWidth: true
+ }
+ }
+ }
+ }
+
+ // ---- signed in: navigation plus the experiment section ----------------------
+ RowLayout {
+ spacing: 0
+ Rectangle {
+ Layout.preferredWidth: 184
+ Layout.fillHeight: true
+ color: theme.card
+ Rectangle { anchors.right: parent.right; width: 1; height: parent.height; color: theme.border }
+ Column {
+ anchors.fill: parent
+ anchors.margins: 10
+ anchors.topMargin: 14
+ spacing: 4
+ Repeater {
+ model: s.sections
+ delegate: AbstractButton {
+ required property var modelData
+ width: parent.width
+ height: 34
+ enabled: modelData.enabled
+ hoverEnabled: true
+ readonly property bool current: modelData.id === s.section
+ onClicked: backend.openSection(modelData.id)
+ background: Rectangle {
+ radius: 6
+ color: parent.current ? tint(theme.primary, 0.18) : parent.hovered ? theme.hover : "transparent"
+ }
+ contentItem: RowLayout {
+ spacing: 8
+ Item { implicitWidth: 2 }
+ Icon { name: modelData.icon; size: 18; color: modelData.enabled ? theme.fg : theme.fgSubtle }
+ Text {
+ text: modelData.label + (modelData.badge ? " · " + modelData.badge : "")
+ color: modelData.enabled ? theme.fg : theme.fgSubtle
+ font.pixelSize: 13
+ font.weight: parent.parent.current ? Font.DemiBold : Font.Normal
+ Layout.fillWidth: true
+ }
+ }
+ }
+ }
+ }
+ }
+
+ StackLayout {
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ currentIndex: wizard.open ? 1 : 0
+
+ // -- browse: active experiment, guidance and the switch list --
+ ColumnLayout {
+ spacing: 12
+ Item { implicitHeight: 4 }
+ RowLayout {
+ Layout.leftMargin: 20
+ Layout.rightMargin: 20
+ spacing: 12
+ ActiveCard { Layout.fillWidth: true; Layout.preferredWidth: 300; Layout.alignment: Qt.AlignTop }
+ ColumnLayout {
+ Layout.fillWidth: true
+ Layout.preferredWidth: 200
+ Layout.alignment: Qt.AlignTop
+ spacing: 12
+ NoticeBanner { Layout.fillWidth: true }
+ Card {
+ title: "How switching works"
+ padding: 14
+ spacing: 6
+ Layout.fillWidth: true
+ Repeater {
+ model: s.howItWorks
+ delegate: RowLayout {
+ required property var modelData
+ required property int index
+ spacing: 8
+ Layout.fillWidth: true
+ NumberDot { text: index + 1; size: 20; Layout.alignment: Qt.AlignTop }
+ Txt { text: modelData; role: "muted"; Layout.fillWidth: true }
+ }
+ }
+ }
+ }
+ }
+ Card {
+ title: "Switch experiment"
+ padding: 16
+ spacing: 10
+ fillContent: true
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ Layout.leftMargin: 20
+ Layout.rightMargin: 20
+ Layout.bottomMargin: 16
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: 0
+ InputField {
+ placeholderText: "Search p-group, title, PI or account"
+ text: s.query
+ Layout.fillWidth: true
+ Layout.rightMargin: 8
+ onTextEdited: backend.setQuery(text)
+ }
+ Segmented {
+ options: [["upcoming", "Upcoming"], ["all", "All"]]
+ value: s.scope
+ onPicked: (value) => backend.setScope(value)
+ }
+ }
+ RowLayout {
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ spacing: 12
+ ListView {
+ id: list
+ Layout.fillWidth: true
+ Layout.preferredWidth: 300
+ Layout.fillHeight: true
+ clip: true
+ spacing: 4
+ model: backend.rows
+ ScrollBar.vertical: ScrollBar {}
+ delegate: Rectangle {
+ required property string pgroup
+ required property string title
+ required property string pi
+ required property string beamtime
+ required property string status
+ required property string statusLabel
+ readonly property bool selected: (s.selected || {}).pgroup === pgroup
+ width: list.width - 4
+ height: 52
+ radius: 8
+ color: selected ? tint(theme.primary, 0.14) : rowMouse.containsMouse ? theme.hover : "transparent"
+ border.color: selected ? tint(theme.primary, 0.6) : "transparent"
+ MouseArea {
+ id: rowMouse
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: backend.selectExperiment(pgroup)
+ }
+ RowLayout {
+ anchors.fill: parent
+ anchors.leftMargin: 12
+ anchors.rightMargin: 12
+ spacing: 10
+ Text {
+ Layout.alignment: Qt.AlignTop
+ Layout.topMargin: 8
+ text: pgroup
+ color: theme.fg
+ font.family: root.mono
+ font.pixelSize: 13
+ font.weight: Font.DemiBold
+ }
+ ColumnLayout {
+ spacing: 2
+ Layout.fillWidth: true
+ Text { text: title; color: theme.fg; font.pixelSize: 13; elide: Text.ElideRight; Layout.fillWidth: true }
+ Text { text: pi + " · " + beamtime; color: theme.fgMuted; font.pixelSize: 13; elide: Text.ElideRight; Layout.fillWidth: true }
+ }
+ StatusPill {
+ visible: ["active", "now", "next", "past"].indexOf(status) >= 0
+ text: statusLabel
+ tone: status === "active" ? theme.success : status === "now" ? theme.warning
+ : status === "next" ? theme.primary : theme.fgSubtle
+ }
+ }
+ }
+ Txt {
+ anchors.top: parent.top
+ anchors.topMargin: 8
+ visible: list.count === 0
+ role: "muted"
+ text: s.loadingExperiments ? "Loading experiments…"
+ : s.totalCount > 0 ? "No experiment matches the search."
+ : "Atlas lists no experiments for this beamline."
+ }
+ }
+ Rectangle {
+ Layout.fillWidth: true
+ Layout.preferredWidth: 200
+ Layout.fillHeight: true
+ radius: 8
+ color: theme.field
+ border.color: theme.border
+ readonly property var sel: s.selected || ({})
+ readonly property bool has: sel.pgroup !== undefined
+ ColumnLayout {
+ anchors.fill: parent
+ anchors.margins: 14
+ anchors.topMargin: 12
+ spacing: 6
+ Txt { visible: parent.parent.has; text: parent.parent.sel.pgroup || ""; role: "h2" }
+ Txt { visible: parent.parent.has; text: parent.parent.sel.title || ""; role: "strong"; Layout.fillWidth: true }
+ Txt { visible: parent.parent.has; text: "PI: " + (parent.parent.sel.pi || ""); role: "muted" }
+ Txt { visible: parent.parent.has; text: parent.parent.sel.beamtime || ""; role: "muted"; Layout.fillWidth: true }
+ Txt {
+ visible: parent.parent.has
+ textFormat: Text.StyledText
+ text: "Group logs in as " + (parent.parent.sel.linuxAccount || "unknown") + ""
+ Layout.fillWidth: true
+ }
+ Txt {
+ visible: parent.parent.has && (parent.parent.sel.abstract || "") !== ""
+ text: parent.parent.sel.abstract || ""
+ role: "subtle"
+ maximumLineCount: 4
+ elide: Text.ElideRight
+ Layout.fillWidth: true
+ }
+ Txt { visible: !parent.parent.has; text: "Select an experiment to see its details."; role: "muted" }
+ Item { Layout.fillHeight: true }
+ TextButton {
+ visible: parent.parent.has
+ variant: "primary"
+ iconName: "swap_horiz"
+ enabled: s.canSwitch
+ text: parent.parent.sel.isActive ? "This is the active experiment"
+ : "Switch to " + parent.parent.sel.pgroup + "…"
+ Layout.fillWidth: true
+ onClicked: backend.startSwitch("")
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // -- the guided switch: review, confirm, log in --
+ Item {
+ Flickable {
+ anchors.fill: parent
+ contentHeight: wizardCard.implicitHeight + 32
+ clip: true
+ Card {
+ id: wizardCard
+ y: 16
+ padding: 20
+ spacing: 14
+ width: Math.max(560, Math.min(780, root.width - 184 - 40))
+ x: (parent.width - width) / 2
+ ColumnLayout {
+ spacing: 2
+ Txt { text: wizard.phase === "done" ? "Experiment switched" : "Switch experiment"; role: "h2" }
+ Txt { text: (wizard.fromPgroup || "") + " → " + (target.pgroup || ""); role: "muted" }
+ }
+ // stepper
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: 8
+ Repeater {
+ model: wizard.steps || []
+ delegate: RowLayout {
+ required property var modelData
+ required property int index
+ readonly property bool finished: index < wizard.step || (wizard.phase === "done" && index === wizard.step)
+ readonly property bool current: index === wizard.step && wizard.phase !== "done"
+ spacing: 8
+ Layout.fillWidth: index > 0
+ Rectangle {
+ visible: index > 0
+ Layout.fillWidth: true
+ Layout.minimumWidth: 24
+ height: 2
+ color: index <= wizard.step ? theme.success : theme.border
+ }
+ Rectangle {
+ width: 24; height: 24; radius: 12
+ color: parent.finished ? theme.success : parent.current ? theme.primary : theme.track
+ Text {
+ anchors.centerIn: parent
+ text: parent.parent.finished ? "✓" : index + 1
+ color: parent.parent.finished || parent.parent.current ? "white" : theme.fgMuted
+ font.pixelSize: 12
+ font.weight: Font.Bold
+ }
+ }
+ Text {
+ text: modelData
+ color: parent.finished || parent.current ? theme.fg : theme.fgMuted
+ font.pixelSize: 13
+ font.weight: parent.finished || parent.current ? Font.DemiBold : Font.Normal
+ }
+ }
+ }
+ }
+ Rectangle { Layout.fillWidth: true; height: 1; color: theme.border }
+
+ // step 1: review
+ ColumnLayout {
+ visible: wizard.step === 0
+ Layout.fillWidth: true
+ spacing: 6
+ Txt {
+ text: "Check that this is the right experiment. Switching also changes which Linux account the group uses on this computer."
+ role: "muted"
+ Layout.fillWidth: true
+ }
+ RowLayout {
+ Layout.fillWidth: true
+ Item { Layout.preferredWidth: 110 }
+ Txt { text: "Now"; role: "subtle"; Layout.fillWidth: true; Layout.preferredWidth: 100 }
+ Item { Layout.preferredWidth: 16 }
+ Txt { text: "After switching"; role: "subtle"; Layout.fillWidth: true; Layout.preferredWidth: 100 }
+ }
+ Repeater {
+ model: plan.changes || []
+ delegate: Rectangle {
+ required property var modelData
+ Layout.fillWidth: true
+ implicitHeight: 30
+ radius: 6
+ color: modelData.key ? tint(theme.warning, 0.16) : "transparent"
+ RowLayout {
+ anchors.fill: parent
+ anchors.leftMargin: 8
+ anchors.rightMargin: 8
+ spacing: 0
+ Txt { text: modelData.label; role: modelData.key ? "strong" : "muted"; Layout.preferredWidth: 102; wrapMode: Text.NoWrap }
+ Txt { text: modelData.before; role: "muted"; elide: Text.ElideRight; wrapMode: Text.NoWrap; Layout.fillWidth: true; Layout.preferredWidth: 100 }
+ Icon { name: "arrow_forward"; size: 16; color: theme.fgSubtle; Layout.rightMargin: 12; Layout.leftMargin: 4 }
+ Item {
+ Layout.fillWidth: true
+ Layout.preferredWidth: 100
+ implicitHeight: 22
+ Chip { visible: modelData.key; text: modelData.after; anchors.verticalCenter: parent.verticalCenter }
+ Txt { visible: !modelData.key; text: modelData.after; role: "strong"; elide: Text.ElideRight; wrapMode: Text.NoWrap; width: parent.width; anchors.verticalCenter: parent.verticalCenter }
+ }
+ }
+ }
+ }
+ }
+
+ // step 2: what happens, then confirm
+ ColumnLayout {
+ visible: wizard.step === 1
+ Layout.fillWidth: true
+ spacing: 10
+ Txt { text: "What happens when you switch"; role: "strong" }
+ Repeater {
+ model: plan.consequences || []
+ delegate: RowLayout {
+ required property var modelData
+ Layout.fillWidth: true
+ spacing: 10
+ Icon { name: modelData.icon; size: 20; color: root.toneColor(modelData.tone); Layout.alignment: Qt.AlignTop }
+ ColumnLayout {
+ spacing: 1
+ Layout.fillWidth: true
+ Txt { text: modelData.title; role: "strong"; Layout.fillWidth: true }
+ Txt { text: modelData.text; role: "muted"; Layout.fillWidth: true }
+ }
+ }
+ }
+ Rectangle {
+ Layout.fillWidth: true
+ implicitHeight: 44
+ radius: 8
+ color: tint(theme.warning, 0.14)
+ border.color: tint(theme.warning, 0.5)
+ RowLayout {
+ anchors.fill: parent
+ anchors.leftMargin: 12
+ anchors.rightMargin: 12
+ spacing: 10
+ Icon { name: "login"; size: 22; color: theme.warning }
+ Txt { text: "Afterwards the group must log in to this computer as"; role: "strong"; wrapMode: Text.NoWrap }
+ Chip { text: plan.linuxAccount || "" }
+ Item { Layout.fillWidth: true }
+ }
+ }
+ CheckBox {
+ id: ack
+ checked: !!wizard.ack
+ enabled: wizard.phase !== "switching"
+ text: plan.ackText || ""
+ onToggled: backend.setAcknowledged(checked)
+ indicator: Rectangle {
+ x: ack.leftPadding
+ y: (ack.height - height) / 2
+ width: 18; height: 18; radius: 4
+ color: ack.checked ? theme.primary : theme.field
+ border.color: ack.checked ? theme.primary : theme.fgSubtle
+ Text { anchors.centerIn: parent; visible: ack.checked; text: "✓"; color: "white"; font.pixelSize: 13; font.weight: Font.Bold }
+ }
+ contentItem: Text {
+ leftPadding: ack.indicator.width + 10
+ text: ack.text
+ color: theme.fg
+ font.pixelSize: 13
+ font.weight: Font.DemiBold
+ verticalAlignment: Text.AlignVCenter
+ }
+ }
+ Banner {
+ visible: (wizard.error || "") !== ""
+ tone: "danger"
+ title: "The switch failed"
+ text: wizard.error || ""
+ Layout.fillWidth: true
+ }
+ }
+
+ // step 3: log in as the experiment account
+ ColumnLayout {
+ visible: wizard.step === 2
+ Layout.fillWidth: true
+ spacing: 10
+ Banner {
+ tone: "ok"
+ title: (target.pgroup || "") + " is now the active experiment"
+ text: "New scans are saved for the new experiment. One step is left on this computer:"
+ Layout.fillWidth: true
+ }
+ RowLayout {
+ spacing: 10
+ Txt { text: "Log in as"; role: "h2" }
+ Chip { text: plan.linuxAccount || ""; big: true }
+ }
+ Repeater {
+ model: plan.loginSteps || []
+ delegate: RowLayout {
+ required property var modelData
+ required property int index
+ Layout.fillWidth: true
+ spacing: 10
+ NumberDot { text: index + 1; Layout.alignment: Qt.AlignTop }
+ ColumnLayout {
+ spacing: 1
+ Layout.fillWidth: true
+ Txt { text: modelData.title; role: "strong"; Layout.fillWidth: true }
+ Txt { text: modelData.text; role: "muted"; Layout.fillWidth: true }
+ }
+ }
+ }
+ }
+
+ RowLayout {
+ Layout.fillWidth: true
+ Layout.topMargin: 4
+ TextButton {
+ visible: wizard.phase !== "done" && wizard.phase !== "switching"
+ text: "Cancel"
+ variant: "ghost"
+ onClicked: backend.closeWizard()
+ }
+ Item { Layout.fillWidth: true }
+ TextButton {
+ visible: wizard.step > 0 && wizard.phase !== "done" && wizard.phase !== "switching"
+ text: "Back"
+ iconName: "arrow_back"
+ onClicked: backend.back()
+ }
+ TextButton {
+ text: wizard.step === 0 ? "Next"
+ : wizard.step === 1 ? (wizard.phase === "switching" ? "Switching…" : "Switch to " + (target.pgroup || ""))
+ : "Done"
+ variant: wizard.step === 1 ? "danger" : "primary"
+ iconName: wizard.step === 0 ? "arrow_forward" : wizard.step === 1 ? "swap_horiz" : "check"
+ busy: wizard.phase === "switching"
+ enabled: wizard.step !== 1 || !!wizard.canConfirm
+ onClicked: wizard.step === 0 ? backend.next()
+ : wizard.step === 1 ? backend.confirm() : backend.closeWizard()
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/tests/unit_tests/test_admin_view_ports.py b/tests/unit_tests/test_admin_view_ports.py
new file mode 100644
index 00000000..1cca515e
--- /dev/null
+++ b/tests/unit_tests/test_admin_view_ports.py
@@ -0,0 +1,366 @@
+"""Tests for the reworked admin view (QML and QWidget) and its guided experiment switch."""
+
+# pylint: disable=protected-access,redefined-outer-name
+import time
+from datetime import datetime
+from unittest import mock
+
+import pytest
+from qtpy.QtCore import QUrl
+from qtpy.QtNetwork import QNetworkRequest
+
+from bec_widgets.applications.views.admin_view import admin_view as admin_view_module
+from bec_widgets.tests.utils import create_widget
+from bec_widgets.widgets.services.bec_atlas_admin_view.admin_ux import admin_demo
+from bec_widgets.widgets.services.bec_atlas_admin_view.admin_ux.admin_ux_common import (
+ AdminAtlasService,
+ account_notice,
+ experiment_summary,
+ filter_experiments,
+ format_beamtime,
+ format_remaining,
+ linux_account_for,
+ select_next_pgroup,
+ switch_plan,
+)
+from bec_widgets.widgets.services.bec_atlas_admin_view.admin_ux.admin_view_qml import AdminViewQML
+from bec_widgets.widgets.services.bec_atlas_admin_view.admin_ux.admin_view_qwidget import (
+ AdminViewQWidget,
+)
+from bec_widgets.widgets.services.bec_atlas_admin_view.bec_atlas_admin_view import BECAtlasAdminView
+
+AUTH = {"email": "staff@psi.ch", "groups": [], "deployment_id": "dep-x12sa"}
+
+
+def _summaries():
+ experiments = admin_demo.demo_experiments()
+ next_pgroup = select_next_pgroup([e for e in experiments if e["pgroup"] != "p22622"])
+ return {
+ e["pgroup"]: experiment_summary(e, active_pgroup="p22622", next_pgroup=next_pgroup)
+ for e in experiments
+ }
+
+
+########################
+## Pure helpers
+########################
+
+
+def test_linux_account_comes_from_eaccount_or_pgroup():
+ assert linux_account_for({"eaccount": "e11111", "pgroup": "p22222"}) == "e11111"
+ assert linux_account_for({"eaccount": "", "pgroup": "p22222"}) == "e22222"
+ assert linux_account_for({"eaccount": "", "pgroup": "staff"}) == ""
+ assert linux_account_for(None) == ""
+
+
+def test_summaries_mark_active_next_past_and_unscheduled():
+ rows = _summaries()
+ assert rows["p22622"]["status"] == "active"
+ assert rows["p22623"]["status"] == "next"
+ assert rows["p22631"]["status"] == "upcoming"
+ assert rows["p22604"]["status"] == "past"
+ assert rows["p22640"]["status"] == "unscheduled"
+ assert rows["p22623"]["linuxAccount"] == "e22623"
+ assert rows["p22623"]["pi"] == "Marco Rossi"
+
+
+def test_filter_hides_past_by_default_and_matches_all_words():
+ rows = list(_summaries().values())
+ upcoming = filter_experiments(rows, "", "upcoming")
+ assert "p22604" not in [r["pgroup"] for r in upcoming]
+ assert upcoming[0]["pgroup"] == "p22622" # the active experiment comes first
+ assert upcoming[-1]["pgroup"] == "p22640" # unscheduled last
+ assert "p22604" in [r["pgroup"] for r in filter_experiments(rows, "", "all")]
+ assert [r["pgroup"] for r in filter_experiments(rows, "rossi battery", "all")] == ["p22623"]
+ assert [r["pgroup"] for r in filter_experiments(rows, "e22631", "all")] == ["p22631"]
+
+
+def test_select_next_prefers_running_then_soonest():
+ now = datetime(2026, 1, 10, 12)
+ experiments = [
+ {
+ "pgroup": "pa",
+ "schedule": [{"start": "20/01/2026 08:00:00", "end": "21/01/2026 08:00:00"}],
+ },
+ {
+ "pgroup": "pb",
+ "schedule": [{"start": "15/01/2026 08:00:00", "end": "16/01/2026 08:00:00"}],
+ },
+ {
+ "pgroup": "pc",
+ "schedule": [{"start": "01/01/2026 08:00:00", "end": "02/01/2026 08:00:00"}],
+ },
+ ]
+ assert select_next_pgroup(experiments, now) == "pb"
+ experiments.append(
+ {
+ "pgroup": "pd",
+ "schedule": [{"start": "09/01/2026 08:00:00", "end": "11/01/2026 08:00:00"}],
+ }
+ )
+ assert select_next_pgroup(experiments, now) == "pd"
+ assert select_next_pgroup([], now) == ""
+
+
+def test_account_notice_tones():
+ active = _summaries()["p22622"]
+ assert account_notice("e22622", active)["tone"] == "ok"
+ assert account_notice("wyzula_j", active)["tone"] == "info"
+ warning = account_notice("e22604", active)
+ assert warning["tone"] == "warning"
+ assert "e22622" in warning["text"]
+ assert account_notice("anyone", {}) == {}
+
+
+def test_switch_plan_names_the_new_linux_account_and_the_queue():
+ rows = _summaries()
+ plan = switch_plan(rows["p22622"], rows["p22623"], "wyzula_j", queue_busy=False)
+ login = next(c for c in plan["changes"] if c["label"] == "Linux login")
+ assert (login["before"], login["after"], login["key"]) == ("e22622", "e22623", True)
+ assert plan["linuxAccount"] == "e22623"
+ assert "e22623" in plan["ackText"]
+ assert any("e22623" in step["title"] for step in plan["loginSteps"])
+ assert any("wyzula_j" in step["text"] for step in plan["loginSteps"])
+ assert plan["consequences"][-1]["tone"] == "ok"
+ busy = switch_plan(rows["p22622"], rows["p22623"], "wyzula_j", queue_busy=True)
+ assert busy["consequences"][-1]["title"] == "A scan is running or queued"
+
+
+def test_small_formatters():
+ assert format_remaining(872) == "14:32"
+ assert format_remaining(3723) == "1:02:03"
+ assert format_remaining(-5) == "0:00"
+ assert format_beamtime(None, None) == "No beamtime scheduled"
+ start, end = datetime(2026, 10, 12, 8), datetime(2026, 10, 15, 8)
+ assert format_beamtime(start, end) == "12 Oct 2026, 08:00 – 15 Oct, 08:00"
+
+
+########################
+## Atlas service
+########################
+
+
+class _Reply:
+ def __init__(self, url: str, status: int):
+ self._url = url
+ self._status = status
+ self.deleteLater = mock.MagicMock()
+
+ def attribute(self, attr):
+ assert attr == QNetworkRequest.Attribute.HttpStatusCodeAttribute
+ return self._status
+
+ def url(self):
+ return QUrl(self._url)
+
+
+@pytest.mark.parametrize(
+ "endpoint, status, kind, text",
+ [
+ ("/user/login", 401, "login", "Wrong username or password."),
+ ("/user/login", 0, "login", "not reachable"),
+ ("/deployments/experiment", 500, "switch", "was not changed"),
+ ("/realms/experiments", 404, "experiments", "Could not load"),
+ ],
+)
+def test_service_reports_failures_inline(qtbot, endpoint, status, kind, text):
+ service = AdminAtlasService(base_url="http://atlas")
+ qtbot.addWidget(service)
+ received = []
+ service.request_failed.connect(lambda k, m: received.append((k, m)))
+ reply = _Reply(f"http://atlas{endpoint}", status)
+ service._handle_response(reply)
+ assert received and received[0][0] == kind and text in received[0][1]
+ reply.deleteLater.assert_called_once()
+ service.cleanup()
+ service.cleanup() # a second cleanup (close event) is harmless
+
+
+########################
+## Both views
+########################
+
+
+@pytest.fixture(params=[AdminViewQWidget, AdminViewQML], ids=["qwidget", "qml"])
+def admin(request, qtbot, mocked_client):
+ widget = create_widget(qtbot, request.param, client=mocked_client, system_user="wyzula_j")
+ service = widget.atlas_http_service
+ for name in ("login", "logout", "get_experiments_for_realm", "set_experiment"):
+ setattr(service, name, mock.MagicMock())
+ widget._on_deployment_info(admin_demo.demo_deployment().model_dump(), {})
+ yield widget
+
+
+def _sign_in(widget):
+ widget._on_authenticated({**AUTH, "exp": time.time() + 900})
+ widget.atlas_http_service.get_experiments_for_realm.assert_called_with("X12SA")
+ widget._on_http_response(
+ {
+ "request_url": f"{widget._atlas_url}/realms/experiments?realm_id=X12SA",
+ "headers": {},
+ "status": 200,
+ "data": admin_demo.demo_experiments(),
+ }
+ )
+
+
+def test_signed_out_shows_active_experiment_and_account_notice(admin):
+ state = admin.view_state()
+ assert not state["signedIn"]
+ assert state["active"]["pgroup"] == "p22622"
+ assert state["active"]["linuxAccount"] == "e22622"
+ assert state["notice"]["tone"] == "info"
+ admin.login("", "")
+ assert admin.view_state()["signInError"]
+ admin.atlas_http_service.login.assert_not_called()
+ admin.login("staff", "secret")
+ admin.atlas_http_service.login.assert_called_once_with(username="staff", password="secret")
+ assert admin.view_state()["signingIn"]
+ admin._on_request_failed("login", "Wrong username or password.")
+ state = admin.view_state()
+ assert not state["signingIn"] and state["signInError"] == "Wrong username or password."
+
+
+def test_sign_in_for_another_deployment_is_refused(admin):
+ admin._on_authenticated({**AUTH, "deployment_id": "other", "exp": time.time() + 900})
+ state = admin.view_state()
+ assert not state["signedIn"]
+ assert "owner rights" in state["signInError"]
+
+
+def test_sign_in_lists_experiments_and_preselects_the_next(admin):
+ _sign_in(admin)
+ state = admin.view_state()
+ assert state["signedIn"] and state["remaining"]
+ assert state["selected"]["pgroup"] == "p22623"
+ assert state["canSwitch"]
+ assert [r["pgroup"] for r in state["rows"]][0] == "p22622"
+ admin.set_scope("all")
+ assert "p22604" in [r["pgroup"] for r in admin.view_state()["rows"]]
+ admin.set_query("tanaka")
+ assert [r["pgroup"] for r in admin.view_state()["rows"]] == ["p22631"]
+ admin.select_experiment("p22622")
+ assert not admin.view_state()["canSwitch"]
+
+
+def test_guided_switch_needs_confirmation_and_ends_with_login_steps(admin):
+ _sign_in(admin)
+ admin.start_switch("")
+ wizard = admin.view_state()["wizard"]
+ assert wizard["open"] and wizard["step"] == 0 and wizard["fromPgroup"] == "p22622"
+ assert wizard["plan"]["linuxAccount"] == "e22623"
+ admin.wizard_next()
+ admin.confirm_switch() # not acknowledged yet: nothing is sent
+ admin.atlas_http_service.set_experiment.assert_not_called()
+ assert not admin.view_state()["wizard"]["canConfirm"]
+ admin.set_acknowledged(True)
+ assert admin.view_state()["wizard"]["canConfirm"]
+ admin.confirm_switch()
+ admin.atlas_http_service.set_experiment.assert_called_once_with("p22623", "dep-x12sa")
+ assert admin.view_state()["wizard"]["phase"] == "switching"
+ admin.close_wizard() # cannot leave while the request runs
+ assert admin.view_state()["wizard"]["open"]
+ switched = []
+ admin.experiment_switched.connect(switched.append)
+ admin._on_http_response(
+ {
+ "request_url": f"{admin._atlas_url}/deployments/experiment?experiment_id=p22623",
+ "headers": {},
+ "status": 200,
+ "data": {},
+ }
+ )
+ state = admin.view_state()
+ assert switched == ["p22623"]
+ assert state["wizard"]["phase"] == "done" and state["wizard"]["step"] == 2
+ assert state["active"]["pgroup"] == "p22623"
+ # the confirmed change stays visible although the active experiment moved
+ login = next(c for c in state["wizard"]["plan"]["changes"] if c["label"] == "Linux login")
+ assert (login["before"], login["after"]) == ("e22622", "e22623")
+ admin.close_wizard()
+ assert not admin.view_state()["wizard"]["open"]
+
+
+def test_failed_switch_stays_in_confirm_step(admin):
+ _sign_in(admin)
+ admin.start_switch("p22631")
+ admin.wizard_next()
+ admin.set_acknowledged(True)
+ admin.confirm_switch()
+ admin._on_request_failed("switch", "BEC Atlas refused the switch (HTTP 500).")
+ wizard = admin.view_state()["wizard"]
+ assert wizard["phase"] == "error" and wizard["step"] == 1 and "refused" in wizard["error"]
+ admin.confirm_switch() # retry is possible
+ assert admin.atlas_http_service.set_experiment.call_count == 2
+ admin._on_request_failed("switch", "again")
+ admin.wizard_back()
+ assert admin.view_state()["wizard"]["step"] == 0
+ admin.wizard_back()
+ assert not admin.view_state()["wizard"]["open"]
+
+
+def test_busy_queue_adds_a_warning(admin):
+ _sign_in(admin)
+ item = mock.MagicMock(status="RUNNING")
+ admin._on_queue_status({"queue": {"primary": mock.MagicMock(info=[item])}}, {})
+ admin.start_switch("")
+ consequences = admin.view_state()["wizard"]["plan"]["consequences"]
+ assert consequences[-1]["tone"] == "warning"
+
+
+def test_sign_out_clears_the_session(admin):
+ _sign_in(admin)
+ admin.start_switch("")
+ admin._on_authenticated({})
+ state = admin.view_state()
+ assert not state["signedIn"] and not state["rows"] and not state["wizard"]["open"]
+
+
+def test_qml_backend_drives_the_same_flow(qtbot, mocked_client):
+ widget = create_widget(qtbot, AdminViewQML, client=mocked_client, system_user="e22622")
+ for name in ("login", "logout", "get_experiments_for_realm", "set_experiment"):
+ setattr(widget.atlas_http_service, name, mock.MagicMock())
+ widget._on_deployment_info(admin_demo.demo_deployment().model_dump(), {})
+ assert widget.view.rootObject() is not None
+ _sign_in(widget)
+ backend = widget.backend
+ assert backend.rows.rowCount() == len(widget.view_state()["rows"])
+ assert backend.state["notice"]["tone"] == "ok"
+ backend.selectExperiment("p22631")
+ backend.startSwitch("")
+ backend.next()
+ backend.setAcknowledged(True)
+ backend.confirm()
+ widget.atlas_http_service.set_experiment.assert_called_once_with("p22631", "dep-x12sa")
+
+
+def test_qwidget_buttons_drive_the_flow(qtbot, mocked_client):
+ widget = create_widget(qtbot, AdminViewQWidget, client=mocked_client, system_user="wyzula_j")
+ for name in ("login", "logout", "get_experiments_for_realm", "set_experiment"):
+ setattr(widget.atlas_http_service, name, mock.MagicMock())
+ widget._on_deployment_info(admin_demo.demo_deployment().model_dump(), {})
+ widget._username.setText("staff")
+ widget._password.setText("secret")
+ widget._sign_in_button.click()
+ widget.atlas_http_service.login.assert_called_once_with(username="staff", password="secret")
+ assert widget._password.text() == ""
+ _sign_in(widget)
+ assert widget._pages.currentIndex() == 1
+ widget._switch_button.click()
+ assert widget._content.currentIndex() == 1
+ widget._wiz_primary.click()
+ assert not widget._wiz_primary.isEnabled()
+ widget._ack.setChecked(True)
+ assert widget._wiz_primary.isEnabled()
+ widget._wiz_primary.click()
+ widget.atlas_http_service.set_experiment.assert_called_once_with("p22623", "dep-x12sa")
+
+
+@pytest.mark.parametrize(
+ "value, cls", [("qml", AdminViewQML), ("qwidget", AdminViewQWidget), ("", BECAtlasAdminView)]
+)
+def test_admin_view_env_switch(qtbot, mocked_client, monkeypatch, value, cls):
+ monkeypatch.setenv(admin_view_module.ADMIN_UI_ENV, value)
+ widget = admin_view_module.create_admin_widget()
+ qtbot.addWidget(widget)
+ assert isinstance(widget, cls)