494 lines
18 KiB
Python
494 lines
18 KiB
Python
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())
|