"""Bounded GUI logging, error presentation and request-scoped error capture.""" from __future__ import annotations from collections import deque from collections.abc import Callable, Iterator from contextlib import contextmanager from datetime import datetime import html import logging from pathlib import Path import threading import traceback from PyQt6.QtCore import QSignalBlocker, QTimer from PyQt6.QtGui import QTextCursor from PyQt6.QtWidgets import QComboBox, QHBoxLayout, QLabel, QMessageBox, QPushButton, QTextEdit, QVBoxLayout, QWidget from python_app.gui.settings_defaults import env_enabled from python_app.logging_setup import LOG_LEVELS, add_handler, configure_logging, get_logger, package_logger, set_log_level class _PanelLogBuffer: """Worker threads enqueue records; the GUI flushes one bounded batch per tick.""" _CAPACITY = 2000 def __init__(self) -> None: self._lock = threading.Lock() self._entries: deque[tuple[str, str, str | None, str | None]] = deque(maxlen=self._CAPACITY) self._dropped_count = 0 def append(self, level: str, text: str, details: str | None, once_key: str | None) -> None: with self._lock: if len(self._entries) == self._CAPACITY: self._dropped_count += 1 self._entries.append((level, text, details, once_key)) def drain(self) -> tuple[list[tuple[str, str, str | None, str | None]], int]: with self._lock: entries, dropped = list(self._entries), self._dropped_count self._entries.clear() self._dropped_count = 0 return entries, dropped class _PanelHandler(logging.Handler): def __init__(self, buffer: _PanelLogBuffer) -> None: super().__init__() self._buffer = buffer def emit(self, record: logging.LogRecord) -> None: try: details = getattr(record, "panel_details", None) once_key = getattr(record, "panel_once_key", None) self._buffer.append( "WARN" if record.levelname == "WARNING" else record.levelname, record.getMessage(), details if isinstance(details, str) else None, once_key if isinstance(once_key, str) else None, ) except Exception: self.handleError(record) def _entry_html(level: str, text: str, details: str | None = None) -> str: palette = {"DEBUG": ("#6c7b8d", "#52627a", "#8a97a8"), "INFO": ("#1d5fbf", "#1f2937", "#526277"), "WARN": ("#9a5b00", "#5c4300", "#7a6640"), "ERROR": ("#c43d4d", "#6b1f2a", "#8b5d66")} accent, message, detail = palette.get(level.upper(), palette["INFO"]) timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3] body = (f"{html.escape(level)}" f" {timestamp}" f" {html.escape(text).replace(chr(10), '
')}
") if details: body += f"
{html.escape(details)}
" return f"
{body}
" class LogController: def __init__(self, parent: QWidget, project_root: Path, status: Callable[[str], None]) -> None: self._parent, self._status = parent, status configure_logging(log_dir=project_root / "python_app/runtime/logs") self._logger = get_logger("gui") self._buffer = _PanelLogBuffer() self._seen: set[str] = set() self._capture: list[str] | None = None self.panel = QWidget(parent) layout = QVBoxLayout(self.panel) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(6) header = QHBoxLayout() header.setSpacing(8) title = QLabel("Runtime Log") title.setObjectName("hintLabel") header.addWidget(title) header.addStretch(1) label = QLabel("Level") label.setObjectName("hintLabel") header.addWidget(label) self.level = QComboBox() self.level.setObjectName("logLevelCombo") self.level.addItems([name.capitalize() for name in LOG_LEVELS]) self.level.setCurrentText("Info") self.level.setToolTip("Logging verbosity — applied live and saved to run_config") self.level.currentTextChanged.connect(self.set_level) header.addWidget(self.level) self._toggle = QPushButton("Collapse") self._toggle.setObjectName("sectionToggleButton") self._toggle.setToolTip("Hide runtime log entries") self._toggle.clicked.connect(self.toggle) header.addWidget(self._toggle) layout.addLayout(header) self._box = QTextEdit() self._box.setObjectName("runtimeLogBox") self._box.setReadOnly(True) self._box.setUndoRedoEnabled(False) self._box.setMinimumHeight(170) document = self._box.document() assert document is not None document.setMaximumBlockCount(1200) layout.addWidget(self._box) self._handler = _PanelHandler(self._buffer) add_handler(self._handler) self._timer = QTimer(self.panel) self._timer.setInterval(100) self._timer.timeout.connect(self.flush) self._timer.start() def set_level(self, text: str) -> None: with QSignalBlocker(self.level): self.level.setCurrentText(text.capitalize()) set_log_level(text) self.info(f"Log level set to {text.upper()}.") def toggle(self) -> None: visible = self._box.isHidden() self._box.setVisible(visible) self._toggle.setText("Collapse" if visible else "Expand") self._toggle.setToolTip("Hide runtime log entries" if visible else "Show runtime log entries") def flush(self) -> None: entries, dropped = self._buffer.drain() rendered: list[str] = [] if dropped: rendered.append(_entry_html("WARN", f"Log panel overflow: {dropped} record(s) dropped (they are still in the log file).")) for level, text, details, once_key in entries: if once_key is not None: if once_key in self._seen: continue self._seen.add(once_key) rendered.append(_entry_html(level, text, details)) if level == "ERROR": self._status("Status: error") if rendered: cursor = self._box.textCursor() cursor.movePosition(QTextCursor.MoveOperation.End) self._box.setTextCursor(cursor) self._box.insertHtml("".join(rendered)) self._box.insertPlainText("\n") self._box.ensureCursorVisible() def debug(self, text: str, *, once_key: str | None = None) -> None: self._logger.debug(text, extra={"panel_once_key": once_key}) def info(self, text: str, *, once_key: str | None = None) -> None: self._logger.info(text, extra={"panel_once_key": once_key}) def warning(self, text: str, *, details: str | None = None, once_key: str | None = None) -> None: self._logger.warning(text, extra={"panel_details": details, "panel_once_key": once_key}) def error(self, text: str, *, details: str | None = None, once_key: str | None = None) -> None: self._logger.error(text, extra={"panel_details": details, "panel_once_key": once_key}) @staticmethod def exception_summary(exc: Exception) -> str: message = str(exc).strip() return f"{type(exc).__name__}: {message}" if message else type(exc).__name__ @staticmethod def exception_details(exc: Exception) -> str: return "".join(traceback.TracebackException.from_exception(exc).format(chain=True)).strip() def exception(self, context: str, exc: Exception, *, level: str = "ERROR") -> tuple[str, str]: message, details = f"{context}: {self.exception_summary(exc)}", self.exception_details(exc) (self.warning if level.upper() in {"WARN", "WARNING"} else self.error)(message, details=details) return message, details @contextmanager def capture_errors(self) -> Iterator[list[str]]: previous, captured = self._capture, [] self._capture = captured try: yield captured finally: self._capture = previous @contextmanager def suspend_error_capture(self) -> Iterator[None]: """Keep unrelated Qt callbacks out of the active web or GPIO action.""" previous, self._capture = self._capture, None try: yield finally: self._capture = previous def show_error(self, message: str, *, details: str | None = None) -> None: self.error(message, details=details) if self._capture is not None: if not self._capture: self._capture.append(message) return if env_enabled("RADAR_SYSTEM_HEADLESS"): return dialog = QMessageBox(self._parent) dialog.setIcon(QMessageBox.Icon.Critical) dialog.setWindowTitle("Error") dialog.setText(message) if details: dialog.setDetailedText(details) dialog.exec() def show_exception(self, context: str, exc: Exception) -> None: self.show_error(f"{context}: {self.exception_summary(exc)}", details=self.exception_details(exc)) def close(self) -> None: self._timer.stop() package_logger().removeHandler(self._handler) self._handler.close()