From 4ddbf26af705926be60d651a596afd734dba8291 Mon Sep 17 00:00:00 2001 From: Zoe Forbes Date: Sun, 8 Feb 2026 16:22:25 -0600 Subject: [PATCH] feat: add Silo_Diag connection diagnostics command Add a diagnostics command that sequentially tests: - DNS resolution of the server hostname - Health endpoint (/health) - Readiness endpoint (/ready) - Auth endpoint (/auth/me) with token - SSE event stream (/events) Results are printed to the FreeCAD console with PASS/FAIL status. Closes #3 --- freecad/InitGui.py | 1 + freecad/silo_commands.py | 135 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 136 insertions(+) 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 aad817c..3792e54 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 @@ -3191,6 +3192,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()) @@ -3207,3 +3341,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())