Files
AareDAQ/src/aare/gui/panels/developer_help_dialog.py
T

494 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (dont 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())