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
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user