diff --git a/freecad/silo_commands.py b/freecad/silo_commands.py index bea0aa6..b5a0fb3 100644 --- a/freecad/silo_commands.py +++ b/freecad/silo_commands.py @@ -7,6 +7,7 @@ import socket import urllib.error import urllib.parse import urllib.request +from datetime import datetime from pathlib import Path from typing import Any, Dict, List, Optional, Tuple @@ -26,7 +27,28 @@ from silo_client import ( _PREF_GROUP = "User parameter:BaseApp/Preferences/Mod/KindredSilo" # Configuration - preferences take priority over env vars -SILO_PROJECTS_DIR = os.environ.get("SILO_PROJECTS_DIR", os.path.expanduser("~/projects")) +SILO_PROJECTS_DIR = os.environ.get( + "SILO_PROJECTS_DIR", os.path.expanduser("~/projects") +) + + +def _relative_time(dt): + """Format a datetime as a human-friendly relative string.""" + now = datetime.now() + diff = now - dt + seconds = int(diff.total_seconds()) + if seconds < 60: + return "just now" + minutes = seconds // 60 + if minutes < 60: + return f"{minutes}m ago" + hours = minutes // 60 + if hours < 24: + return f"{hours}h ago" + days = hours // 24 + if days < 30: + return f"{days}d ago" + return dt.strftime("%Y-%m-%d") # --------------------------------------------------------------------------- @@ -64,7 +86,9 @@ class FreeCADSiloSettings(SiloSettings): param = FreeCAD.ParamGet(_PREF_GROUP) return param.GetString("SslCertPath", "") - def save_auth(self, username: str, role: str = "", source: str = "", token: str = ""): + def save_auth( + self, username: str, role: str = "", source: str = "", token: str = "" + ): param = FreeCAD.ParamGet(_PREF_GROUP) param.SetString("AuthUsername", username) param.SetString("AuthRole", role) @@ -122,7 +146,9 @@ def _get_ssl_verify() -> bool: def _get_ssl_context(): from silo_client._ssl import build_ssl_context - return build_ssl_context(_fc_settings.get_ssl_verify(), _fc_settings.get_ssl_cert_path()) + return build_ssl_context( + _fc_settings.get_ssl_verify(), _fc_settings.get_ssl_cert_path() + ) def _get_auth_headers() -> Dict[str, str]: @@ -179,7 +205,9 @@ def _fetch_server_mode() -> str: # Icon helper # --------------------------------------------------------------------------- -_ICON_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "resources", "icons") +_ICON_DIR = os.path.join( + os.path.dirname(os.path.abspath(__file__)), "resources", "icons" +) def _icon(name): @@ -580,7 +608,9 @@ def handle_kindred_url(url: str): parts = [parsed.netloc] + [p for p in parsed.path.split("/") if p] if len(parts) >= 2 and parts[0] == "item": part_number = parts[1] - FreeCAD.Console.PrintMessage(f"Silo: Opening item {part_number} from kindred:// URL\n") + FreeCAD.Console.PrintMessage( + f"Silo: Opening item {part_number} from kindred:// URL\n" + ) _sync.open_item(part_number) @@ -600,9 +630,8 @@ class Silo_Open: } def Activated(self): - from PySide import QtGui, QtWidgets - from open_search import OpenItemWidget + from PySide import QtGui, QtWidgets mw = FreeCADGui.getMainWindow() mdi = mw.findChild(QtWidgets.QMdiArea) @@ -649,7 +678,6 @@ class Silo_New: def Activated(self): from PySide import QtGui, QtWidgets - from schema_form import SchemaFormWidget mw = FreeCADGui.getMainWindow() @@ -689,7 +717,9 @@ class Silo_New: }, ) obj.Label = part_number - _sync.save_to_canonical_path(FreeCAD.ActiveDocument, force_rename=True) + _sync.save_to_canonical_path( + FreeCAD.ActiveDocument, force_rename=True + ) else: _sync.create_document_for_item(result, save=True) @@ -770,7 +800,9 @@ class Silo_Save: # Try to upload to MinIO try: - result = _client._upload_file(part_number, str(file_path), properties, "Auto-save") + result = _client._upload_file( + part_number, str(file_path), properties, "Auto-save" + ) new_rev = result["revision_number"] FreeCAD.Console.PrintMessage(f"Uploaded as revision {new_rev}\n") @@ -803,7 +835,9 @@ class Silo_Commit: obj = get_tracked_object(doc) if not obj: - FreeCAD.Console.PrintError("No tracked object. Use 'New' to register first.\n") + FreeCAD.Console.PrintError( + "No tracked object. Use 'New' to register first.\n" + ) return part_number = obj.SiloPartNumber @@ -820,7 +854,9 @@ class Silo_Commit: if not file_path: return - result = _client._upload_file(part_number, str(file_path), properties, comment) + result = _client._upload_file( + part_number, str(file_path), properties, comment + ) new_rev = result["revision_number"] FreeCAD.Console.PrintMessage(f"Committed revision {new_rev}: {comment}\n") @@ -869,7 +905,9 @@ def _check_pull_conflicts(part_number, local_path, doc=None): server_updated = item.get("updated_at", "") if server_updated: # Parse ISO format timestamp - server_dt = datetime.datetime.fromisoformat(server_updated.replace("Z", "+00:00")) + server_dt = datetime.datetime.fromisoformat( + server_updated.replace("Z", "+00:00") + ) if server_dt > local_mtime: conflicts.append("Server version is newer than local file.") except Exception: @@ -899,7 +937,9 @@ class SiloPullDialog: # Revision table self._table = QtGui.QTableWidget() self._table.setColumnCount(5) - self._table.setHorizontalHeaderLabels(["Rev", "Date", "Comment", "Status", "File"]) + self._table.setHorizontalHeaderLabels( + ["Rev", "Date", "Comment", "Status", "File"] + ) self._table.setSelectionBehavior(QtGui.QAbstractItemView.SelectRows) self._table.setSelectionMode(QtGui.QAbstractItemView.SingleSelection) self._table.setEditTriggers(QtGui.QAbstractItemView.NoEditTriggers) @@ -992,7 +1032,9 @@ def _pull_dependencies(part_number, progress_callback=None): # Skip if already exists locally existing = find_file_by_part_number(child_pn) if existing and existing.exists(): - FreeCAD.Console.PrintMessage(f" {child_pn}: already exists at {existing}\n") + FreeCAD.Console.PrintMessage( + f" {child_pn}: already exists at {existing}\n" + ) # Still recurse — this child may itself be an assembly with missing deps _pull_dependencies(child_pn, progress_callback) continue @@ -1072,14 +1114,18 @@ class Silo_Pull: if not has_any_file: if existing_local: - FreeCAD.Console.PrintMessage(f"Opening existing local file: {existing_local}\n") + FreeCAD.Console.PrintMessage( + f"Opening existing local file: {existing_local}\n" + ) FreeCAD.openDocument(str(existing_local)) else: try: item = _client.get_item(part_number) new_doc = _sync.create_document_for_item(item, save=True) if new_doc: - FreeCAD.Console.PrintMessage(f"Created local file for {part_number}\n") + FreeCAD.Console.PrintMessage( + f"Created local file for {part_number}\n" + ) else: QtGui.QMessageBox.warning( None, @@ -1166,7 +1212,9 @@ class Silo_Pull: progress.setValue(100) progress.close() if dep_pulled: - FreeCAD.Console.PrintMessage(f"Pulled {len(dep_pulled)} dependency file(s)\n") + FreeCAD.Console.PrintMessage( + f"Pulled {len(dep_pulled)} dependency file(s)\n" + ) # Close existing document if open, then reopen if doc and doc.FileName == str(dest_path): @@ -1221,7 +1269,9 @@ class Silo_Push: server_dt = datetime.fromisoformat( server_time_str.replace("Z", "+00:00") ) - local_dt = datetime.fromtimestamp(local_mtime, tz=timezone.utc) + local_dt = datetime.fromtimestamp( + local_mtime, tz=timezone.utc + ) if local_dt > server_dt: unuploaded.append(lf) else: @@ -1234,7 +1284,9 @@ class Silo_Push: pass # Not in DB, skip if not unuploaded: - QtGui.QMessageBox.information(None, "Push", "All local files are already uploaded.") + QtGui.QMessageBox.information( + None, "Push", "All local files are already uploaded." + ) return msg = f"Found {len(unuploaded)} files to upload:\n\n" @@ -1252,7 +1304,9 @@ class Silo_Push: uploaded = 0 for item in unuploaded: - result = _sync.upload_file(item["part_number"], item["path"], "Synced from local") + result = _sync.upload_file( + item["part_number"], item["path"], "Synced from local" + ) if result: uploaded += 1 @@ -1301,9 +1355,7 @@ class Silo_Info: msg = f"

{part_number}

" msg += f"

Type: {item.get('item_type', '-')}

" msg += f"

Description: {item.get('description', '-')}

" - msg += ( - f"

Projects: {', '.join(project_codes) if project_codes else 'None'}

" - ) + msg += f"

Projects: {', '.join(project_codes) if project_codes else 'None'}

" msg += f"

Current Revision: {item.get('current_revision', 1)}

" msg += f"

Local Revision: {getattr(obj, 'SiloRevision', '-')}

" @@ -1369,7 +1421,9 @@ class Silo_TagProjects: try: # Get current projects for item current_projects = _client.get_item_projects(part_number) - current_codes = {p.get("code", "") for p in current_projects if p.get("code")} + current_codes = { + p.get("code", "") for p in current_projects if p.get("code") + } # Get all available projects all_projects = _client.get_projects() @@ -1480,7 +1534,9 @@ class Silo_Rollback: dialog.setMinimumHeight(300) layout = QtGui.QVBoxLayout(dialog) - label = QtGui.QLabel(f"Select a revision to rollback to (current: Rev {current_rev}):") + label = QtGui.QLabel( + f"Select a revision to rollback to (current: Rev {current_rev}):" + ) layout.addWidget(label) # Revision table @@ -1495,8 +1551,12 @@ class Silo_Rollback: for i, rev in enumerate(prev_revisions): table.setItem(i, 0, QtGui.QTableWidgetItem(str(rev["revision_number"]))) table.setItem(i, 1, QtGui.QTableWidgetItem(rev.get("status", "draft"))) - table.setItem(i, 2, QtGui.QTableWidgetItem(rev.get("created_at", "")[:10])) - table.setItem(i, 3, QtGui.QTableWidgetItem(rev.get("comment", "") or "")) + table.setItem( + i, 2, QtGui.QTableWidgetItem(rev.get("created_at", "")[:10]) + ) + table.setItem( + i, 3, QtGui.QTableWidgetItem(rev.get("comment", "") or "") + ) table.resizeColumnsToContents() layout.addWidget(table) @@ -1522,7 +1582,9 @@ class Silo_Rollback: def on_rollback(): selected = table.selectedItems() if not selected: - QtGui.QMessageBox.warning(dialog, "Rollback", "Please select a revision") + QtGui.QMessageBox.warning( + dialog, "Rollback", "Please select a revision" + ) return selected_rev[0] = int(table.item(selected[0].row(), 0).text()) dialog.accept() @@ -1620,7 +1682,9 @@ class Silo_SetStatus: # Update status _client.update_revision(part_number, rev_num, status=status) - FreeCAD.Console.PrintMessage(f"Updated Rev {rev_num} status to '{status}'\n") + FreeCAD.Console.PrintMessage( + f"Updated Rev {rev_num} status to '{status}'\n" + ) QtGui.QMessageBox.information( None, "Status Updated", f"Revision {rev_num} status set to '{status}'" ) @@ -1684,7 +1748,9 @@ class Silo_Settings: ssl_checkbox.setChecked(param.GetBool("SslVerify", True)) layout.addWidget(ssl_checkbox) - ssl_hint = QtGui.QLabel("Disable only for internal servers with self-signed certificates.") + ssl_hint = QtGui.QLabel( + "Disable only for internal servers with self-signed certificates." + ) ssl_hint.setWordWrap(True) ssl_hint.setStyleSheet("color: #888; font-size: 11px;") layout.addWidget(ssl_hint) @@ -1961,7 +2027,9 @@ class Silo_BOM: wu_table = QtGui.QTableWidget() wu_table.setColumnCount(5) - wu_table.setHorizontalHeaderLabels(["Parent Part Number", "Type", "Qty", "Unit", "Ref Des"]) + wu_table.setHorizontalHeaderLabels( + ["Parent Part Number", "Type", "Qty", "Unit", "Ref Des"] + ) wu_table.setSelectionBehavior(QtGui.QAbstractItemView.SelectRows) wu_table.setSelectionMode(QtGui.QAbstractItemView.SingleSelection) wu_table.setEditTriggers(QtGui.QAbstractItemView.NoEditTriggers) @@ -1990,12 +2058,16 @@ class Silo_BOM: bom_table.setItem( row, 1, QtGui.QTableWidgetItem(entry.get("child_description", "")) ) - bom_table.setItem(row, 2, QtGui.QTableWidgetItem(entry.get("rel_type", ""))) + bom_table.setItem( + row, 2, QtGui.QTableWidgetItem(entry.get("rel_type", "")) + ) qty = entry.get("quantity") bom_table.setItem( row, 3, QtGui.QTableWidgetItem(str(qty) if qty is not None else "") ) - bom_table.setItem(row, 4, QtGui.QTableWidgetItem(entry.get("unit") or "")) + bom_table.setItem( + row, 4, QtGui.QTableWidgetItem(entry.get("unit") or "") + ) ref_des = entry.get("reference_designators") or [] bom_table.setItem(row, 5, QtGui.QTableWidgetItem(", ".join(ref_des))) bom_table.setItem( @@ -2017,12 +2089,16 @@ class Silo_BOM: wu_table.setItem( row, 0, QtGui.QTableWidgetItem(entry.get("parent_part_number", "")) ) - wu_table.setItem(row, 1, QtGui.QTableWidgetItem(entry.get("rel_type", ""))) + wu_table.setItem( + row, 1, QtGui.QTableWidgetItem(entry.get("rel_type", "")) + ) qty = entry.get("quantity") wu_table.setItem( row, 2, QtGui.QTableWidgetItem(str(qty) if qty is not None else "") ) - wu_table.setItem(row, 3, QtGui.QTableWidgetItem(entry.get("unit") or "")) + wu_table.setItem( + row, 3, QtGui.QTableWidgetItem(entry.get("unit") or "") + ) ref_des = entry.get("reference_designators") or [] wu_table.setItem(row, 4, QtGui.QTableWidgetItem(", ".join(ref_des))) wu_table.resizeColumnsToContents() @@ -2075,7 +2151,9 @@ class Silo_BOM: try: qty = float(qty_text) except ValueError: - QtGui.QMessageBox.warning(dialog, "BOM", "Quantity must be a number.") + QtGui.QMessageBox.warning( + dialog, "BOM", "Quantity must be a number." + ) return unit = unit_input.text().strip() or None @@ -2154,7 +2232,9 @@ class Silo_BOM: try: new_qty = float(qty_text) except ValueError: - QtGui.QMessageBox.warning(dialog, "BOM", "Quantity must be a number.") + QtGui.QMessageBox.warning( + dialog, "BOM", "Quantity must be a number." + ) return new_unit = unit_input.text().strip() or None @@ -2178,7 +2258,9 @@ class Silo_BOM: ) load_bom() except Exception as exc: - QtGui.QMessageBox.warning(dialog, "BOM", f"Failed to update entry:\n{exc}") + QtGui.QMessageBox.warning( + dialog, "BOM", f"Failed to update entry:\n{exc}" + ) def on_remove(): selected = bom_table.selectedItems() @@ -2204,7 +2286,9 @@ class Silo_BOM: _client.delete_bom_entry(part_number, child_pn) load_bom() except Exception as exc: - QtGui.QMessageBox.warning(dialog, "BOM", f"Failed to remove entry:\n{exc}") + QtGui.QMessageBox.warning( + dialog, "BOM", f"Failed to remove entry:\n{exc}" + ) add_btn.clicked.connect(on_add) edit_btn.clicked.connect(on_edit) @@ -2243,7 +2327,9 @@ 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, int, str) # (status, retry_count, error_message) + connection_status = QtCore.Signal( + str, int, str + ) # (status, retry_count, error_message) server_mode_changed = QtCore.Signal(str) # "normal" / "read-only" / "degraded" _MAX_RETRIES = 10 @@ -2317,7 +2403,9 @@ class SiloEventListener(QtCore.QThread): req = urllib.request.Request(url, headers=headers, method="GET") try: - self._response = urllib.request.urlopen(req, context=_get_ssl_context(), timeout=90) + self._response = urllib.request.urlopen( + req, context=_get_ssl_context(), timeout=90 + ) except urllib.error.HTTPError as e: if e.code in (404, 501): raise _SSEUnsupported() @@ -2388,6 +2476,8 @@ class SiloAuthDockWidget: self.widget = QtGui.QWidget() self._event_listener = None + self._activity_events = [] # list of (datetime, text, part_number) + self._activity_seeded = False self._build_ui() self._refresh_status() @@ -2395,6 +2485,11 @@ class SiloAuthDockWidget: self._timer.timeout.connect(self._refresh_status) self._timer.start(30000) + # Refresh relative timestamps every 60s + self._ts_timer = QtCore.QTimer(self.widget) + self._ts_timer.timeout.connect(self._rebuild_activity_feed) + self._ts_timer.start(60000) + # -- UI construction ---------------------------------------------------- def _build_ui(self): @@ -2597,8 +2692,11 @@ class SiloAuthDockWidget: self._sse_label.setStyleSheet("font-size: 11px; color: #4CAF50;") self._sse_label.setToolTip("") FreeCAD.Console.PrintMessage("Silo: SSE connected\n") + self._seed_activity_feed() elif status == "disconnected": - self._sse_label.setText(f"Reconnecting ({retry}/{SiloEventListener._MAX_RETRIES})...") + 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( @@ -2608,7 +2706,9 @@ class SiloAuthDockWidget: 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") + 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;") @@ -2617,6 +2717,8 @@ class SiloAuthDockWidget: global _server_mode _server_mode = mode self._update_mode_banner() + if mode != "normal": + self._append_activity_event(f"Server mode: {mode}") def _update_mode_banner(self): _MODE_BANNERS = { @@ -2647,18 +2749,59 @@ class SiloAuthDockWidget: mw = FreeCADGui.getMainWindow() if mw is not None: mw.statusBar().showMessage(f"Silo: {part_number} updated on server", 5000) - self._refresh_activity_panel() + self._append_activity_event(f"{part_number} updated", part_number) def _on_remote_revision(self, part_number, revision): - FreeCAD.Console.PrintMessage(f"Silo: New revision {revision} for {part_number}\n") + FreeCAD.Console.PrintMessage( + f"Silo: New revision {revision} for {part_number}\n" + ) mw = FreeCADGui.getMainWindow() if mw is not None: - mw.statusBar().showMessage(f"Silo: {part_number} rev {revision} available", 5000) - self._refresh_activity_panel() + mw.statusBar().showMessage( + f"Silo: {part_number} rev {revision} available", 5000 + ) + self._append_activity_event( + f"{part_number} Rev {revision} created", part_number + ) - def _refresh_activity_panel(self): - """Refresh the Database Activity panel if it exists.""" - from PySide import QtCore, QtGui, QtWidgets + def _append_activity_event(self, text, pn=""): + """Prepend an event to the activity feed and rebuild the display.""" + self._activity_events.insert(0, (datetime.now(), text, pn)) + self._activity_events = self._activity_events[:50] + self._rebuild_activity_feed() + + def _seed_activity_feed(self): + """One-time: populate the feed with recent items from the database.""" + if self._activity_seeded: + return + self._activity_seeded = True + try: + items = _client.list_items() + if isinstance(items, list): + for item in reversed(items[:10]): + pn = item.get("part_number", "") + desc = item.get("description", "") + if desc and len(desc) > 40: + desc = desc[:37] + "..." + text = f"{pn} \u2013 {desc}" if desc else pn + updated = item.get("updated_at", "") + ts = datetime.now() + if updated: + try: + ts = datetime.fromisoformat( + updated.replace("Z", "+00:00") + ).replace(tzinfo=None) + except (ValueError, AttributeError): + pass + self._activity_events.insert(0, (ts, text, pn)) + self._activity_events = self._activity_events[:50] + except Exception: + pass + self._rebuild_activity_feed() + + def _rebuild_activity_feed(self): + """Render _activity_events into the Database Activity QListWidget.""" + from PySide import QtCore, QtWidgets mw = FreeCADGui.getMainWindow() if mw is None: @@ -2680,64 +2823,18 @@ class SiloAuthDockWidget: ) activity_list._silo_connected = True - # Collect local part numbers for badge - local_pns = set() - try: - for lf in search_local_files(): - local_pns.add(lf.get("part_number", "")) - except Exception: - pass + if not self._activity_events: + item = QtWidgets.QListWidgetItem("(No activity yet)") + item.setFlags(QtCore.Qt.NoItemFlags) + activity_list.addItem(item) + return - try: - items = _client.list_items() - if isinstance(items, list): - for item in items[:20]: - pn = item.get("part_number", "") - desc = item.get("description", "") - updated = item.get("updated_at", "") - if updated: - updated = updated[:10] - - # Fetch latest revision info - rev_num = "" - comment = "" - try: - revs = _client.get_revisions(pn) - if revs: - latest = revs[0] if isinstance(revs, list) else revs - rev_num = str(latest.get("revision_number", "")) - comment = latest.get("comment", "") or "" - except Exception: - pass - - # Truncate long descriptions - desc_display = desc - if len(desc_display) > 40: - desc_display = desc_display[:37] + "..." - - # Build display text - rev_part = f" \u2013 Rev {rev_num}" if rev_num else "" - date_part = f" \u2013 {updated}" if updated else "" - local_badge = " \u25cf local" if pn in local_pns else "" - line1 = f"{pn} \u2013 {desc_display}{rev_part}{date_part}{local_badge}" - - if comment: - line1 += f'\n "{comment}"' - else: - line1 += "\n (no comment)" - - list_item = QtWidgets.QListWidgetItem(line1) - list_item.setData(QtCore.Qt.UserRole, pn) - if desc and len(desc) > 40: - list_item.setToolTip(desc) - if pn in local_pns: - list_item.setForeground(QtGui.QColor("#4CAF50")) - activity_list.addItem(list_item) - - if activity_list.count() == 0: - activity_list.addItem("(No items in database)") - except Exception: - activity_list.addItem("(Unable to refresh activity)") + for ts, text, pn in self._activity_events: + label = f"{text} \u00b7 {_relative_time(ts)}" + list_item = QtWidgets.QListWidgetItem(label) + if pn: + list_item.setData(QtCore.Qt.UserRole, pn) + activity_list.addItem(list_item) def _on_activity_double_click(self, item): """Open/checkout item from activity pane.""" @@ -3175,7 +3272,9 @@ class Silo_StartPanel: dock = QtGui.QDockWidget("Silo", mw) dock.setObjectName("SiloStartPanel") dock.setWidget(content.widget) - dock.setAllowedAreas(QtCore.Qt.LeftDockWidgetArea | QtCore.Qt.RightDockWidgetArea) + dock.setAllowedAreas( + QtCore.Qt.LeftDockWidgetArea | QtCore.Qt.RightDockWidgetArea + ) mw.addDockWidget(QtCore.Qt.LeftDockWidgetArea, dock) def IsActive(self): @@ -3209,9 +3308,9 @@ class _DiagWorker(QtCore.QThread): 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}") + addrs = socket.getaddrinfo( + hostname, None, socket.AF_UNSPEC, socket.SOCK_STREAM + ) except socket.gaierror as e: self.result.emit("DNS", False, f"{hostname}: {e}") except Exception as e: