diff --git a/src/aare/common/models.py b/src/aare/common/models.py index 52d23d94..c7cb2dd4 100644 --- a/src/aare/common/models.py +++ b/src/aare/common/models.py @@ -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 diff --git a/src/aare/daq/config.py b/src/aare/daq/config.py index ca337072..ff1c01a8 100644 --- a/src/aare/daq/config.py +++ b/src/aare/daq/config.py @@ -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 diff --git a/src/aare/daq/server.py b/src/aare/daq/server.py index e63be080..107a88be 100644 --- a/src/aare/daq/server.py +++ b/src/aare/daq/server.py @@ -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" diff --git a/src/aare/gui/main_window.py b/src/aare/gui/main_window.py index 045392de..857c7516 100644 --- a/src/aare/gui/main_window.py +++ b/src/aare/gui/main_window.py @@ -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) \ No newline at end of file + 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() \ No newline at end of file diff --git a/src/aare/gui/panels/developer_help_dialog.py b/src/aare/gui/panels/developer_help_dialog.py index 5979f2b2..d418db78 100644 --- a/src/aare/gui/panels/developer_help_dialog.py +++ b/src/aare/gui/panels/developer_help_dialog.py @@ -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() \ No newline at end of file diff --git a/src/aare/gui/threads/daq_worker.py b/src/aare/gui/threads/daq_worker.py index d2d12638..a05ab3a9 100644 --- a/src/aare/gui/threads/daq_worker.py +++ b/src/aare/gui/threads/daq_worker.py @@ -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") \ No newline at end of file + 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()) \ No newline at end of file