Compare commits

..

1 Commits

Author SHA1 Message Date
Zoe Forbes
d7c6066030 feat: live activity panel with SSE event feed and relative timestamps
Replace static item list refresh with real-time event feed:
- Add _relative_time() helper for human-friendly timestamps
- Prepend SSE events (item updates, new revisions, mode changes) instantly
- Seed feed with 10 recent items on first SSE connect (no per-item revision calls)
- Refresh relative timestamps every 60 seconds
- Cap activity feed at 50 events
- Remove expensive list_items + get_revisions calls on every SSE event
2026-02-12 17:27:25 -06:00
3 changed files with 101 additions and 553 deletions

View File

@@ -1,463 +0,0 @@
"""DAG extraction engine for FreeCAD documents.
Extracts the feature tree from a FreeCAD document as nodes and edges
for syncing to the Silo server. No GUI dependencies -- usable in both
desktop and headless (``--console``) mode.
Public API
----------
classify_type(type_id) -> Optional[str]
compute_properties_hash(obj) -> str
extract_dag(doc) -> (nodes, edges)
"""
import hashlib
import json
import math
from typing import Any, Dict, List, Optional, Set, Tuple
# ---------------------------------------------------------------------------
# TypeId -> DAG node type mapping
# ---------------------------------------------------------------------------
_TYPE_MAP: Dict[str, str] = {
# Sketch
"Sketcher::SketchObject": "sketch",
# Sketch-based additive
"PartDesign::Pad": "pad",
"PartDesign::Revolution": "pad",
"PartDesign::AdditivePipe": "pad",
"PartDesign::AdditiveLoft": "pad",
"PartDesign::AdditiveHelix": "pad",
# Sketch-based subtractive
"PartDesign::Pocket": "pocket",
"PartDesign::Groove": "pocket",
"PartDesign::Hole": "pocket",
"PartDesign::SubtractivePipe": "pocket",
"PartDesign::SubtractiveLoft": "pocket",
"PartDesign::SubtractiveHelix": "pocket",
# Dress-up
"PartDesign::Fillet": "fillet",
"PartDesign::Chamfer": "chamfer",
"PartDesign::Draft": "chamfer",
"PartDesign::Thickness": "chamfer",
# Transformations
"PartDesign::Mirrored": "pad",
"PartDesign::LinearPattern": "pad",
"PartDesign::PolarPattern": "pad",
"PartDesign::Scaled": "pad",
"PartDesign::MultiTransform": "pad",
# Boolean
"PartDesign::Boolean": "pad",
# Additive primitives
"PartDesign::AdditiveBox": "pad",
"PartDesign::AdditiveCylinder": "pad",
"PartDesign::AdditiveSphere": "pad",
"PartDesign::AdditiveCone": "pad",
"PartDesign::AdditiveEllipsoid": "pad",
"PartDesign::AdditiveTorus": "pad",
"PartDesign::AdditivePrism": "pad",
"PartDesign::AdditiveWedge": "pad",
# Subtractive primitives
"PartDesign::SubtractiveBox": "pocket",
"PartDesign::SubtractiveCylinder": "pocket",
"PartDesign::SubtractiveSphere": "pocket",
"PartDesign::SubtractiveCone": "pocket",
"PartDesign::SubtractiveEllipsoid": "pocket",
"PartDesign::SubtractiveTorus": "pocket",
"PartDesign::SubtractivePrism": "pocket",
"PartDesign::SubtractiveWedge": "pocket",
# Containers
"PartDesign::Body": "body",
"App::Part": "part",
"Part::Feature": "part",
# Datum / reference
"PartDesign::Point": "datum",
"PartDesign::Line": "datum",
"PartDesign::Plane": "datum",
"PartDesign::CoordinateSystem": "datum",
"PartDesign::ShapeBinder": "datum",
"PartDesign::SubShapeBinder": "datum",
}
def classify_type(type_id: str) -> Optional[str]:
"""Map a FreeCAD TypeId string to a DAG node type.
Returns one of ``sketch``, ``pad``, ``pocket``, ``fillet``,
``chamfer``, ``body``, ``part``, ``datum``, or ``None`` if the
TypeId is not a recognized feature.
"""
return _TYPE_MAP.get(type_id)
# ---------------------------------------------------------------------------
# Properties hash
# ---------------------------------------------------------------------------
def _safe_float(val: Any) -> Any:
"""Convert a float to a JSON-safe value, replacing NaN/Infinity with 0."""
if isinstance(val, float) and (math.isnan(val) or math.isinf(val)):
return 0.0
return val
def _prop_value(obj: Any, name: str) -> Any:
"""Safely read ``obj.<name>.Value``, returning *None* on failure."""
try:
return _safe_float(getattr(obj, name).Value)
except Exception:
return None
def _prop_raw(obj: Any, name: str) -> Any:
"""Safely read ``obj.<name>``, returning *None* on failure."""
try:
return getattr(obj, name)
except Exception:
return None
def _link_name(obj: Any, name: str) -> Optional[str]:
"""Return the ``.Name`` of a linked object property, or *None*."""
try:
link = getattr(obj, name)
if isinstance(link, (list, tuple)):
link = link[0]
return link.Name if link is not None else None
except Exception:
return None
def _collect_inputs(obj: Any) -> Dict[str, Any]:
"""Extract the parametric inputs that affect *obj*'s geometry.
The returned dict is JSON-serialized and hashed to produce the
``properties_hash`` for the DAG node.
"""
tid = obj.TypeId
inputs: Dict[str, Any] = {"_type": tid}
# --- Sketch ---
if tid == "Sketcher::SketchObject":
inputs["geometry_count"] = _prop_raw(obj, "GeometryCount")
inputs["constraint_count"] = _prop_raw(obj, "ConstraintCount")
try:
inputs["geometry"] = obj.Shape.exportBrepToString()
except Exception:
pass
return inputs
# --- Extrude (Pad / Pocket) ---
if tid in ("PartDesign::Pad", "PartDesign::Pocket"):
inputs["length"] = _prop_value(obj, "Length")
inputs["type"] = str(_prop_raw(obj, "Type") or "")
inputs["reversed"] = _prop_raw(obj, "Reversed")
inputs["sketch"] = _link_name(obj, "Profile")
return inputs
# --- Revolution / Groove ---
if tid in ("PartDesign::Revolution", "PartDesign::Groove"):
inputs["angle"] = _prop_value(obj, "Angle")
inputs["type"] = str(_prop_raw(obj, "Type") or "")
inputs["reversed"] = _prop_raw(obj, "Reversed")
inputs["sketch"] = _link_name(obj, "Profile")
return inputs
# --- Hole ---
if tid == "PartDesign::Hole":
inputs["diameter"] = _prop_value(obj, "Diameter")
inputs["depth"] = _prop_value(obj, "Depth")
inputs["threaded"] = _prop_raw(obj, "Threaded")
inputs["thread_type"] = str(_prop_raw(obj, "ThreadType") or "")
inputs["depth_type"] = str(_prop_raw(obj, "DepthType") or "")
inputs["sketch"] = _link_name(obj, "Profile")
return inputs
# --- Pipe / Loft / Helix (additive + subtractive) ---
if tid in (
"PartDesign::AdditivePipe",
"PartDesign::SubtractivePipe",
"PartDesign::AdditiveLoft",
"PartDesign::SubtractiveLoft",
"PartDesign::AdditiveHelix",
"PartDesign::SubtractiveHelix",
):
inputs["sketch"] = _link_name(obj, "Profile")
inputs["spine"] = _link_name(obj, "Spine")
return inputs
# --- Fillet ---
if tid == "PartDesign::Fillet":
inputs["radius"] = _prop_value(obj, "Radius")
return inputs
# --- Chamfer ---
if tid == "PartDesign::Chamfer":
inputs["chamfer_type"] = str(_prop_raw(obj, "ChamferType") or "")
inputs["size"] = _prop_value(obj, "Size")
inputs["size2"] = _prop_value(obj, "Size2")
inputs["angle"] = _prop_value(obj, "Angle")
return inputs
# --- Draft ---
if tid == "PartDesign::Draft":
inputs["angle"] = _prop_value(obj, "Angle")
inputs["reversed"] = _prop_raw(obj, "Reversed")
return inputs
# --- Thickness ---
if tid == "PartDesign::Thickness":
inputs["value"] = _prop_value(obj, "Value")
inputs["reversed"] = _prop_raw(obj, "Reversed")
inputs["mode"] = str(_prop_raw(obj, "Mode") or "")
inputs["join"] = str(_prop_raw(obj, "Join") or "")
return inputs
# --- Mirrored ---
if tid == "PartDesign::Mirrored":
inputs["mirror_plane"] = _link_name(obj, "MirrorPlane")
return inputs
# --- LinearPattern ---
if tid == "PartDesign::LinearPattern":
inputs["direction"] = _link_name(obj, "Direction")
inputs["reversed"] = _prop_raw(obj, "Reversed")
inputs["length"] = _prop_value(obj, "Length")
inputs["occurrences"] = _prop_value(obj, "Occurrences")
return inputs
# --- PolarPattern ---
if tid == "PartDesign::PolarPattern":
inputs["axis"] = _link_name(obj, "Axis")
inputs["reversed"] = _prop_raw(obj, "Reversed")
inputs["angle"] = _prop_value(obj, "Angle")
inputs["occurrences"] = _prop_value(obj, "Occurrences")
return inputs
# --- Scaled ---
if tid == "PartDesign::Scaled":
inputs["factor"] = _prop_value(obj, "Factor")
inputs["occurrences"] = _prop_value(obj, "Occurrences")
return inputs
# --- MultiTransform ---
if tid == "PartDesign::MultiTransform":
try:
inputs["transform_count"] = len(obj.Transformations)
except Exception:
pass
return inputs
# --- Boolean ---
if tid == "PartDesign::Boolean":
inputs["type"] = str(_prop_raw(obj, "Type") or "")
return inputs
# --- Primitives (additive) ---
if tid in (
"PartDesign::AdditiveBox",
"PartDesign::SubtractiveBox",
):
inputs["length"] = _prop_value(obj, "Length")
inputs["width"] = _prop_value(obj, "Width")
inputs["height"] = _prop_value(obj, "Height")
return inputs
if tid in (
"PartDesign::AdditiveCylinder",
"PartDesign::SubtractiveCylinder",
):
inputs["radius"] = _prop_value(obj, "Radius")
inputs["height"] = _prop_value(obj, "Height")
inputs["angle"] = _prop_value(obj, "Angle")
return inputs
if tid in (
"PartDesign::AdditiveSphere",
"PartDesign::SubtractiveSphere",
):
inputs["radius"] = _prop_value(obj, "Radius")
return inputs
if tid in (
"PartDesign::AdditiveCone",
"PartDesign::SubtractiveCone",
):
inputs["radius1"] = _prop_value(obj, "Radius1")
inputs["radius2"] = _prop_value(obj, "Radius2")
inputs["height"] = _prop_value(obj, "Height")
return inputs
if tid in (
"PartDesign::AdditiveEllipsoid",
"PartDesign::SubtractiveEllipsoid",
):
inputs["radius1"] = _prop_value(obj, "Radius1")
inputs["radius2"] = _prop_value(obj, "Radius2")
inputs["radius3"] = _prop_value(obj, "Radius3")
return inputs
if tid in (
"PartDesign::AdditiveTorus",
"PartDesign::SubtractiveTorus",
):
inputs["radius1"] = _prop_value(obj, "Radius1")
inputs["radius2"] = _prop_value(obj, "Radius2")
return inputs
if tid in (
"PartDesign::AdditivePrism",
"PartDesign::SubtractivePrism",
):
inputs["polygon"] = _prop_raw(obj, "Polygon")
inputs["circumradius"] = _prop_value(obj, "Circumradius")
inputs["height"] = _prop_value(obj, "Height")
return inputs
if tid in (
"PartDesign::AdditiveWedge",
"PartDesign::SubtractiveWedge",
):
for dim in (
"Xmin",
"Ymin",
"Zmin",
"X2min",
"Z2min",
"Xmax",
"Ymax",
"Zmax",
"X2max",
"Z2max",
):
inputs[dim.lower()] = _prop_value(obj, dim)
return inputs
# --- Datum / ShapeBinder ---
if tid in (
"PartDesign::Point",
"PartDesign::Line",
"PartDesign::Plane",
"PartDesign::CoordinateSystem",
"PartDesign::ShapeBinder",
"PartDesign::SubShapeBinder",
):
try:
p = obj.Placement
inputs["position"] = {
"x": _safe_float(p.Base.x),
"y": _safe_float(p.Base.y),
"z": _safe_float(p.Base.z),
}
inputs["rotation"] = {
"axis_x": _safe_float(p.Rotation.Axis.x),
"axis_y": _safe_float(p.Rotation.Axis.y),
"axis_z": _safe_float(p.Rotation.Axis.z),
"angle": _safe_float(p.Rotation.Angle),
}
except Exception:
pass
return inputs
# --- Body / Part (containers) ---
if tid in ("PartDesign::Body", "App::Part", "Part::Feature"):
try:
inputs["child_count"] = len(obj.Group)
except Exception:
inputs["child_count"] = 0
return inputs
# --- Fallback ---
inputs["label"] = obj.Label
return inputs
def compute_properties_hash(obj: Any) -> str:
"""Return a SHA-256 hex digest of *obj*'s parametric inputs.
The hash is used for memoization -- if a node's inputs haven't
changed since the last validation run, re-validation can be skipped.
"""
inputs = _collect_inputs(obj)
canonical = json.dumps(inputs, sort_keys=True, default=str)
return hashlib.sha256(canonical.encode()).hexdigest()
# ---------------------------------------------------------------------------
# DAG extraction
# ---------------------------------------------------------------------------
def extract_dag(
doc: Any,
) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
"""Walk a FreeCAD document and return ``(nodes, edges)``.
*nodes* is a list of dicts matching the Silo ``PUT /dag`` payload
schema. *edges* connects dependencies (source) to dependents
(target).
Only objects whose TypeId is recognized by :func:`classify_type`
are included. Edges are limited to pairs where **both** endpoints
are included, preventing dangling references to internal objects
such as ``App::Origin``.
"""
# Pass 1 -- identify included objects
included: Set[str] = set()
classified: Dict[str, str] = {} # obj.Name -> node_type
for obj in doc.Objects:
if not hasattr(obj, "TypeId"):
continue
node_type = classify_type(obj.TypeId)
if node_type is not None:
included.add(obj.Name)
classified[obj.Name] = node_type
# Pass 2 -- build nodes and edges
nodes: List[Dict[str, Any]] = []
edges: List[Dict[str, Any]] = []
seen_edges: Set[Tuple[str, str]] = set()
for obj in doc.Objects:
if obj.Name not in included:
continue
nodes.append(
{
"node_key": obj.Name,
"node_type": classified[obj.Name],
"properties_hash": compute_properties_hash(obj),
"metadata": {
"label": obj.Label,
"type_id": obj.TypeId,
},
}
)
# Walk dependencies: OutList contains objects this one depends on
try:
out_list = obj.OutList
except Exception:
continue
for dep in out_list:
if not hasattr(dep, "Name"):
continue
if dep.Name not in included:
continue
edge_key = (dep.Name, obj.Name)
if edge_key in seen_edges:
continue
seen_edges.add(edge_key)
edges.append(
{
"source_key": dep.Name,
"target_key": obj.Name,
"edge_type": "depends_on",
}
)
return nodes, edges

View File

@@ -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
@@ -31,6 +32,25 @@ SILO_PROJECTS_DIR = os.environ.get(
)
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")
# ---------------------------------------------------------------------------
# FreeCAD settings adapter
# ---------------------------------------------------------------------------
@@ -214,43 +234,39 @@ def get_projects_dir() -> Path:
def get_cad_file_path(part_number: str, description: str = "") -> Path:
"""Generate canonical file path for a CAD file.
Path format: ~/projects/cad/{category_code}_{category_name}/{part_number}_{description}.kc
Path format: ~/projects/cad/{category_code}_{category_name}/{part_number}_{description}.FCStd
"""
category, _ = parse_part_number(part_number)
folder_name = get_category_folder_name(category)
if description:
filename = f"{part_number}_{sanitize_filename(description)}.kc"
filename = f"{part_number}_{sanitize_filename(description)}.FCStd"
else:
filename = f"{part_number}.kc"
filename = f"{part_number}.FCStd"
return get_projects_dir() / "cad" / folder_name / filename
def find_file_by_part_number(part_number: str) -> Optional[Path]:
"""Find existing CAD file for a part number. Prefers .kc over .FCStd."""
"""Find existing CAD file for a part number."""
category, _ = parse_part_number(part_number)
folder_name = get_category_folder_name(category)
cad_dir = get_projects_dir() / "cad" / folder_name
for search_dir in _search_dirs(cad_dir):
for ext in ("*.kc", "*.FCStd"):
matches = list(search_dir.glob(f"{part_number}{ext[1:]}"))
if matches:
return matches[0]
if cad_dir.exists():
matches = list(cad_dir.glob(f"{part_number}*.FCStd"))
if matches:
return matches[0]
return None
def _search_dirs(category_dir: Path):
"""Yield the category dir, then all sibling dirs under cad/."""
if category_dir.exists():
yield category_dir
base_cad_dir = category_dir.parent
base_cad_dir = get_projects_dir() / "cad"
if base_cad_dir.exists():
for subdir in base_cad_dir.iterdir():
if subdir.is_dir() and subdir != category_dir:
yield subdir
if subdir.is_dir():
matches = list(subdir.glob(f"{part_number}*.FCStd"))
if matches:
return matches[0]
return None
def search_local_files(search_term: str = "", category_filter: str = "") -> list:
@@ -272,9 +288,7 @@ def search_local_files(search_term: str = "", category_filter: str = "") -> list
if category_filter and category_code.upper() != category_filter.upper():
continue
for fcstd_file in sorted(
list(category_dir.glob("*.kc")) + list(category_dir.glob("*.FCStd"))
):
for fcstd_file in category_dir.glob("*.FCStd"):
filename = fcstd_file.stem
parts = filename.split("_", 1)
part_number = parts[0]
@@ -1763,7 +1777,7 @@ class Silo_Settings:
path, _ = QtGui.QFileDialog.getOpenFileName(
dialog,
"Select CA Certificate",
os.path.dirname(cert_input.text()) or os.path.expanduser("~"),
os.path.dirname(cert_input.text()) or "/etc/ssl/certs",
"Certificates (*.pem *.crt *.cer);;All Files (*)",
)
if path:
@@ -2456,6 +2470,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()
@@ -2463,6 +2479,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):
@@ -2665,6 +2686,7 @@ 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})..."
@@ -2689,6 +2711,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 = {
@@ -2719,7 +2743,7 @@ 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(
@@ -2730,11 +2754,48 @@ class SiloAuthDockWidget:
mw.statusBar().showMessage(
f"Silo: {part_number} rev {revision} available", 5000
)
self._refresh_activity_panel()
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:
@@ -2756,66 +2817,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."""
@@ -3292,8 +3305,6 @@ class _DiagWorker(QtCore.QThread):
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: