Merge branch 'apache-auth'
Build and Publish / test (push) Failing after 1m39s
Build and Publish / build (push) Skipped
Build and Publish / Build and Deploy Docs (push) Skipped

This commit is contained in:
appleb_m committed 2026-06-10 15:07:13 +02:00
commit 40f2835aca
22 files changed
+548 -292

No files matched your search

+2 -2
View File
@@ -10,8 +10,8 @@ gui:
gonio_camera_id: "1"
daq:
daq_url: "http://mx-x06da-queue-01.psi.ch:5210"
daq_url: "https://mx-x06da-queue-01.psi.ch"
cert_path: "/sls/x06da/misc/.cert/6d.crt"
shared:
jfjoch:
jfjoch_url: "http://sls-gpu-001:8080"
+2 -1
View File
@@ -8,7 +8,8 @@ gui:
gonio_camera_id: ""
daq:
daq_url: "http://mx-x06sa-queue-01.psi.ch:5210"
daq_url: "https://mx-x06sa-queue-01.psi.ch"
cert_path: "/sls/x06sa/misc/.cert/6s.crt"
shared:
jfjoch:
+2 -1
View File
@@ -9,7 +9,8 @@ gui:
gonio_camera_id: "1"
daq:
daq_url: "http://mx-x10sa-queue-01.psi.ch:5210"
daq_url: "https://mx-x10sa-queue-01.psi.ch"
cert_path: "/sls/x10sa/misc/.cert/10s.crt"
shared:
jfjoch:
+83 -5
View File
@@ -1,13 +1,18 @@
import grp
import ipaddress
import logging
import os
import pwd
import re
import uuid
from datetime import datetime, timedelta, UTC
from typing import List
import time
logger = logging.getLogger("aareDAQ")
import jwt
from fastapi import Depends, HTTPException, status
from fastapi import Depends, HTTPException, Request, status
from fastapi.security import OAuth2PasswordRequestForm, OAuth2PasswordBearer
from pydantic import BaseModel
@@ -30,6 +35,10 @@ BATON_REQUEST_TIMEOUT_SECONDS = 30
STAFF_GROUP = "unx-MXgroup"
SUPER_USERS = ["e10019", "e11206", "e18147"]
APACHE_ACCESS_LOG = "/var/log/httpd/daq-access.log"
# Common Log Format: IP - username [timestamp] "request" status bytes ...
_LOG_PATTERN = re.compile(r'^\S+ \S+ (\S+) \[.*?\] ".*?" (\d+)')
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
class TokenData(BaseModel):
@@ -46,15 +55,84 @@ def create_access_token(token: TokenData):
return encoded_jwt
def authenticate_user(cfg: BeamlineConfig, form_data: OAuth2PasswordRequestForm) -> str:
user_info = pwd.getpwnam(form_data.username)
def authenticate_from_apache_log() -> str:
"""
Read the Apache access log and return the username from the most recent
200 response. Apache and the DAQ server are co-located on the same machine.
"""
try:
with open(APACHE_ACCESS_LOG, 'r') as f:
lines = f.readlines()
except OSError as e:
raise AuthenticationException(
message=f"Cannot read Apache access log: {e}",
status_code=401,
headers={"WWW-Authenticate": "Bearer"},
code=AuthErrorCode.INVALID_TOKEN,
) from e
for line in reversed(lines):
m = _LOG_PATTERN.match(line)
if m and m.group(2) == '200' and m.group(1) != '-':
return m.group(1)
raise AuthenticationException(
message="No authenticated user found in Apache access log",
status_code=401,
headers={"WWW-Authenticate": "Bearer"},
code=AuthErrorCode.INVALID_TOKEN,
)
def _is_loopback(host: str | None) -> bool:
if host is None:
return False
try:
addr = ipaddress.ip_address(host)
# Unwrap IPv4-mapped IPv6 (e.g. ::ffff:127.0.0.1) before checking
if isinstance(addr, ipaddress.IPv6Address) and addr.ipv4_mapped is not None:
return addr.ipv4_mapped.is_loopback
return addr.is_loopback
except ValueError:
return False
def authenticate_from_proxy_header(request: Request) -> str:
"""
Extract the pre-authenticated username from the trusted Apache proxy header.
Only trust this header when the request originates from localhost, meaning
only the Apache proxy running on the same host can supply it.
"""
client_host = request.client.host if request.client else None
logger.debug(f"[auth] /token client_host={client_host!r} request.client={request.client!r}")
if not _is_loopback(client_host):
logger.warning(f"[auth] Rejecting X-Remote-User: client_host {client_host!r} is not loopback")
raise AuthenticationException(
message="X-Remote-User header is only trusted from the localhost proxy",
status_code=401,
headers={"WWW-Authenticate": "Bearer"},
code=AuthErrorCode.INVALID_TOKEN,
)
remote_user = request.headers.get("X-Remote-User")
if not remote_user:
raise AuthenticationException(
message="Missing X-Remote-User header from proxy",
status_code=401,
headers={"WWW-Authenticate": "Bearer"},
code=AuthErrorCode.INVALID_TOKEN,
)
return remote_user
def authenticate_user(cfg: BeamlineConfig, username: str) -> str:
user_info = pwd.getpwnam(username)
groups = os.getgrouplist(user_info.pw_name, user_info.pw_gid)
supplementary_groups = [grp.getgrgid(g).gr_name.lower() for g in groups]
super_user = form_data.username in SUPER_USERS
super_user = username in SUPER_USERS
pgroups = [group for group in supplementary_groups if group.startswith('p')]
staff = "unx-mxgroup" in supplementary_groups or "unx-sls_mx" in supplementary_groups or super_user
token = TokenData(sub=form_data.username,
token = TokenData(sub=username,
pgroups=pgroups,
staff=staff,
session=cfg.generate_session())
+13 -4
View File
@@ -24,7 +24,7 @@ from aare.common.automation_models import AutomationProgress
from aare.common.raster_grid import RasterGridRequest, CompletedRasterGrid
from aare.common.rotation_scan import RotationScanRequest, CompletedRotationScan
from aare.common.sample_geometry import SampleGeometryModel
from fastapi import FastAPI, Depends
from fastapi import FastAPI, Depends, Request
from fastapi.concurrency import run_in_threadpool
from fastapi import HTTPException
from fastapi import status as api_status
@@ -256,17 +256,26 @@ async def automation_progress_event_stream() -> AsyncGenerator[str, None]:
return
@app.post("/token")
async def login(form_data: OAuth2PasswordRequestForm = Depends()):
async def login(request: Request, form_data: OAuth2PasswordRequestForm = Depends()):
"""
Authenticate a user and return an access token.
When the request carries an X-Remote-User header (set by the Apache Kerberos
proxy), the username is taken from that header and the proxy origin is verified.
Otherwise the username from the form data is used (local / dev access).
Args:
request: The incoming HTTP request (used to inspect headers and client IP).
form_data: OAuth2 password request form containing username and password.
Returns:
A dictionary containing the access token and token type.
"""
data = await run_in_threadpool(auth.authenticate_user, cfg, form_data)
if request.headers.get("X-Remote-User"):
username = auth.authenticate_from_proxy_header(request)
else:
username = auth.authenticate_from_apache_log()
data = await run_in_threadpool(auth.authenticate_user, cfg, username)
return {"access_token": data, "token_type": "bearer"}
@app.get("/meta/error-codes")
@@ -2377,7 +2386,7 @@ def main():
urllib3.disable_warnings()
# Run the application using uvicorn
uvicorn.run("aare.daq.server:app", host="0.0.0.0", port=5210, workers=2, log_config=get_uvicorn_logging_config())
uvicorn.run("aare.daq.server:app", host="127.0.0.1", port=5210, workers=2, proxy_headers=False, log_config=get_uvicorn_logging_config())
if __name__ == "__main__":
+41 -26
View File
@@ -1,6 +1,6 @@
import os
import json
import subprocess
import jwt
import requests
from aare.common.models import TokenData
from aare.common.auth_models import get_user
@@ -8,7 +8,7 @@ from aare.common.logger_config import setup_logger
logger = setup_logger('aareGUI')
def auth(base_url: str | None) -> str:
def auth(base_url: str | None, cert_path: str | None) -> str:
curr_user = get_user()
if base_url is None:
token_data = TokenData(sub=curr_user,
@@ -17,38 +17,53 @@ def auth(base_url: str | None) -> str:
pgroups=["p16371", "p22233"])
return jwt.encode(token_data.model_dump(), "ABC123")
# Single call: Kerberos SPNEGO through Apache, which proxies to the DAQ server.
# Apache injects X-Remote-User; the DAQ server reads it from the localhost-proxied request.
url = f"{base_url}/token"
cacert = f"{cert_path}"
try:
response = requests.post(
url,
data={
"username": curr_user,
"password": ""
},
headers={
"Content-Type": "application/x-www-form-urlencoded"
},
timeout=(3.0, 15.0), # (connect timeout, read timeout)
token_result = subprocess.run(
['curl', '-s', '--cacert', cacert,
'--negotiate', '-u', ':',
'-X', 'POST', url,
'-d', 'username=&password=',
'-H', 'Content-Type: application/x-www-form-urlencoded'],
capture_output=True,
text=True,
timeout=18.0,
)
except requests.RequestException as e:
logger.error(f"Authentication request failed (network): {e}")
except subprocess.TimeoutExpired as e:
logger.error(f"Token request curl timed out: {e}")
raise RuntimeError(
"Cannot reach AareDAQ server (network error). "
"Cannot reach AareDAQ server (timeout). "
"Please check the server is running and your connection."
) from e
if response.status_code != 200:
# Avoid dumping full HTML/tracebacks into the GUI; keep it short and actionable
logger.error(f"Authentication request failed: HTTP {response.status_code}. Body: {response.text[:500]}")
except FileNotFoundError as e:
logger.error(f"curl not found: {e}")
raise RuntimeError("curl not found on this system.") from e
except OSError as e:
logger.error(f"Token request curl OS error: {e}")
raise RuntimeError(
f"Authentication failed (HTTP {response.status_code}). "
"The server may be starting up or unavailable."
"Cannot reach AareDAQ server (OS error). "
"Please check the server is running and your connection."
) from e
except Exception as e:
logger.error(f"Token request curl error: {e}")
raise RuntimeError(
"Cannot reach AareDAQ server (unknown error). "
)
if token_result.returncode != 0:
logger.error(f"Token curl exited {token_result.returncode}. stderr: {token_result.stderr[:500]}")
raise RuntimeError(
f"Token request failed (curl exit {token_result.returncode}). "
"Check Kerberos ticket is valid (kinit) and server is reachable."
)
try:
response_json = response.json()
except ValueError as e:
logger.error(f"Authentication response was not JSON. Body: {response.text[:500]}")
response_json = json.loads(token_result.stdout)
except json.JSONDecodeError as e:
logger.error(f"Token response not JSON. stdout: {token_result.stdout[:500]}")
raise RuntimeError(
"Authentication failed (invalid server response). "
"The server may be starting up or misconfigured."
@@ -56,7 +71,7 @@ def auth(base_url: str | None) -> str:
token = response_json.get("access_token")
if not token or not isinstance(token, str):
logger.error(f"Authentication response missing access_token. Keys: {list(response_json.keys())}")
logger.error(f"Missing access_token. Keys: {list(response_json.keys())}. Server response {response_json}")
raise RuntimeError(
"Authentication failed (missing token in server response). "
"The server may be starting up."
Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

+17 -4
View File
@@ -45,21 +45,24 @@ def main():
#TODO if zmq and pred stream come from same source, do not need images from both streams, can combine
match mx_beamline():
case MXBeamline.X06DA:
default_url = cfg_get("gui.daq.daq_url", "http://mx-x06da-queue-01.psi.ch:5210")
default_url = cfg_get("gui.daq.daq_url", "https://mx-x06da-queue-01.psi.ch")
default_cert_path = cfg_get("gui.daq.cert_path", "/sls/x06da/misc/.cert/6d.crt")
default_zmq_addr = cfg_get("gui.cameras.sample_camera_zmq_url", "tcp://x06da-pserv-01:9089")
default_pred_zmq_addr = cfg_get("gui.cameras.prediction_zmq_url", "tcp://mx-ml:9091")
default_beamline_cam_addr = cfg_get("gui.cameras.beamline_camera_url", "x06da-axis-1.psi.ch")
default_gonio_cam_addr = cfg_get("gui.cameras.gonio_camera_url", "axis-accc8ed2972e.psi.ch")
default_gonio_camera_id = int(cfg_get("gui.cameras.gonio_camera_id", 3))
case MXBeamline.X10SA:
default_url = cfg_get("gui.daq.daq_url", "http://mx-x10sa-queue-01.psi.ch:5210")
default_url = cfg_get("gui.daq.daq_url", "https://mx-x10sa-queue-01.psi.ch")
default_cert_path = cfg_get("gui.daq.cert_path", "/sls/x10sa/misc/.cert/10s.crt")
default_zmq_addr = cfg_get("gui.cameras.sample_camera_zmq_url", "tcp://x10sa-spark-01:9091")
default_pred_zmq_addr = cfg_get("gui.cameras.prediction_zmq_url", "tcp://x10sa-spark-01:9091")
default_beamline_cam_addr = cfg_get("gui.cameras.beamline_camera_url", "axis-accc8eb02488.psi.ch")
default_gonio_cam_addr = cfg_get("gui.cameras.gonio_camera_url", "axis-accc8ea5e463.psi.ch")
default_gonio_camera_id = int(cfg_get("gui.cameras.gonio_camera_id", 1))
case MXBeamline.X06SA:
default_url = cfg_get("gui.daq.daq_url", "http://mx-x06sa-queue-01.psi.ch:5210")
default_url = cfg_get("gui.daq.daq_url", "https://mx-x06sa-queue-01.psi.ch")
default_cert_path = cfg_get("gui.daq.cert_path", "/sls/x06sa/misc/.cert/6s.crt")
default_zmq_addr = cfg_get("gui.cameras.sample_camera_zmq_url", "")
default_pred_zmq_addr = cfg_get("gui.cameras.prediction_zmq_url", "")
default_beamline_cam_addr = cfg_get("gui.cameras.beamline_camera_url", "")
@@ -67,6 +70,7 @@ def main():
default_gonio_camera_id = int(cfg_get("gui.cameras.gonio_camera_id", 1))
case _:
default_url = cfg_get("gui.daq.daq_url", "")
default_cert_path = cfg_get("gui.daq.cert_path", "")
default_zmq_addr = cfg_get("gui.cameras.sample_camera_zmq_url", "")
default_pred_zmq_addr = cfg_get("gui.cameras.prediction_zmq_url", "")
default_beamline_cam_addr = cfg_get("gui.cameras.beamline_camera_url", "")
@@ -79,6 +83,11 @@ def main():
"url", default_url)
parser.addOption(urlOption)
certPath = QCommandLineOption(["c", "aaredaq-cert-path"],
"Server Certificate Path (for self-signed certificates)",
"cert", default_cert_path)
parser.addOption(certPath)
defaultImage = QCommandLineOption(["i", "image"],
"Default image to display in absence of the ZMQ stream",
"image")
@@ -105,6 +114,10 @@ def main():
if base_url == "" or base_url.lower() == "none":
base_url = None
cert_path = parser.value(certPath)
if cert_path == "" or cert_path.lower() == "none":
cert_path = None
zmq_addr = parser.value(cameraZeroMQ)
if zmq_addr == "":
zmq_addr = None
@@ -123,7 +136,7 @@ def main():
try:
splash.set_progress(50, f"Connecting to {base_url or 'backend'}...")
token = auth(base_url)
token = auth(base_url, cert_path)
if not token or token.count(".") != 2:
raise RuntimeError(
"Authentication did not return a valid token. "
+71 -19
View File
@@ -106,6 +106,13 @@ class MainWindow(QMainWindow):
self._controls_help_dialog = None
self._cleanup_done = False
self._default_window_state = None
self._pre_automation_window_state = None
self._pre_automation_ref_tools_visible = False
self._pre_automation_left_column_visible = True
self._pre_automation_right_column_visible = True
self._in_compact_automation_view = False
self._enter_automation_view_action = None
self._return_main_view_action = None
self._beamline_cam_addr = beamline_cam_addr
self._gonio_cam_addr = gonio_cam_addr
@@ -199,7 +206,7 @@ class MainWindow(QMainWindow):
self.raster = RasterGridManager(geom=geom)
self.rotation = RotationScanManager()
collection_controls_scroll = NoWheelScrollArea(top_widget)
self.collection_controls_scroll = NoWheelScrollArea(top_widget)
self.left_column = QWidget(parent=top_widget)
self.left_column_layout = QVBoxLayout(self.left_column)
@@ -226,13 +233,13 @@ class MainWindow(QMainWindow):
self.beamline_state_panel.hide()
self.left_column_layout.addStretch()
top_widget_layout.addWidget(collection_controls_scroll)
collection_controls_scroll.setWidget(self.left_column)
collection_controls_scroll.setHorizontalScrollBarPolicy(
top_widget_layout.addWidget(self.collection_controls_scroll)
self.collection_controls_scroll.setWidget(self.left_column)
self.collection_controls_scroll.setHorizontalScrollBarPolicy(
Qt.ScrollBarPolicy.ScrollBarAlwaysOff
)
collection_controls_scroll.setWidgetResizable(True)
collection_controls_scroll.setFixedWidth(
self.collection_controls_scroll.setWidgetResizable(True)
self.collection_controls_scroll.setFixedWidth(
max(
self.data_collection.set_width,
self.loop_centering.sizeHint().width(),
@@ -295,7 +302,7 @@ class MainWindow(QMainWindow):
self.compact_automation_page.setStyleSheet(
"""
QWidget#compactAutomationPage {
background-color: #e8eefc;
background-color: rgb(216, 228, 253);
}
"""
)
@@ -307,15 +314,15 @@ class MainWindow(QMainWindow):
top_widget_layout.addWidget(self.video_tab)
self._start_axis_camera_threads()
beamline_controls_scroll = NoWheelScrollArea(top_widget)
self.beamline_controls_scroll = NoWheelScrollArea(top_widget)
self.beamline = BeamlineControls(beamline_controls_scroll, staff=self.__decoded_token.staff)
top_widget_layout.addWidget(beamline_controls_scroll)
beamline_controls_scroll.setWidget(self.beamline)
beamline_controls_scroll.setHorizontalScrollBarPolicy(
self.beamline = BeamlineControls(self.beamline_controls_scroll, staff=self.__decoded_token.staff)
top_widget_layout.addWidget(self.beamline_controls_scroll)
self.beamline_controls_scroll.setWidget(self.beamline)
self.beamline_controls_scroll.setHorizontalScrollBarPolicy(
Qt.ScrollBarPolicy.ScrollBarAlwaysOff
)
beamline_controls_scroll.setFixedWidth(self.beamline.set_width + 10)
self.beamline_controls_scroll.setFixedWidth(self.beamline.set_width + 10)
self.tell_samples = TellSamplePanel(samples=SampleShortInfoList(s=[]))
self.ref_tools_panel = ReferenceToolsPanel(samples=SampleShortInfoList(s=[]))
@@ -478,6 +485,7 @@ class MainWindow(QMainWindow):
self.setWindowTitle("AareGUI")
self.create_menu_bar()
self._update_view_mode_actions()
self._setup_global_shortcuts()
self._capture_default_window_state()
self._restore_window_state()
@@ -916,6 +924,17 @@ class MainWindow(QMainWindow):
key=lambda sample: sample.loc_str_sort(),
)
def _update_view_mode_actions(self) -> None:
in_automation_view = bool(self._in_compact_automation_view)
if self._enter_automation_view_action is not None:
self._enter_automation_view_action.setVisible(not in_automation_view)
self._enter_automation_view_action.setEnabled(not in_automation_view)
if self._return_main_view_action is not None:
self._return_main_view_action.setVisible(in_automation_view)
self._return_main_view_action.setEnabled(in_automation_view)
@Slot()
def enter_compact_automation_view(self) -> None:
self.job_list_panel.ensure_default_queue_from_samples(
@@ -923,6 +942,12 @@ class MainWindow(QMainWindow):
)
self._refresh_compact_queue_preview()
if not self._in_compact_automation_view:
self._pre_automation_window_state = self.saveState()
self._pre_automation_ref_tools_visible = self.ref_tools_dock.isVisible()
self._pre_automation_left_column_visible = self.collection_controls_scroll.isVisible()
self._pre_automation_right_column_visible = self.beamline_controls_scroll.isVisible()
self.tell_samples_dock.setVisible(False)
self.job_list_dock.setVisible(False)
self.manual_sample_dock.setVisible(False)
@@ -937,12 +962,29 @@ class MainWindow(QMainWindow):
if self.__decoded_token.staff:
self.ref_tools_dock.setVisible(False)
self.collection_controls_scroll.setVisible(False)
self.beamline_controls_scroll.setVisible(False)
self.content_stack.setCurrentWidget(self.compact_automation_page)
self._in_compact_automation_view = True
self._update_view_mode_actions()
@Slot()
def _return_from_compact_automation_view(self) -> None:
self.content_stack.setCurrentWidget(self._standard_main_page)
self.restore_default_view()
if self._pre_automation_window_state is not None:
self.restoreState(self._pre_automation_window_state)
self.collection_controls_scroll.setVisible(self._pre_automation_left_column_visible)
self.beamline_controls_scroll.setVisible(self._pre_automation_right_column_visible)
if self.__decoded_token.staff:
self.ref_tools_dock.setVisible(self._pre_automation_ref_tools_visible)
self._in_compact_automation_view = False
self._update_view_mode_actions()
self.job_list_dock.setVisible(True)
self.tell_samples_dock.setVisible(True)
self.job_list_dock.raise_()
@@ -1025,6 +1067,16 @@ class MainWindow(QMainWindow):
quit_action.triggered.connect(self.close)
file_menu.addAction(quit_action)
self._enter_automation_view_action = QAction("Automation View", self)
self._enter_automation_view_action.setShortcut(QKeySequence("Ctrl+5"))
self._enter_automation_view_action.triggered.connect(self.enter_compact_automation_view)
menu_bar.addAction(self._enter_automation_view_action)
self._return_main_view_action = QAction("Return to Main View", self)
self._return_main_view_action.setShortcut(QKeySequence("Ctrl+Shift+5"))
self._return_main_view_action.triggered.connect(self._return_from_compact_automation_view)
menu_bar.addAction(self._return_main_view_action)
view_menu = menu_bar.addMenu("View")
if self._beamline_state_panel_enabled:
@@ -1138,11 +1190,6 @@ class MainWindow(QMainWindow):
beamline_combined_tab_action.triggered.connect(lambda: self.video_tab.setCurrentIndex(3))
view_menu.addAction(beamline_combined_tab_action)
compact_automation_action = QAction("Automation View", self)
compact_automation_action.setShortcut(QKeySequence("Ctrl+5"))
compact_automation_action.triggered.connect(self.enter_compact_automation_view)
view_menu.addAction(compact_automation_action)
view_menu.addSeparator()
restore_default_view_action = QAction("Restore Default View", self)
@@ -1194,6 +1241,11 @@ class MainWindow(QMainWindow):
self.restoreState(self._default_window_state)
self.content_stack.setCurrentWidget(self._standard_main_page)
self._in_compact_automation_view = False
self._update_view_mode_actions()
self.collection_controls_scroll.setVisible(True)
self.beamline_controls_scroll.setVisible(True)
self.tell_samples_dock.setVisible(True)
self.job_list_dock.setVisible(True)
+9
View File
@@ -22,6 +22,12 @@ def get_entry(sample: SampleShortInfo, column: int):
elif column == 6:
return sample.mount_count
elif column == 7:
return sample.raster_count
elif column == 8:
return sample.rotation_count
elif column == 9:
return sample.screening_count
elif column == 10:
return sample.comment
@@ -46,6 +52,9 @@ class UserSampleSpreadsheet(QAbstractTableModel):
"Priority",
"User",
"Mount count",
"Raster count",
"Rotation count",
"Screening count",
"Comment",
]
self.current_sample = current_sample
+114 -88
View File
@@ -37,48 +37,52 @@ class CompactAutomationPanel(QFrame):
self.setStyleSheet(
"""
QFrame#compactAutomationPanel {
background: #edf3fb;
background: #d8e4fd;
border: none;
border-radius: 18px;
}
QFrame#compactTopCard,
QFrame#compactCameraCard,
QFrame#compactControlsCard,
QFrame#compactProgressCard,
QFrame#compactControlsCard {
background: #ffffff;
border: 1px solid #d9e4f2;
QFrame#compactQueueCard,
QFrame#compactQueueItem {
background: #e6eefc;
border: 1px solid #b9ccee;
border-radius: 16px;
}
QFrame#compactQueueItem {
background: #f7faff;
border: 1px solid #dbe6f5;
border-radius: 12px;
}
QLabel#compactQueueTitle {
color: #4b5563;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
}
QLabel#compactQueueValue {
color: #0f172a;
QLabel#compactSectionTitle {
background: transparent;
color: #17324d;
font-size: 14px;
font-weight: 700;
}
QLabel#compactDashboardLabel {
color: #334155;
QLabel#compactSectionHint {
background: transparent;
color: #51657d;
font-size: 12px;
}
QLabel#compactQueueTitle {
background: transparent;
color: #51657d;
font-size: 11px;
font-weight: 700;
}
QLabel#compactQueueValue {
background: transparent;
color: #10263a;
font-size: 14px;
font-weight: 700;
}
QToolButton#compactMenuButton {
background: #eff6ff;
color: #1d4ed8;
border: 1px solid #bfdbfe;
background: #cbdcf8;
color: #17324d;
border: 1px solid #9fb9e5;
border-radius: 14px;
padding: 10px 14px;
font-size: 18px;
@@ -86,23 +90,19 @@ class CompactAutomationPanel(QFrame):
}
QToolButton#compactMenuButton:hover {
background: #dbeafe;
}
QFrame#compactAutomationProgressStrip {
background: transparent;
border: none;
background: #bfd4f6;
}
QLabel#compactProgressSummary {
color: #1e293b;
background: transparent;
color: #17324d;
font-size: 13px;
padding: 2px 2px 6px 2px;
}
QLabel#compactProgressStep {
background: #f8fbff;
border: 1px solid #dbe6f5;
background: #dfe9fb;
border: 1px solid #bfd1ef;
border-radius: 10px;
padding: 8px 6px;
}
@@ -121,10 +121,11 @@ class CompactAutomationPanel(QFrame):
background: #1d4ed8;
}
QPushButton#compactSecondaryButton {
background: #ffffff;
color: #0f172a;
border: 1px solid #cbd5e1;
QPushButton#compactSecondaryButton,
QToolButton#compactSecondaryButton {
background: #dfe9fb;
color: #17324d;
border: 1px solid #b2c7eb;
border-radius: 14px;
padding: 14px 18px;
font-size: 14px;
@@ -133,17 +134,7 @@ class CompactAutomationPanel(QFrame):
QPushButton#compactSecondaryButton:hover,
QToolButton#compactSecondaryButton:hover {
background: #f8fafc;
}
QToolButton#compactSecondaryButton {
background: #ffffff;
color: #0f172a;
border: 1px solid #cbd5e1;
border-radius: 14px;
padding: 14px 18px;
font-size: 14px;
font-weight: 700;
background: #d3e1f8;
}
"""
)
@@ -152,44 +143,18 @@ class CompactAutomationPanel(QFrame):
main_layout.setContentsMargins(18, 18, 18, 18)
main_layout.setSpacing(12)
top_card = QFrame(self)
top_card.setObjectName("compactTopCard")
top_layout = QHBoxLayout(top_card)
top_layout.setContentsMargins(12, 12, 12, 12)
top_layout.setSpacing(10)
camera_card = QFrame(self)
camera_card.setObjectName("compactCameraCard")
camera_layout = QVBoxLayout(camera_card)
camera_layout.setContentsMargins(12, 12, 12, 12)
camera_layout.setSpacing(8)
self.current_card = self._build_queue_item("Current", "—")
self.next_card = self._build_queue_item("Next", "—")
self.next_next_card = self._build_queue_item("Then", "—")
camera_title = QLabel("Camera", self)
camera_title.setObjectName("compactSectionTitle")
camera_layout.addWidget(camera_title)
camera_layout.addWidget(camera_widget, 1)
top_layout.addWidget(self.current_card["frame"], 2)
top_layout.addWidget(self.next_card["frame"], 2)
top_layout.addWidget(self.next_next_card["frame"], 2)
self.menu_button = QToolButton(self)
self.menu_button.setObjectName("compactMenuButton")
self.menu_button.setText("☰")
self.menu_button.setToolTip("Return to main view")
self.menu_button.clicked.connect(self.show_full_view_requested.emit)
top_layout.addWidget(self.menu_button, 0, Qt.AlignmentFlag.AlignTop)
main_layout.addWidget(top_card)
main_layout.addWidget(camera_widget, 1)
progress_card = QFrame(self)
progress_card.setObjectName("compactProgressCard")
progress_layout = QVBoxLayout(progress_card)
progress_layout.setContentsMargins(12, 10, 12, 10)
progress_layout.setSpacing(6)
progress_title = QLabel("Automation progress", self)
progress_title.setObjectName("compactDashboardLabel")
progress_layout.addWidget(progress_title)
self.progress_strip = CompactAutomationProgressStrip(self)
progress_layout.addWidget(self.progress_strip)
main_layout.addWidget(progress_card)
main_layout.addWidget(camera_card, 1)
controls_card = QFrame(self)
controls_card.setObjectName("compactControlsCard")
@@ -197,9 +162,31 @@ class CompactAutomationPanel(QFrame):
controls_layout.setContentsMargins(12, 12, 12, 12)
controls_layout.setSpacing(10)
controls_header = QHBoxLayout()
controls_header.setSpacing(10)
controls_title_wrap = QVBoxLayout()
controls_title_wrap.setContentsMargins(0, 0, 0, 0)
controls_title_wrap.setSpacing(2)
controls_title = QLabel("Controls", self)
controls_title.setObjectName("compactDashboardLabel")
controls_layout.addWidget(controls_title)
controls_title.setObjectName("compactSectionTitle")
controls_hint = QLabel("Run and manage the automation queue.", self)
controls_hint.setObjectName("compactSectionHint")
controls_title_wrap.addWidget(controls_title)
controls_title_wrap.addWidget(controls_hint)
self.menu_button = QToolButton(self)
self.menu_button.setObjectName("compactMenuButton")
self.menu_button.setText("☰")
self.menu_button.setToolTip("Return to main view")
self.menu_button.clicked.connect(self.show_full_view_requested.emit)
controls_header.addLayout(controls_title_wrap, 1)
controls_header.addWidget(self.menu_button, 0, Qt.AlignmentFlag.AlignTop)
controls_layout.addLayout(controls_header)
button_row = QHBoxLayout()
button_row.setSpacing(10)
@@ -212,7 +199,7 @@ class CompactAutomationPanel(QFrame):
self.skip_button.setObjectName("compactSecondaryButton")
self.skip_button.clicked.connect(self.skip_clicked.emit)
self.step_button = QPushButton("Step Through", self)
self.step_button = QPushButton("Step", self)
self.step_button.setObjectName("compactSecondaryButton")
self.step_button.setCheckable(True)
self.step_button.toggled.connect(self._on_step_toggled)
@@ -238,6 +225,45 @@ class CompactAutomationPanel(QFrame):
controls_layout.addLayout(button_row)
main_layout.addWidget(controls_card)
progress_card = QFrame(self)
progress_card.setObjectName("compactProgressCard")
progress_layout = QVBoxLayout(progress_card)
progress_layout.setContentsMargins(12, 12, 12, 12)
progress_layout.setSpacing(8)
progress_title = QLabel("Progress", self)
progress_title.setObjectName("compactSectionTitle")
progress_layout.addWidget(progress_title)
self.progress_strip = CompactAutomationProgressStrip(self)
progress_layout.addWidget(self.progress_strip)
main_layout.addWidget(progress_card)
queue_card = QFrame(self)
queue_card.setObjectName("compactQueueCard")
queue_layout = QVBoxLayout(queue_card)
queue_layout.setContentsMargins(12, 12, 12, 12)
queue_layout.setSpacing(10)
queue_title = QLabel("Queue Preview", self)
queue_title.setObjectName("compactSectionTitle")
queue_layout.addWidget(queue_title)
queue_row = QHBoxLayout()
queue_row.setSpacing(10)
self.current_card = self._build_queue_item("Now", "—")
self.next_card = self._build_queue_item("Next", "—")
self.next_next_card = self._build_queue_item("Later", "—")
queue_row.addWidget(self.current_card["frame"], 1)
queue_row.addWidget(self.next_card["frame"], 1)
queue_row.addWidget(self.next_next_card["frame"], 1)
queue_layout.addLayout(queue_row)
main_layout.addWidget(queue_card)
def _build_queue_item(self, title: str, value: str) -> dict[str, QWidget | QLabel]:
frame = QFrame(self)
frame.setObjectName("compactQueueItem")
+7 -1
View File
@@ -27,6 +27,12 @@ def get_entry(sample: SampleShortInfo, column: int):
return sample.sample_name
elif column == 2:
return sample.mount_count
elif column == 3:
return sample.rotation_count
elif column == 4:
return sample.raster_count
elif column == 5:
return sample.screening_count
return ""
class ReferenceToolsModel(QAbstractTableModel):
@@ -35,7 +41,7 @@ class ReferenceToolsModel(QAbstractTableModel):
self.samples: list[SampleShortInfo] = rows or []
self.current_reference = current_reference
self.header = ["Position", "Sample name", "Mount count"]
self.header = ["Position", "Sample name", "Mount count", "Raster count", "Rotation count", "Screening count",]
self.__sort_col = 0
self.__sort_order = Qt.SortOrder.AscendingOrder
self.__sorted_samples: list[SampleShortInfo] = []
+14 -1
View File
@@ -10,7 +10,7 @@ from collections import deque
from typing import cast, Literal
from PySide6.QtCore import Signal, QUrl, Slot, QTimer, QObject, QByteArray
from PySide6.QtNetwork import QNetworkAccessManager, QNetworkRequest, QNetworkReply
from PySide6.QtNetwork import QNetworkAccessManager, QNetworkRequest, QNetworkReply, QSslError
from jfjoch_client import ScanResult, ScanResultImagesInner
from aare.common.auth_models import BatonStatus
@@ -120,6 +120,7 @@ class DAQWorker(QObject):
self.__token = token
self.__base_url = base_url
self.__net_manager = QNetworkAccessManager()
self.__net_manager.sslErrors.connect(self._handle_ssl_errors)
self.__timer = QTimer()
self.__timer.setInterval(500)
self.__timer.timeout.connect(self.regular_update)
@@ -326,6 +327,18 @@ class DAQWorker(QObject):
reply = self.__net_manager.get(request)
reply.finished.connect(lambda: self.handle_status_response(reply))
_SELF_SIGNED_ERRORS = {
QSslError.SslError.SelfSignedCertificate,
QSslError.SslError.SelfSignedCertificateInChain,
}
@staticmethod
def _handle_ssl_errors(reply: QNetworkReply, errors: list):
self_signed = [e for e in errors if e.error() in DAQWorker._SELF_SIGNED_ERRORS]
if self_signed:
reply.ignoreSslErrors(self_signed)
@staticmethod
def handle_response(reply: QNetworkReply):
"""
+13 -1
View File
@@ -1,6 +1,6 @@
from typing import Optional, Dict
from PySide6.QtCore import QObject, Signal, Slot, QUrl, QTimer, QByteArray
from PySide6.QtNetwork import QNetworkAccessManager, QNetworkRequest, QNetworkReply
from PySide6.QtNetwork import QNetworkAccessManager, QNetworkRequest, QNetworkReply, QSslError
class SSEClient(QObject):
@@ -14,6 +14,7 @@ class SSEClient(QObject):
def __init__(self, parent=None):
super().__init__(parent)
self._network_manager = QNetworkAccessManager(self)
self._network_manager.sslErrors.connect(self._handle_ssl_errors)
self._reply: Optional[QNetworkReply] = None
self._reconnect_timer = QTimer(self)
self._reconnect_timer.setSingleShot(True)
@@ -60,6 +61,17 @@ class SSEClient(QObject):
"""Check if connected to SSE"""
return self._connected and self._reply and self._reply.isOpen()
_SELF_SIGNED_ERRORS = {
QSslError.SslError.SelfSignedCertificate,
QSslError.SslError.SelfSignedCertificateInChain,
}
@staticmethod
def _handle_ssl_errors(reply: QNetworkReply, errors: list):
self_signed = [e for e in errors if e.error() in SSEClient._SELF_SIGNED_ERRORS]
if self_signed:
reply.ignoreSslErrors(self_signed)
def _start_connection(self):
"""Start SSE connection"""
request = QNetworkRequest(self._url)
+25 -21
View File
@@ -18,16 +18,24 @@ class CompactAutomationProgressStrip(QFrame):
self._queue_count = 0
self._running = False
self._step_labels: dict[WorkflowStateKind, QLabel] = {}
self._summary_label: QLabel | None = None
self._timer = QTimer(self)
self._timer.setInterval(1000)
self._timer.timeout.connect(self._refresh_live_view)
self.setObjectName("compactAutomationProgressStrip")
self.setStyleSheet(
"""
QFrame#compactAutomationProgressStrip {
background: transparent;
border: none;
}
"""
)
layout = QVBoxLayout(self)
layout.setContentsMargins(14, 12, 14, 12)
layout.setSpacing(10)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(8)
self._summary_label = QLabel("Automation idle", self)
self._summary_label.setObjectName("compactProgressSummary")
@@ -46,7 +54,6 @@ class CompactAutomationProgressStrip(QFrame):
):
label = QLabel(self)
label.setObjectName("compactProgressStep")
label.setMinimumWidth(90)
label.setWordWrap(True)
steps_row.addWidget(label, 1)
self._step_labels[step] = label
@@ -55,8 +62,7 @@ class CompactAutomationProgressStrip(QFrame):
self._apply_empty_state()
def _apply_empty_state(self) -> None:
if self._summary_label is not None:
self._summary_label.setText("Automation idle")
self._summary_label.setText("Automation idle")
for step, label in self._step_labels.items():
label.setText(self._format_step_html(step, StepStatus.PENDING))
@@ -97,9 +103,9 @@ class CompactAutomationProgressStrip(QFrame):
icon = self._step_icon(status)
title = self._step_title(step)
return (
f"<div style='text-align:center;'>"
f"<div style='font-size:18px; color:{color}; font-weight:700;'>{icon}</div>"
f"<div style='font-size:12px; color:{color}; font-weight:700;'>{title}</div>"
f"<div style='text-align:center; background:transparent;'>"
f"<div style='font-size:18px; color:{color}; font-weight:700; background:transparent;'>{icon}</div>"
f"<div style='font-size:12px; color:{color}; font-weight:700; background:transparent;'>{title}</div>"
f"</div>"
)
@@ -120,6 +126,7 @@ class CompactAutomationProgressStrip(QFrame):
return "N/A"
return datetime.fromtimestamp(epoch_seconds).strftime("%H:%M:%S")
@Slot()
def _refresh_live_view(self) -> None:
if self._progress is not None:
self.set_progress(self._progress)
@@ -163,19 +170,16 @@ class CompactAutomationProgressStrip(QFrame):
elif self._running:
state_text = "Running"
state_color = "#2563eb"
elif not self._running:
state_text = "Paused"
state_color = "#c2410c"
if self._summary_label is not None:
self._summary_label.setText(
f"<b>Status:</b> <span style='color:{state_color}; font-weight:700;'>{state_text}</span>"
f" &nbsp;&nbsp; <b>Current:</b> {current_sample}"
f" &nbsp;&nbsp; <b>Queue:</b> {self._queue_count}"
f" &nbsp;&nbsp; <b>ETA:</b> {self._format_duration(queue_remaining)}"
f" &nbsp;&nbsp; <b>Done by:</b> {self._format_eta(eta)}"
)
self._summary_label.setText(
f"<span style='background:transparent;'><b>Status:</b> "
f"<span style='color:{state_color}; font-weight:700; background:transparent;'>{state_text}</span>"
f" &nbsp;&nbsp; <b>Now:</b> {current_sample}"
f" &nbsp;&nbsp; <b>Queue:</b> {self._queue_count}"
f" &nbsp;&nbsp; <b>ETA:</b> {self._format_duration(queue_remaining)}"
f" &nbsp;&nbsp; <b>Done:</b> {self._format_eta(eta)}</span>"
)
states = {step.step: step.status for step in progress.steps}
for step, label in self._step_labels.items():
label.setText(self._format_step_html(step, states.get(step, StepStatus.PENDING)))
label.setText(self._format_step_html(step, states.get(step, StepStatus.PENDING)))