release: v1.2.0 — About+update checker, log viewer, restart button, dup-name fix, stale-file hardening #26
@@ -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
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
Reference in New Issue
Block a user