from __future__ import annotations import json import logging from typing import Dict from PySide6.QtCore import Qt, Slot, QUrl from PySide6.QtGui import QGuiApplication, QDesktopServices from PySide6.QtWidgets import ( QDialog, QVBoxLayout, QHBoxLayout, QLineEdit, QPushButton, QTabWidget, QTableWidget, QTableWidgetItem, QTextEdit, QLabel, QWidget, QCheckBox, QDialogButtonBox, QFormLayout, QFrame, ) 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.gui.threads.daq_worker import DAQWorker class DeveloperHelpDialog(QDialog): def __init__(self, *, daq: DAQWorker, is_staff: bool, parent=None): super().__init__(parent) self._daq = daq self._is_staff = bool(is_staff) self._codes: Dict[str, str] = {} self._last_payload: dict = {} self._freeze_payload: bool = False self._always_highlight_last_error: bool = True self.setWindowTitle("Developer / Help") self.setMinimumSize(860, 560) root = QVBoxLayout(self) root.setContentsMargins(12, 12, 12, 12) root.setSpacing(8) # Compact banner (staff only) self._banner = QLabel(self) self._banner.setVisible(self._is_staff) self._banner.setWordWrap(True) self._banner.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) self._banner.setStyleSheet( "QLabel {" " background: #f6f6f6;" " border: 1px solid #d0d0d0;" " border-radius: 6px;" " padding: 6px 8px;" "}" ) root.addWidget(self._banner) # Top controls top = QHBoxLayout() top.setSpacing(8) root.addLayout(top) top.addWidget(QLabel("Filter:", self)) self._filter = QLineEdit(self) self._filter.setPlaceholderText("Type to filter (matches name, value, or help)…") self._filter.setClearButtonEnabled(True) self._filter.setMinimumHeight(28) self._filter.setStyleSheet( "QLineEdit {" " background: white;" " border: 1px solid #bdbdbd;" " border-radius: 6px;" " padding: 4px 8px;" "}" ) self._filter.textChanged.connect(self._apply_filter) top.addWidget(self._filter, 1) self._refresh_btn = QPushButton("Refresh", self) self._refresh_btn.clicked.connect(self.refresh) top.addWidget(self._refresh_btn) self._freeze_cb = QCheckBox("Freeze payload", self) self._freeze_cb.setVisible(self._is_staff) self._freeze_cb.toggled.connect(self._set_freeze_payload) top.addWidget(self._freeze_cb) self._highlight_cb = QCheckBox("Always highlight last error code", self) self._highlight_cb.setVisible(self._is_staff) self._highlight_cb.setChecked(True) self._highlight_cb.toggled.connect(self._set_always_highlight) top.addWidget(self._highlight_cb) self._copy_selected_btn = QPushButton("Copy code", self) self._copy_selected_btn.clicked.connect(self._copy_selected_code) top.addWidget(self._copy_selected_btn) self._copy_all_btn = QPushButton("Copy all (filtered)", self) self._copy_all_btn.clicked.connect(self._copy_all_filtered) top.addWidget(self._copy_all_btn) self._copy_payload_btn = QPushButton("Copy payload", self) self._copy_payload_btn.setVisible(self._is_staff) 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")) top.addWidget(self._open_gui_log_btn) self._open_daq_log_btn = QPushButton("Open DAQ log", self) self._open_daq_log_btn.setVisible(self._is_staff) 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"]) self._codes_table.setSortingEnabled(True) self._codes_table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) self._codes_table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows) self._codes_table.setSelectionMode(QTableWidget.SelectionMode.SingleSelection) self._codes_table.itemSelectionChanged.connect(self._update_code_details) self._codes_table.horizontalHeader().setStretchLastSection(True) self._details_frame = QFrame(self) self._details_frame.setFrameShape(QFrame.Shape.StyledPanel) self._details_frame.setStyleSheet( "QFrame {" " background: #fafafa;" " border: 1px solid #d0d0d0;" " border-radius: 6px;" "}" ) details_layout = QVBoxLayout(self._details_frame) details_layout.setContentsMargins(10, 10, 10, 10) details_layout.setSpacing(8) form = QFormLayout() form.setLabelAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) details_layout.addLayout(form) self._detail_name = QLabel("-", self) self._detail_name.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) form.addRow("Name:", self._detail_name) value_row = QHBoxLayout() value_row.setSpacing(8) self._detail_value = QLabel("-", self) self._detail_value.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) self._copy_value_btn = QPushButton("Copy value", self) self._copy_value_btn.clicked.connect(self._copy_selected_value) value_row.addWidget(self._detail_value, 1) value_row.addWidget(self._copy_value_btn, 0) value_row_widget = QWidget(self) value_row_widget.setLayout(value_row) form.addRow("Value:", value_row_widget) self._detail_help = QLabel("-", self) self._detail_help.setWordWrap(True) self._detail_help.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) self._detail_help.setStyleSheet( "QLabel {" " background: white;" " border: 1px solid #e0e0e0;" " border-radius: 6px;" " padding: 8px;" "}" ) details_layout.addWidget(QLabel("Help:", self)) details_layout.addWidget(self._detail_help, 1) codes_container = QWidget(self) codes_layout = QVBoxLayout(codes_container) codes_layout.setContentsMargins(0, 0, 0, 0) codes_layout.setSpacing(8) codes_layout.addWidget(self._codes_table, 1) codes_layout.addWidget(self._details_frame, 0) 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) self._payload_summary.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) self._payload_text = QTextEdit(self) self._payload_text.setReadOnly(True) self._payload_text.setLineWrapMode(QTextEdit.LineWrapMode.NoWrap) if self._is_staff: payload_container = QWidget(self) payload_layout = QVBoxLayout(payload_container) payload_layout.setContentsMargins(0, 0, 0, 0) payload_layout.addWidget(self._payload_summary, 0) payload_layout.addWidget(self._payload_text, 1) self._tabs.addTab(payload_container, "Last error payload") 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) if self._is_staff: payloads_container = QWidget(self) payloads_layout = QVBoxLayout(payloads_container) payloads_layout.setContentsMargins(0, 0, 0, 0) 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) self._tracebacks_text.setPlainText("Tracebacks will appear here (last 10).") if self._is_staff: tb_container = QWidget(self) tb_layout = QVBoxLayout(tb_container) tb_layout.setContentsMargins(0, 0, 0, 0) 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) if self._is_staff: log_container = QWidget(self) log_layout = QVBoxLayout(log_container) log_layout.setContentsMargins(0, 0, 0, 0) log_layout.addWidget(self._error_log_text, 1) self._tabs.addTab(log_container, "Error log") # 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) # 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) self._qt_log_handler = QtLogHandler(self._qt_log_emitter) self._qt_log_handler.setLevel(logging.ERROR) self._qt_log_handler.setFormatter(find_existing_formatter()) attach_to_logger("aareGUI", self._qt_log_handler) attach_to_logger("aareDAQ", self._qt_log_handler) self._update_banner() self._update_code_details() def _open_log_file_for_logger(self, logger_name: str) -> None: """ Opens the first FileHandler path attached to the given logger name. """ log = logging.getLogger(logger_name) paths: list[str] = [] for h in getattr(log, "handlers", []) or []: p = getattr(h, "baseFilename", None) if p and isinstance(p, str): paths.append(p) if not paths: self._banner.setText(f"No file handler found for logger '{logger_name}'.") return QDesktopServices.openUrl(QUrl.fromLocalFile(paths[0])) @Slot(bool) def _set_freeze_payload(self, enabled: bool) -> None: self._freeze_payload = bool(enabled) @Slot(bool) def _set_always_highlight(self, enabled: bool) -> None: self._always_highlight_last_error = bool(enabled) @Slot() def refresh(self) -> None: self._daq.get_error_codes() 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()) @Slot(dict) def set_error_codes(self, codes: dict) -> None: self._codes = {str(k): str(v) for k, v in (codes or {}).items()} self._apply_filter() @Slot(dict) def set_last_error_payload(self, payload: dict) -> None: if not self._is_staff: return if self._freeze_payload: return self._last_payload = payload or {} pretty = json.dumps(self._last_payload, indent=2, sort_keys=True, default=str) self._payload_text.setPlainText(pretty) code, msg = self._extract_code_message() url = str((self._last_payload or {}).get("url") or "") status = (self._last_payload or {}).get("http_status") self._payload_summary.setText( f"URL: {url}\nHTTP: {status}\nCode: {code or '-'}\nMessage: {msg or '-'}" ) self._update_banner() self._select_code_from_last_error() @Slot(list) def set_last_error_payloads(self, payloads: list) -> None: if not self._is_staff: return blocks: list[str] = [] for i, p in enumerate(payloads[-10:], start=max(1, len(payloads) - 9)): try: pretty = json.dumps(p or {}, indent=2, sort_keys=True, default=str) except Exception: pretty = str(p) blocks.append(f"#{i}\n{pretty}") self._payloads_text.setPlainText("\n\n".join(blocks) if blocks else "(none captured yet)") @Slot(str) def _append_error_log_line(self, line: str) -> None: if not self._is_staff: return self._error_log_text.append(line) def _code_name_for_value(self, value: str) -> str | None: for k, v in self._codes.items(): if v == value: return k return None def _extract_code_message(self) -> tuple[str | None, str | None]: body = (self._last_payload or {}).get("body_json") if isinstance(body, dict): code = body.get("code") msg = body.get("message") return (str(code) if code is not None else None, str(msg) if msg is not None else None) return (None, None) def _update_banner(self) -> None: if not self._is_staff: return code, msg = self._extract_code_message() if code or msg: parts = [] if code: parts.append(f"Last error: {code}") if msg: parts.append(msg) self._banner.setText(" — ".join(parts)) else: self._banner.setText("Last error: (none captured yet)") def _code_name_for_value(self, value: str) -> str | None: for name, v in (self._codes or {}).items(): if str(v) == str(value): return str(name) return None def _select_code_by_name(self, name: str) -> None: if not name: return for row in range(self._codes_table.rowCount()): item = self._codes_table.item(row, 0) if item and item.text() == name: self._codes_table.setCurrentCell(row, 0) self._codes_table.scrollToItem(item) return def _select_code_from_last_error(self) -> None: if not self._codes: return code_value, _ = self._extract_code_message() if not code_value: return name = self._code_name_for_value(code_value) if name: self._select_code_by_name(name) @Slot() def _apply_filter(self) -> None: term = (self._filter.text() or "").strip().lower() items = sorted(self._codes.items(), key=lambda kv: kv[0]) if term: def _match(name: str, value: str) -> bool: h = error_code_help(value) or "" return term in f"{name}\n{value}\n{h}".lower() items = [(k, v) for (k, v) in items if _match(k, v)] self._codes_table.setRowCount(len(items)) for row, (k, v) in enumerate(items): item = QTableWidgetItem(k) item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsEditable) self._codes_table.setItem(row, 0, item) self._codes_table.resizeColumnsToContents() # Keep selection sensible (don’t resize columns here; avoids jitter) if self._codes_table.rowCount() > 0 and self._codes_table.currentRow() < 0: self._codes_table.setCurrentCell(0, 0) self._update_code_details() # If enabled, re-try selection after the table content changes if self._is_staff and self._always_highlight_last_error and not self._freeze_payload: self._select_code_from_last_error() def _selected_code(self) -> tuple[str | None, str | None]: row = self._codes_table.currentRow() if row < 0: return (None, None) name_item = self._codes_table.item(row, 0) if not name_item: return (None, None) name = name_item.text() value = self._codes.get(name) return (name, value) @Slot() def _update_code_details(self) -> None: name, value = self._selected_code() if not name or not value: self._detail_name.setText("-") self._detail_value.setText("-") self._detail_help.setText("Select an error code to see details.") self._copy_value_btn.setEnabled(False) return self._detail_name.setText(name) self._detail_value.setText(value) self._detail_help.setText(error_code_help(value) or "(no help text defined yet)") self._copy_value_btn.setEnabled(True) @Slot() def _copy_selected_code(self) -> None: name, value = self._selected_code() if not name or not value: QGuiApplication.clipboard().setText("") return QGuiApplication.clipboard().setText(f"{name}={value}") @Slot() def _copy_selected_value(self) -> None: _, value = self._selected_code() QGuiApplication.clipboard().setText(value or "") @Slot() def _copy_all_filtered(self) -> None: rows = self._codes_table.rowCount() out = {} for r in range(rows): name = self._codes_table.item(r, 0).text() out[name] = self._codes.get(name, "") QGuiApplication.clipboard().setText(json.dumps(out, indent=2, sort_keys=True)) @Slot() def _copy_payload(self) -> None: if not self._is_staff: return QGuiApplication.clipboard().setText(self._payload_text.toPlainText())