From 2b3843a7140b14244f83bca873786a7b572d3666 Mon Sep 17 00:00:00 2001 From: the_og Date: Tue, 7 Jul 2026 20:40:32 -0400 Subject: [PATCH] feat: add View logs button + LogViewerDialog for in-app MCP log viewer (#6) --- bcc.py | 130 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 130 insertions(+) diff --git a/bcc.py b/bcc.py index 599eaac..6423110 100644 --- a/bcc.py +++ b/bcc.py @@ -374,6 +374,9 @@ class ServerEditor(QFrame): self.spawn_btn.setToolTip("Spawn the server for 3 s and report whether it starts cleanly") self.spawn_btn.clicked.connect(self._test_spawn) self.spawn_btn.setVisible(False) + self.logs_btn = QPushButton("View logs") + self.logs_btn.setToolTip("Open this server's MCP log in a read-only, auto-tailing viewer") + self.logs_btn.clicked.connect(self._view_logs) self.details_btn = QPushButton("Details ▸") self.details_btn.setCheckable(True) self.details_btn.toggled.connect(self._toggle_diag) @@ -384,6 +387,7 @@ class ServerEditor(QFrame): dep.addWidget(self.fix_btn) dep.addWidget(self.test_btn) dep.addWidget(self.spawn_btn) + dep.addWidget(self.logs_btn) dep.addWidget(self.details_btn) dep.addWidget(recheck) outer.addLayout(dep) @@ -728,6 +732,13 @@ class ServerEditor(QFrame): elif self.diag_card.isVisible(): self.diag_text.setPlainText(self._full_diag_text()) + def _view_logs(self): + name = self.current_name() + if not name: + return + dlg = LogViewerDialog(self.window(), name) + dlg.exec() + # --------------------------------------------------------------------------- # # Arguments editor: one line = one argument, with a numbered gutter so that @@ -1090,6 +1101,125 @@ class PasteDialog(QDialog): self.err.setText(str(e)) +# --------------------------------------------------------------------------- # +# Log viewer dialog (issue #6): a read-only, auto-tailing view of a single +# server's MCP log file. Polls on a QTimer instead of watching the filesystem +# so it works the same on every platform; never writes to the log. +# --------------------------------------------------------------------------- # +class LogViewerDialog(QDialog): + POLL_MS = 1500 + MAX_TAIL_BYTES = 300_000 + + def __init__(self, parent, server_name: str): + super().__init__(parent) + self._name = server_name + self._last_path: Path | None = None + self._last_size: int | None = None + self.setWindowTitle(f"Logs — {server_name}") + self.resize(760, 520) + + v = QVBoxLayout(self) + v.setSpacing(8) + + self.path_label = QLabel("") + self.path_label.setObjectName("muted") + self.path_label.setWordWrap(True) + v.addWidget(self.path_label) + + self.view = QPlainTextEdit() + self.view.setObjectName("diag") + self.view.setReadOnly(True) + self.view.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap) + v.addWidget(self.view, 1) + + row = QHBoxLayout() + self.status_label = QLabel("") + self.status_label.setObjectName("muted") + row.addWidget(self.status_label, 1) + refresh_btn = QPushButton("Refresh now") + refresh_btn.clicked.connect(self._poll) + row.addWidget(refresh_btn) + close_btn = QPushButton("Close") + close_btn.setObjectName("primary") + close_btn.clicked.connect(self.accept) + row.addWidget(close_btn) + v.addLayout(row) + + self._timer = QTimer(self) + self._timer.setInterval(self.POLL_MS) + self._timer.timeout.connect(self._poll) + self._timer.start() + + self._poll() + + def _poll(self): + path = core.server_log_path(self._name) + if path is None: + self._last_path = None + self._last_size = None + self.path_label.setText(f"No log file for '{self._name}' yet.") + self.view.setPlaceholderText( + "No log yet — this fills in once the server has run at least once " + "and produced output." + ) + self.view.clear() + self.status_label.setText("Waiting…") + return + + self.path_label.setText(str(path)) + try: + size = path.stat().st_size + except OSError: + self.status_label.setText("Log file disappeared.") + return + + # Nothing changed since the last poll -> skip the re-read/re-render. + if path == self._last_path and size == self._last_size: + return + + was_at_bottom = self._is_scrolled_to_bottom() + text, truncated = self._read_tail(path) + self.view.setPlainText(text) + self.status_label.setText( + f"Showing last {self.MAX_TAIL_BYTES // 1000} KB of the log." if truncated else "" + ) + if was_at_bottom: + self._scroll_to_bottom() + + self._last_path = path + self._last_size = size + + def _read_tail(self, path: Path) -> tuple[str, bool]: + try: + size = path.stat().st_size + with open(path, "rb") as f: + truncated = size > self.MAX_TAIL_BYTES + if truncated: + f.seek(size - self.MAX_TAIL_BYTES) + data = f.read() + except OSError as e: + return f"(could not read log: {e})", False + text = data.decode("utf-8", errors="replace") + if truncated: + nl = text.find("\n") + if nl != -1: + text = text[nl + 1 :] + text = "… (earlier lines truncated) …\n" + text + return text, truncated + + def _is_scrolled_to_bottom(self) -> bool: + sb = self.view.verticalScrollBar() + return sb.value() >= sb.maximum() - 4 + + def _scroll_to_bottom(self): + sb = self.view.verticalScrollBar() + sb.setValue(sb.maximum()) + + def closeEvent(self, event): + self._timer.stop() + super().closeEvent(event) + + # --------------------------------------------------------------------------- # # Main window # --------------------------------------------------------------------------- #