GUI/DAQ: track active guis and allow staff to force close sessions if needed. GUI's should autoclose if no interaction or uaomation after a grace period. REDIS should clear close GUIS after 10 minutes of no status updates
Build and Publish / test (push) Successful in 1m23s
Build and Publish / build (push) Successful in 16s
Build and Publish / Build and Deploy Docs (push) Successful in 35s

This commit is contained in:
2026-04-30 12:44:02 +02:00
parent df84850941
commit 2b7c634227
6 changed files with 569 additions and 18 deletions
+13
View File
@@ -738,6 +738,18 @@ class SessionStatus(BaseModel):
staff: bool = False
class OpenGuiSessionInfo(BaseModel):
session: int
username: str
staff: bool = False
last_seen_ts: float
last_interaction_ts: float | None = None
close_requested: bool = False
close_requested_by: str | None = None
close_requested_at: float | None = None
close_grace_seconds: int | None = None
class CrystalSize(BaseModel):
x: float = 0.0
y: float = 0.0
@@ -752,6 +764,7 @@ class DAQStatusModel(BaseModel):
busy: bool
sample: SampleShortInfo | None = None
session: SessionStatus
open_guis: list[OpenGuiSessionInfo] = []
box: BoundingBoxModel | None = None
last_best_res: float | None = None
last_best_b_factor: float | None = None
+144 -2
View File
@@ -3,6 +3,7 @@ import io
import json
from typing import Tuple, List
from dataclasses import asdict, is_dataclass
import time
import numpy as np
import redis
@@ -16,8 +17,16 @@ from aare.common.models import (
zoom_manager,
SampleShortInfo,
SessionStatus,
BeamlineStateEnum, SessionsStateEnum, SampleShortInfoList, CryojetSettingsModel, ZoomModel,
FluorescenceSpectrumOutputModel, CrystalSize, SimpleStrategyInputModel, SimpleScanParameters
BeamlineStateEnum,
SessionsStateEnum,
SampleShortInfoList,
CryojetSettingsModel,
ZoomModel,
FluorescenceSpectrumOutputModel,
CrystalSize,
SimpleStrategyInputModel,
SimpleScanParameters,
OpenGuiSessionInfo
)
from aare.common.auth_models import (
@@ -79,6 +88,8 @@ class BeamlineConfig:
__client (redis.Redis): Redis client instance used for interacting with the datastore.
"""
GUI_SESSION_EXPIRE_SECONDS = 60 * 10
def __init__(self, bl: MXBeamline):
self.__mxb = bl
self.__bl = bl.value.lower()
@@ -89,6 +100,137 @@ class BeamlineConfig:
self.__client = redis.Redis(host=host, port=6379, db=0, decode_responses=True)
self.simulated_detector = bl is MXBeamline.SIMULATED
#GUI session management
def _gui_sessions_index_key(self) -> str:
return f"{self.__bl}:gui_sessions"
def _gui_session_key(self, session: int) -> str:
return f"{self.__bl}:gui_session:{session}"
def _read_gui_session(self, session: int) -> OpenGuiSessionInfo | None:
raw = self.__client.get(self._gui_session_key(session))
if raw is None:
return None
try:
return OpenGuiSessionInfo(**json.loads(raw))
except Exception:
logger.warning(f"Failed to parse GUI session info for session {session}")
return None
def touch_gui_session(
self,
*,
session: int,
username: str,
staff: bool = False,
expiry_sec: int,
) -> OpenGuiSessionInfo:
existing = self._read_gui_session(session)
payload = OpenGuiSessionInfo(
session=session,
username=username,
staff=staff,
last_seen_ts=time.time(),
last_interaction_ts=existing.last_interaction_ts if existing is not None else None,
close_requested=existing.close_requested if existing is not None else False,
close_requested_by=existing.close_requested_by if existing is not None else None,
close_requested_at=existing.close_requested_at if existing is not None else None,
close_grace_seconds=existing.close_grace_seconds if existing is not None else None,
)
pipe = self.__client.pipeline()
pipe.set(self._gui_session_key(session), payload.model_dump_json())
pipe.expire(self._gui_session_key(session), expiry_sec)
pipe.sadd(self._gui_sessions_index_key(), session)
pipe.execute()
self.purge_expired_gui_sessions()
return payload
def update_gui_interaction(self, session: int, last_interaction_ts: float) -> OpenGuiSessionInfo | None:
payload = self._read_gui_session(session)
if payload is None:
return None
payload.last_interaction_ts = last_interaction_ts
self.__client.set(self._gui_session_key(session), payload.model_dump_json())
ttl = self.__client.ttl(self._gui_session_key(session))
if ttl and ttl > 0:
self.__client.expire(self._gui_session_key(session), ttl)
return payload
def request_gui_close(
self,
*,
session: int,
requested_by: str,
grace_seconds: int = 60,
) -> OpenGuiSessionInfo | None:
payload = self._read_gui_session(session)
if payload is None:
return None
payload.close_requested = True
payload.close_requested_by = requested_by
payload.close_requested_at = time.time()
payload.close_grace_seconds = grace_seconds
self.__client.set(self._gui_session_key(session), payload.model_dump_json())
ttl = self.__client.ttl(self._gui_session_key(session))
if ttl and ttl > 0:
self.__client.expire(self._gui_session_key(session), ttl)
return payload
def clear_gui_close_request(self, session: int) -> None:
payload = self._read_gui_session(session)
if payload is None:
return
payload.close_requested = False
payload.close_requested_by = None
payload.close_requested_at = None
payload.close_grace_seconds = None
self.__client.set(self._gui_session_key(session), payload.model_dump_json())
ttl = self.__client.ttl(self._gui_session_key(session))
if ttl and ttl > 0:
self.__client.expire(self._gui_session_key(session), ttl)
def remove_gui_session(self, session: int) -> None:
pipe = self.__client.pipeline()
pipe.delete(self._gui_session_key(session))
pipe.srem(self._gui_sessions_index_key(), session)
pipe.execute()
def purge_expired_gui_sessions(self) -> None:
session_ids = self.__client.smembers(self._gui_sessions_index_key())
if not session_ids:
return
expired_ids: list[str] = []
for session_id in session_ids:
if not self.__client.exists(self._gui_session_key(int(session_id))):
expired_ids.append(session_id)
if expired_ids:
self.__client.srem(self._gui_sessions_index_key(), *expired_ids)
def get_open_gui_sessions(self) -> list[OpenGuiSessionInfo]:
self.purge_expired_gui_sessions()
session_ids = self.__client.smembers(self._gui_sessions_index_key())
if not session_ids:
return []
sessions: list[OpenGuiSessionInfo] = []
for session_id in sorted((int(s) for s in session_ids)):
payload = self._read_gui_session(session_id)
if payload is not None:
sessions.append(payload)
return sessions
# Session and authentication management
@property
+68
View File
@@ -314,6 +314,13 @@ async def status(token: str = Depends(oauth2_scheme)) -> DAQStatusModel:
"""
data = auth.parse_token(token)
cfg.touch_gui_session(
session=data.session,
username=data.sub,
staff=data.staff,
expiry_sec=auth.SESSION_EXPIRE_SECONDS,
)
full = daq.status
active_pgroup = cfg.pgroup
is_staff = data.staff
@@ -332,6 +339,7 @@ async def status(token: str = Depends(oauth2_scheme)) -> DAQStatusModel:
session=cfg.session_state(data.session),
staff=data.staff
)
full.open_guis = cfg.get_open_gui_sessions() if data.staff else []
return full
@app.get("/beamline/geometry")
@@ -1476,6 +1484,65 @@ async def set_commissioning_mode(val: bool, token: str = Depends(oauth2_scheme))
cfg.commissioning_mode = val
return "OK"
@app.get("/admin/gui_sessions")
async def get_gui_sessions(token: str = Depends(oauth2_scheme)) -> list[dict]:
"""
Staff-only list of currently active GUIs.
"""
data = auth.parse_token(token)
auth.check_jwt_staff_only(data)
return [item.model_dump() for item in cfg.get_open_gui_sessions()]
@app.post("/admin/gui_sessions/{session_id}/request_close")
async def request_gui_close(
session_id: int,
grace_seconds: int = 60,
token: str = Depends(oauth2_scheme),
) -> dict:
"""
Staff-only request for a remote GUI to close gracefully.
"""
data = auth.parse_token(token)
auth.check_jwt_staff_only(data)
payload = cfg.request_gui_close(
session=session_id,
requested_by=data.sub,
grace_seconds=grace_seconds,
)
if payload is None:
raise HTTPException(
status_code=api_status.HTTP_404_NOT_FOUND,
detail="GUI session not found.",
)
return {
"ok": True,
"session": session_id,
"grace_seconds": grace_seconds,
}
@app.post("/admin/gui_sessions/{session_id}/interaction")
async def update_gui_interaction(
session_id: int,
token: str = Depends(oauth2_scheme),
) -> dict:
"""
GUI-side activity heartbeat.
This is distinct from /status so idle timeout ignores status polling.
"""
data = auth.parse_token(token)
if data.session != session_id:
raise HTTPException(
status_code=api_status.HTTP_403_FORBIDDEN,
detail="Cannot update interaction for another session.",
)
cfg.update_gui_interaction(session=session_id, last_interaction_ts=time.time())
return {"ok": True}
@app.post("/access/end_session")
async def end_session(token: str = Depends(oauth2_scheme)) -> str:
"""
@@ -1492,6 +1559,7 @@ async def end_session(token: str = Depends(oauth2_scheme)) -> str:
# so no need to check R/W permissions
token_data = auth.parse_token(token)
cfg.end_active_session(token_data.session)
cfg.remove_gui_session(token_data.session)
return "OK"
+165 -3
View File
@@ -1,7 +1,7 @@
import time
import jwt
from PySide6.QtCore import Qt, Slot, Signal, QTimer, QSettings
from PySide6.QtCore import Qt, Slot, Signal, QTimer, QSettings, QEvent
from PySide6.QtGui import QAction, QPixmap, QKeySequence
from PySide6.QtWidgets import (
QMainWindow,
@@ -96,7 +96,15 @@ class MainWindow(QMainWindow):
self._baton_request_dialog: BatonRequestDialog | None = None
self._baton_pending_dialog: BatonPendingDialog | None = None
# Tutorial manager (define tutorials after widgets exist)
self._last_user_interaction_ts = time.time()
self._last_interaction_report_ts = 0.0
self._interaction_report_min_interval_s = 15.0
self._idle_close_timeout_s = 60 * 60
self._remote_close_deadline_ts: float | None = None
self._remote_close_reason: str | None = None
self._remote_close_banner_active: bool = False
self._latest_daq_status: DAQStatusModel | None = None
self._tutorial_event_bus = TutorialEventBus(self)
self._tutorial_text_resolver = DictionaryTextResolver(MANUAL_MOUNT_TUTORIAL)
self.state_manager = UIStateManager("PSI", "AareGUI")
@@ -391,6 +399,17 @@ class MainWindow(QMainWindow):
self.daq = DAQWorker(base_url=self.__base_url, token=self.__token)
self.installEventFilter(self)
self._idle_timer = QTimer(self)
self._idle_timer.setInterval(60_000)
self._idle_timer.timeout.connect(self._check_idle_timeout)
self._idle_timer.start()
self._remote_close_timer = QTimer(self)
self._remote_close_timer.setInterval(1000)
self._remote_close_timer.timeout.connect(self._check_remote_close_deadline)
self.daq.baton_status_changed.connect(self.status_bar.update_baton_status)
self.daq.baton_status_changed.connect(self._on_baton_status_changed)
self.daq.baton_request_result.connect(self._on_baton_request_result)
@@ -1067,6 +1086,8 @@ class MainWindow(QMainWindow):
@Slot(DAQStatusModel)
def update_daq_status(self, s: DAQStatusModel):
self._latest_daq_status = s
if hasattr(self, "beamline_camera_thread") and self.beamline_camera_thread is not None:
self.beamline_camera_thread.set_busy(s.busy)
@@ -1078,6 +1099,22 @@ class MainWindow(QMainWindow):
s.geom.beam_location_pxl.y,
)
current_session = int(getattr(self.__decoded_token, "session", -1))
for gui in getattr(s, "open_guis", []) or []:
try:
if int(gui.session) == current_session and bool(gui.close_requested):
if self._remote_close_deadline_ts is None:
self._start_remote_close_countdown(
requested_by=gui.close_requested_by,
grace_seconds=gui.close_grace_seconds,
)
break
except Exception:
continue
else:
if self._remote_close_deadline_ts is not None:
self._clear_remote_close_request()
if not self.__mounting and s.state == BeamlineStateEnum.RobotSampleExchange:
self.__mounting = True
self.video_tab.setCurrentIndex(3)
@@ -1348,6 +1385,18 @@ class MainWindow(QMainWindow):
except Exception as e:
logger.warning(f"Failed to stop _samcam_source_timer: {e}")
try:
if hasattr(self, "_idle_timer") and self._idle_timer is not None:
self._idle_timer.stop()
except Exception as e:
logger.warning(f"Failed to stop _idle_timer: {e}")
try:
if hasattr(self, "_remote_close_timer") and self._remote_close_timer is not None:
self._remote_close_timer.stop()
except Exception as e:
logger.warning(f"Failed to stop _remote_close_timer: {e}")
if hasattr(self, "workflow_sse") and self.workflow_sse is not None:
self.workflow_sse.disconnect()
@@ -1368,4 +1417,117 @@ class MainWindow(QMainWindow):
except Exception as e:
logger.warning(f"Failed to stop {attr_name}: {e}")
setattr(self, attr_name, None)
setattr(self, attr_name, None)
def _is_automation_active(self) -> bool:
try:
if hasattr(self, "job_list_panel") and self.job_list_panel is not None:
return bool(self.job_list_panel.is_running())
except Exception:
pass
try:
progress = getattr(self.automation_progress_panel, "_progress", None)
if progress is not None and not bool(getattr(progress, "finished", False)):
return True
except Exception:
pass
return False
def _mark_user_interaction(self) -> None:
self._last_user_interaction_ts = time.time()
now = time.monotonic()
if now - self._last_interaction_report_ts < self._interaction_report_min_interval_s:
return
self._last_interaction_report_ts = now
try:
self.daq.report_gui_interaction(int(self.__decoded_token.session))
except Exception as e:
logger.debug(f"Failed to report GUI interaction: {e}")
def eventFilter(self, obj, event):
try:
if event is not None and event.type() in {
QEvent.Type.MouseButtonPress,
QEvent.Type.MouseButtonRelease,
QEvent.Type.MouseMove,
QEvent.Type.Wheel,
QEvent.Type.KeyPress,
QEvent.Type.KeyRelease,
QEvent.Type.FocusIn,
QEvent.Type.TouchBegin,
QEvent.Type.TouchUpdate,
}:
self._mark_user_interaction()
except Exception as e:
logger.debug(f"GUI interaction event filter error: {e}")
return super().eventFilter(obj, event)
def _start_remote_close_countdown(self, requested_by: str | None, grace_seconds: int | None) -> None:
grace = max(1, int(grace_seconds or 60))
self._remote_close_deadline_ts = time.time() + grace
self._remote_close_reason = requested_by or "staff"
self._remote_close_timer.start()
self.alert_banner_secondary.show_message(
f"GUI close requested by {self._remote_close_reason}. Closing in {grace}s when safe.",
True,
)
self._remote_close_banner_active = True
def _clear_remote_close_request(self) -> None:
self._remote_close_deadline_ts = None
self._remote_close_reason = None
self._remote_close_timer.stop()
if self._remote_close_banner_active:
self.alert_banner_secondary.clear_message()
self._remote_close_banner_active = False
def _can_close_for_idle_or_remote(self) -> bool:
status = self._latest_daq_status
if status is not None and bool(status.busy):
return False
if self._is_automation_active():
return False
return True
@Slot()
def _check_remote_close_deadline(self) -> None:
if self._remote_close_deadline_ts is None:
return
remaining = int(round(self._remote_close_deadline_ts - time.time()))
if remaining > 0:
if self._remote_close_banner_active:
self.alert_banner_secondary.show_message(
f"GUI close requested by {self._remote_close_reason or 'staff'}. Closing in {remaining}s when safe.",
True,
)
return
if not self._can_close_for_idle_or_remote():
if self._remote_close_banner_active:
self.alert_banner_secondary.show_message(
"GUI close requested, waiting for beamline/automation to become idle.",
True,
)
return
logger.warning("Closing GUI due to remote close request.")
self.close()
@Slot()
def _check_idle_timeout(self) -> None:
idle_for_s = time.time() - self._last_user_interaction_ts
if idle_for_s < self._idle_close_timeout_s:
return
if not self._can_close_for_idle_or_remote():
logger.info("Idle timeout reached, but GUI remains open because beamline is busy or automation is active.")
return
logger.warning("Closing GUI after inactivity timeout.")
self.close()
+119 -10
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import json
import logging
import time
from typing import Dict
from PySide6.QtCore import Qt, Slot, QUrl
@@ -22,10 +23,13 @@ from PySide6.QtWidgets import (
QDialogButtonBox,
QFormLayout,
QFrame,
QSpinBox,
QMessageBox,
)
from aare.common.error_codes import error_code_help
from aare.common.logger_config import QtLogEmitter, QtLogHandler, find_existing_formatter, attach_to_logger
from aare.common.models import OpenGuiSessionInfo
from aare.gui.threads.daq_worker import DAQWorker
@@ -40,6 +44,7 @@ class DeveloperHelpDialog(QDialog):
self._last_payload: dict = {}
self._freeze_payload: bool = False
self._always_highlight_last_error: bool = True
self._gui_sessions: list[OpenGuiSessionInfo] = []
self.setWindowTitle("Developer / Help")
self.setMinimumSize(860, 560)
@@ -113,7 +118,6 @@ class DeveloperHelpDialog(QDialog):
self._copy_payload_btn.clicked.connect(self._copy_payload)
top.addWidget(self._copy_payload_btn)
# Staff utilities: open log files
self._open_gui_log_btn = QPushButton("Open GUI log", self)
self._open_gui_log_btn.setVisible(self._is_staff)
self._open_gui_log_btn.clicked.connect(lambda: self._open_log_file_for_logger("aareGUI"))
@@ -124,11 +128,9 @@ class DeveloperHelpDialog(QDialog):
self._open_daq_log_btn.clicked.connect(lambda: self._open_log_file_for_logger("aareDAQ"))
top.addWidget(self._open_daq_log_btn)
# Tabs
self._tabs = QTabWidget(self)
root.addWidget(self._tabs, 1)
# Tab: error codes + details pane
self._codes_table = QTableWidget(self)
self._codes_table.setColumnCount(1)
self._codes_table.setHorizontalHeaderLabels(["Name"])
@@ -196,7 +198,6 @@ class DeveloperHelpDialog(QDialog):
self._tabs.addTab(codes_container, "Error codes")
# Tab: last error payload (staff only) + summary header
self._payload_summary = QLabel(self)
self._payload_summary.setVisible(self._is_staff)
self._payload_summary.setWordWrap(True)
@@ -216,7 +217,6 @@ class DeveloperHelpDialog(QDialog):
else:
self._payload_text.setPlainText("Hidden (staff only).")
# Tab: recent payloads (staff only)
self._payloads_text = QTextEdit(self)
self._payloads_text.setReadOnly(True)
self._payloads_text.setLineWrapMode(QTextEdit.LineWrapMode.NoWrap)
@@ -227,7 +227,6 @@ class DeveloperHelpDialog(QDialog):
payloads_layout.addWidget(self._payloads_text, 1)
self._tabs.addTab(payloads_container, "Recent payloads")
# Tab: tracebacks (staff only)
self._tracebacks_text = QTextEdit(self)
self._tracebacks_text.setReadOnly(True)
self._tracebacks_text.setLineWrapMode(QTextEdit.LineWrapMode.NoWrap)
@@ -240,7 +239,6 @@ class DeveloperHelpDialog(QDialog):
tb_layout.addWidget(self._tracebacks_text, 1)
self._tabs.addTab(tb_container, "Tracebacks")
# Tab: error log (staff only) live view from python logging
self._error_log_text = QTextEdit(self)
self._error_log_text.setReadOnly(True)
self._error_log_text.setLineWrapMode(QTextEdit.LineWrapMode.NoWrap)
@@ -251,19 +249,56 @@ class DeveloperHelpDialog(QDialog):
log_layout.addWidget(self._error_log_text, 1)
self._tabs.addTab(log_container, "Error log")
if self._is_staff:
self._gui_sessions_table = QTableWidget(self)
self._gui_sessions_table.setColumnCount(7)
self._gui_sessions_table.setHorizontalHeaderLabels(
["Session", "User", "Staff", "Last seen", "Last interaction", "Close requested", "Requested by"]
)
self._gui_sessions_table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
self._gui_sessions_table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
self._gui_sessions_table.setSelectionMode(QTableWidget.SelectionMode.SingleSelection)
self._gui_sessions_table.setSortingEnabled(True)
self._gui_sessions_table.horizontalHeader().setStretchLastSection(True)
self._gui_refresh_btn = QPushButton("Refresh GUIs", self)
self._gui_refresh_btn.clicked.connect(self._daq.load_gui_sessions)
self._gui_close_grace_spin = QSpinBox(self)
self._gui_close_grace_spin.setRange(5, 3600)
self._gui_close_grace_spin.setValue(60)
self._gui_close_grace_spin.setSuffix(" s")
self._gui_request_close_btn = QPushButton("Request Close", self)
self._gui_request_close_btn.clicked.connect(self._request_close_selected_gui)
gui_controls = QHBoxLayout()
gui_controls.addWidget(self._gui_refresh_btn)
gui_controls.addWidget(QLabel("Grace:", self))
gui_controls.addWidget(self._gui_close_grace_spin)
gui_controls.addWidget(self._gui_request_close_btn)
gui_controls.addStretch(1)
gui_container = QWidget(self)
gui_layout = QVBoxLayout(gui_container)
gui_layout.setContentsMargins(0, 0, 0, 0)
gui_layout.setSpacing(8)
gui_layout.addLayout(gui_controls)
gui_layout.addWidget(self._gui_sessions_table, 1)
self._tabs.addTab(gui_container, "Active GUIs")
# Bottom button box
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Close, parent=self)
buttons.rejected.connect(self.reject)
buttons.accepted.connect(self.accept)
root.addWidget(buttons)
# Wire signals
self._daq.error_codes_loaded.connect(self.set_error_codes)
self._daq.last_error_payload_changed.connect(self.set_last_error_payload)
self._daq.last_error_payloads_changed.connect(self.set_last_error_payloads)
if self._is_staff:
self._daq.gui_sessions_loaded.connect(self._set_gui_sessions)
# Hook a Qt logging handler to show ERROR+ messages in the dialog
if self._is_staff:
self._qt_log_emitter = QtLogEmitter()
self._qt_log_emitter.message.connect(self._append_error_log_line)
@@ -309,6 +344,7 @@ class DeveloperHelpDialog(QDialog):
self.set_last_error_payload(self._daq.get_last_error_payload())
if self._is_staff:
self.set_last_error_payloads(self._daq.get_last_error_payloads())
self._daq.load_gui_sessions()
@Slot(dict)
def set_error_codes(self, codes: dict) -> None:
@@ -492,3 +528,76 @@ class DeveloperHelpDialog(QDialog):
if not self._is_staff:
return
QGuiApplication.clipboard().setText(self._payload_text.toPlainText())
def _format_age(self, ts: float | None) -> str:
if ts is None:
return "-"
delta = max(0, int(time.time() - float(ts)))
if delta < 60:
return f"{delta}s ago"
if delta < 3600:
return f"{delta // 60}m {delta % 60}s ago"
return f"{delta // 3600}h {(delta % 3600) // 60}m ago"
@Slot(list)
def _set_gui_sessions(self, sessions: list) -> None:
if not self._is_staff:
return
self._gui_sessions = [s if isinstance(s, OpenGuiSessionInfo) else OpenGuiSessionInfo.model_validate(s) for s in sessions]
self._gui_sessions_table.setSortingEnabled(False)
self._gui_sessions_table.setRowCount(len(self._gui_sessions))
for row, session in enumerate(self._gui_sessions):
values = [
str(session.session),
session.username,
"Yes" if session.staff else "No",
self._format_age(session.last_seen_ts),
self._format_age(session.last_interaction_ts),
"Yes" if session.close_requested else "No",
session.close_requested_by or "-",
]
for col, value in enumerate(values):
item = QTableWidgetItem(value)
item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsEditable)
if col == 0:
item.setData(Qt.ItemDataRole.UserRole, session.session)
self._gui_sessions_table.setItem(row, col, item)
self._gui_sessions_table.resizeColumnsToContents()
self._gui_sessions_table.setSortingEnabled(True)
def _selected_gui_session_id(self) -> int | None:
if not self._is_staff:
return None
row = self._gui_sessions_table.currentRow()
if row < 0:
return None
item = self._gui_sessions_table.item(row, 0)
if item is None:
return None
value = item.data(Qt.ItemDataRole.UserRole)
try:
return int(value)
except Exception:
return None
@Slot()
def _request_close_selected_gui(self) -> None:
session_id = self._selected_gui_session_id()
if session_id is None:
QMessageBox.information(self, "Active GUIs", "Please select a GUI session first.")
return
grace_seconds = int(self._gui_close_grace_spin.value())
answer = QMessageBox.question(
self,
"Request GUI Close",
f"Request GUI session {session_id} to close with a {grace_seconds}s grace period?",
)
if answer != QMessageBox.StandardButton.Yes:
return
self._daq.request_gui_close(session_id, grace_seconds)
self._daq.load_gui_sessions()
+60 -3
View File
@@ -13,8 +13,17 @@ from aare.common.auth_models import BatonStatus
from aare.common.coordinate import SmargonCoordinate, Coordinate
from aare.common.error_codes import export_error_codes
from aare.common.exception_handler import JFJochCommunicationError
from aare.common.models import DAQStatusModel, SampleShortInfoList, SampleShortInfo, SampleCameraSettings, \
AutofocusSettings, SimpleScanParameters, FluorescenceSpectrumParameterModel, FluorescenceSpectrumOutputModel
from aare.common.models import (
DAQStatusModel,
SampleShortInfoList,
SampleShortInfo,
SampleCameraSettings,
AutofocusSettings,
SimpleScanParameters,
FluorescenceSpectrumParameterModel,
FluorescenceSpectrumOutputModel,
OpenGuiSessionInfo,
)
from aare.common.automation_models import (
AutomationProgress,
StepState,
@@ -38,6 +47,8 @@ class DAQWorker(QObject):
http_error = Signal(str)
status_message = Signal(str, bool)
automation_progress = Signal(object)
gui_sessions_loaded = Signal(list)
gui_close_requested = Signal(int, int, str)
#dedicated signals for polled device errors and request-time errors
polled_devices_status = Signal(str, bool) # (message, is_error)
@@ -1824,4 +1835,50 @@ class DAQWorker(QObject):
@Slot()
def workflow_stop_automation(self):
"""Stop automation mode."""
self.generic_post("workflow/automation/stop")
self.generic_post("workflow/automation/stop")
def _handle_gui_sessions_response(self, reply: QNetworkReply):
try:
response_data = self.handle_response(reply)
payload = json.loads(response_data) if response_data else []
sessions = [OpenGuiSessionInfo.model_validate(item) for item in payload]
self.gui_sessions_loaded.emit(sessions)
except Exception as e:
logger.error(f"Failed to load GUI sessions: {e}")
self.http_error.emit(str(e))
@Slot()
def load_gui_sessions(self):
if self.__base_url is None:
self.gui_sessions_loaded.emit([])
return
request = QNetworkRequest(QUrl(f"{self.__base_url}/admin/gui_sessions"))
request.setRawHeader(b"Authorization", f"Bearer {self.__token}".encode("utf-8"))
reply = self.__net_manager.get(request)
reply.finished.connect(lambda: self._handle_gui_sessions_response(reply))
@Slot(int, int)
def request_gui_close(self, session_id: int, grace_seconds: int = 60):
if self.__base_url is None:
logger.info(f"POST /admin/gui_sessions/{session_id}/request_close?grace_seconds={grace_seconds}")
return
request = QNetworkRequest(
QUrl(f"{self.__base_url}/admin/gui_sessions/{session_id}/request_close?grace_seconds={grace_seconds}")
)
request.setRawHeader(b"Authorization", f"Bearer {self.__token}".encode("utf-8"))
request.setRawHeader(b"Content-Type", b"application/json")
reply = self.__net_manager.post(request, QByteArray(b""))
reply.finished.connect(lambda: self.handle_req_response(reply))
@Slot(int)
def report_gui_interaction(self, session_id: int):
if self.__base_url is None:
return
request = QNetworkRequest(QUrl(f"{self.__base_url}/admin/gui_sessions/{session_id}/interaction"))
request.setRawHeader(b"Authorization", f"Bearer {self.__token}".encode("utf-8"))
request.setRawHeader(b"Content-Type", b"application/json")
reply = self.__net_manager.post(request, QByteArray(b""))
reply.finished.connect(lambda: reply.deleteLater())