mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-10-29 00:11:28 +01:00
feat(admin_view): QML and QWidget admin views with a guided experiment switch that names the Linux account to log in with
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A4kzsfXBk1iaZU32We5XLP
This commit is contained in:
8 files changed
+3064
-1
No files matched your search
@@ -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()
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Reworked admin view with QML and QWidget renderings of the same experiment-switch flow."""
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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 <b>{selected['linuxAccount'] or 'unknown'}</b>"
|
||||
)
|
||||
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()
|
||||
@@ -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 <b>" + (parent.parent.sel.linuxAccount || "unknown") + "</b>"
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user