diff --git a/freecad/InitGui.py b/freecad/InitGui.py index db03bd6..8f33e2b 100644 --- a/freecad/InitGui.py +++ b/freecad/InitGui.py @@ -48,6 +48,7 @@ class SiloWorkbench(FreeCADGui.Workbench): "Silo_Settings", "Silo_Auth", "Silo_StartPanel", + "Silo_Diag", ] self.appendMenu("Silo", self.menu_commands) diff --git a/freecad/silo_commands.py b/freecad/silo_commands.py index 829693a..6fa500a 100644 --- a/freecad/silo_commands.py +++ b/freecad/silo_commands.py @@ -3,6 +3,7 @@ import json import os import re +import socket import urllib.error import urllib.parse import urllib.request @@ -2365,13 +2366,13 @@ class SiloEventListener(QtCore.QThread): item_updated = QtCore.Signal(str) # part_number revision_created = QtCore.Signal(str, int) # part_number, revision connection_status = QtCore.Signal( - str - ) # "connected" / "disconnected" / "unsupported" + str, int, str + ) # (status, retry_count, error_message) server_mode_changed = QtCore.Signal(str) # "normal" / "read-only" / "degraded" - _MAX_FAST_RETRIES = 3 - _FAST_RETRY_SECS = 5 - _SLOW_RETRY_SECS = 30 + _MAX_RETRIES = 10 + _BASE_DELAY = 1 # seconds, doubles each retry + _MAX_DELAY = 60 # seconds, backoff cap def __init__(self, parent=None): super().__init__(parent) @@ -2394,6 +2395,7 @@ class SiloEventListener(QtCore.QThread): def run(self): retries = 0 + last_error = "" while not self._stop_flag: try: self._listen() @@ -2401,21 +2403,24 @@ class SiloEventListener(QtCore.QThread): if self._stop_flag: return retries += 1 + last_error = "connection closed" except _SSEUnsupported: - self.connection_status.emit("unsupported") + self.connection_status.emit("unsupported", 0, "") return - except Exception: + except Exception as exc: retries += 1 + last_error = str(exc) or "unknown error" - self.connection_status.emit("disconnected") + if retries > self._MAX_RETRIES: + self.connection_status.emit("gave_up", retries - 1, last_error) + return - if retries <= self._MAX_FAST_RETRIES: - delay = self._FAST_RETRY_SECS - else: - delay = self._SLOW_RETRY_SECS + self.connection_status.emit("disconnected", retries, last_error) + + delay = min(self._BASE_DELAY * (2 ** (retries - 1)), self._MAX_DELAY) # Interruptible sleep - for _ in range(delay): + for _ in range(int(delay)): if self._stop_flag: return self.msleep(1000) @@ -2439,7 +2444,7 @@ class SiloEventListener(QtCore.QThread): except urllib.error.URLError: raise - self.connection_status.emit("connected") + self.connection_status.emit("connected", 0, "") event_type = "" data_buf = "" @@ -2705,13 +2710,28 @@ class SiloAuthDockWidget: self._event_listener.stop() self._sse_label.setText("") - def _on_sse_status(self, status): + def _on_sse_status(self, status, retry, error): if status == "connected": self._sse_label.setText("Listening") self._sse_label.setStyleSheet("font-size: 11px; color: #4CAF50;") + self._sse_label.setToolTip("") + FreeCAD.Console.PrintMessage("Silo: SSE connected\n") elif status == "disconnected": - self._sse_label.setText("Reconnecting...") + self._sse_label.setText( + f"Reconnecting ({retry}/{SiloEventListener._MAX_RETRIES})..." + ) self._sse_label.setStyleSheet("font-size: 11px; color: #FF9800;") + self._sse_label.setToolTip(error or "Connection lost") + FreeCAD.Console.PrintWarning( + f"Silo: SSE reconnecting ({retry}/{SiloEventListener._MAX_RETRIES}): {error}\n" + ) + elif status == "gave_up": + self._sse_label.setText("Disconnected") + self._sse_label.setStyleSheet("font-size: 11px; color: #F44336;") + self._sse_label.setToolTip(error or "Max retries reached") + FreeCAD.Console.PrintError( + f"Silo: SSE gave up after {retry} retries: {error}\n" + ) elif status == "unsupported": self._sse_label.setText("Not available") self._sse_label.setStyleSheet("font-size: 11px; color: #888;") @@ -3293,6 +3313,139 @@ class Silo_StartPanel: return True +class _DiagWorker(QtCore.QThread): + """Background worker that runs connectivity diagnostics.""" + + result = QtCore.Signal(str, bool, str) # (test_name, passed, detail) + finished_all = QtCore.Signal() + _HTTP_TIMEOUT = 10 + _DNS_TIMEOUT = 5 + + def run(self): + api_url = _get_api_url() + base_url = api_url.rstrip("/") + if base_url.endswith("/api"): + base_url = base_url[:-4] + self._test_dns(base_url) + self._test_http("Health", f"{base_url}/health", auth=False) + self._test_http("Ready", f"{base_url}/ready", auth=False) + self._test_http("Auth", f"{api_url.rstrip('/')}/auth/me", auth=True) + self._test_sse(api_url) + self.finished_all.emit() + + def _test_dns(self, base_url): + parsed = urllib.parse.urlparse(base_url) + hostname = parsed.hostname + if not hostname: + self.result.emit("DNS", False, "no hostname in URL") + return + try: + addrs = socket.getaddrinfo( + hostname, None, socket.AF_UNSPEC, socket.SOCK_STREAM + ) + first_ip = addrs[0][4][0] if addrs else "?" + self.result.emit("DNS", True, f"{hostname} -> {first_ip}") + except socket.gaierror as e: + self.result.emit("DNS", False, f"{hostname}: {e}") + except Exception as e: + self.result.emit("DNS", False, str(e)) + + def _test_http(self, name, url, auth=False): + try: + headers = {} + if auth: + headers.update(_get_auth_headers()) + req = urllib.request.Request(url, headers=headers, method="GET") + resp = urllib.request.urlopen( + req, context=_get_ssl_context(), timeout=self._HTTP_TIMEOUT + ) + code = resp.getcode() + body = resp.read(4096).decode("utf-8", errors="replace").strip() + detail = f"HTTP {code}" + try: + data = json.loads(body) + if isinstance(data, dict): + parts = [] + for key in ("status", "username", "role"): + if key in data: + parts.append(f"{key}={data[key]}") + if parts: + detail += f" ({', '.join(parts)})" + except (json.JSONDecodeError, ValueError): + if len(body) <= 80: + detail += f" {body}" + self.result.emit(name, code < 400, detail) + except urllib.error.HTTPError as e: + self.result.emit(name, False, f"HTTP {e.code} {e.reason}") + except urllib.error.URLError as e: + self.result.emit(name, False, str(e.reason)) + except Exception as e: + self.result.emit(name, False, str(e)) + + def _test_sse(self, api_url): + url = f"{api_url.rstrip('/')}/events" + try: + headers = {"Accept": "text/event-stream"} + headers.update(_get_auth_headers()) + req = urllib.request.Request(url, headers=headers, method="GET") + resp = urllib.request.urlopen( + req, context=_get_ssl_context(), timeout=self._HTTP_TIMEOUT + ) + content_type = resp.headers.get("Content-Type", "") + code = resp.getcode() + resp.close() + if "text/event-stream" in content_type: + self.result.emit("SSE", True, f"HTTP {code} event-stream") + else: + self.result.emit("SSE", True, f"HTTP {code} (type: {content_type})") + except urllib.error.HTTPError as e: + if e.code in (404, 501): + self.result.emit("SSE", False, f"HTTP {e.code} (not supported)") + else: + self.result.emit("SSE", False, f"HTTP {e.code} {e.reason}") + except urllib.error.URLError as e: + self.result.emit("SSE", False, str(e.reason)) + except Exception as e: + self.result.emit("SSE", False, str(e)) + + +class Silo_Diag: + """Command to run connection diagnostics.""" + + _worker = None + + def GetResources(self): + return { + "MenuText": "Diagnostics", + "ToolTip": "Test DNS, health, readiness, auth, and SSE connectivity", + "Pixmap": _icon("info"), + } + + def Activated(self): + if self._worker is not None and self._worker.isRunning(): + FreeCAD.Console.PrintWarning("Silo: diagnostics already running\n") + return + api_url = _get_api_url() + FreeCAD.Console.PrintMessage(f"--- Silo Diagnostics ({api_url}) ---\n") + self._worker = _DiagWorker() + self._worker.result.connect(self._on_result) + self._worker.finished_all.connect(self._on_finished) + self._worker.start() + + def _on_result(self, name, passed, detail): + if passed: + FreeCAD.Console.PrintMessage(f" PASS {name}: {detail}\n") + else: + FreeCAD.Console.PrintError(f" FAIL {name}: {detail}\n") + + def _on_finished(self): + FreeCAD.Console.PrintMessage("--- Diagnostics complete ---\n") + self._worker = None + + def IsActive(self): + return True + + # Register commands FreeCADGui.addCommand("Silo_Open", Silo_Open()) FreeCADGui.addCommand("Silo_New", Silo_New()) @@ -3309,3 +3462,4 @@ FreeCADGui.addCommand("Silo_Settings", Silo_Settings()) FreeCADGui.addCommand("Silo_Auth", Silo_Auth()) FreeCADGui.addCommand("Silo_StartPanel", Silo_StartPanel()) +FreeCADGui.addCommand("Silo_Diag", Silo_Diag())