Compare commits
29 Commits
feat/live-
...
auto/updat
| Author | SHA1 | Date | |
|---|---|---|---|
| af98994a53 | |||
|
|
d266bfb653 | ||
| a92174e0b9 | |||
|
|
edbaf65923 | ||
| 80f8ec27a0 | |||
|
|
6b3e8b7518 | ||
| b3fe98c696 | |||
|
|
c537e2f08f | ||
| 29b1f32fd9 | |||
| dca6380199 | |||
| 27f0cc0f34 | |||
| a5eff534b5 | |||
|
|
1001424b16 | ||
| 7e3127498a | |||
| 82d8741059 | |||
|
|
3fe43710fa | ||
|
|
8cbd872e5c | ||
|
|
e31321ac95 | ||
|
|
dc64a66f0f | ||
|
|
3d38e4b4c3 | ||
|
|
da2a360c56 | ||
|
|
3dd0da3964 | ||
|
|
4921095296 | ||
|
|
3a9fe6aed8 | ||
|
|
9e99b83091 | ||
| 0f407360ed | |||
| fa4f3145c6 | |||
|
|
fed72676bc | ||
| d3e27010d8 |
@@ -45,6 +45,7 @@ class SiloWorkbench(FreeCADGui.Workbench):
|
||||
"Separator",
|
||||
"Silo_Info",
|
||||
"Silo_BOM",
|
||||
"Silo_Jobs",
|
||||
]
|
||||
self.appendToolbar("Silo Origin", self.silo_toolbar_commands, "Unavailable")
|
||||
|
||||
@@ -52,12 +53,14 @@ class SiloWorkbench(FreeCADGui.Workbench):
|
||||
self.menu_commands = [
|
||||
"Silo_Info",
|
||||
"Silo_BOM",
|
||||
"Silo_Jobs",
|
||||
"Silo_TagProjects",
|
||||
"Silo_SetStatus",
|
||||
"Silo_Rollback",
|
||||
"Separator",
|
||||
"Silo_Settings",
|
||||
"Silo_Auth",
|
||||
"Silo_Runners",
|
||||
"Silo_StartPanel",
|
||||
"Silo_Diag",
|
||||
]
|
||||
@@ -103,7 +106,9 @@ def _register_silo_overlay():
|
||||
return False
|
||||
|
||||
try:
|
||||
FreeCADGui.registerEditingOverlay(
|
||||
from kindred_sdk import register_overlay
|
||||
|
||||
register_overlay(
|
||||
"silo", # overlay id
|
||||
["Silo Origin"], # toolbar names to append
|
||||
_silo_overlay_match, # match function
|
||||
|
||||
BIN
freecad/__pycache__/silo_commands.cpython-313.pyc
Normal file
BIN
freecad/__pycache__/silo_commands.cpython-313.pyc
Normal file
Binary file not shown.
BIN
freecad/__pycache__/silo_origin.cpython-313.pyc
Normal file
BIN
freecad/__pycache__/silo_origin.cpython-313.pyc
Normal file
Binary file not shown.
BIN
freecad/__pycache__/silo_start.cpython-313.pyc
Normal file
BIN
freecad/__pycache__/silo_start.cpython-313.pyc
Normal file
Binary file not shown.
463
freecad/dag.py
Normal file
463
freecad/dag.py
Normal file
@@ -0,0 +1,463 @@
|
||||
"""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
|
||||
@@ -12,4 +12,17 @@
|
||||
<subdirectory>./</subdirectory>
|
||||
</workbench>
|
||||
</content>
|
||||
|
||||
<!-- Kindred Create extensions -->
|
||||
<kindred>
|
||||
<min_create_version>0.1.0</min_create_version>
|
||||
<load_priority>60</load_priority>
|
||||
<pure_python>true</pure_python>
|
||||
<dependencies>
|
||||
<dependency>sdk</dependency>
|
||||
</dependencies>
|
||||
<contexts>
|
||||
<context id="*" action="overlay"/>
|
||||
</contexts>
|
||||
</kindred>
|
||||
</package>
|
||||
|
||||
156
freecad/runner.py
Normal file
156
freecad/runner.py
Normal file
@@ -0,0 +1,156 @@
|
||||
"""Headless runner entry points for silorunner compute jobs.
|
||||
|
||||
These functions are invoked via ``create --console -e`` by the
|
||||
silorunner binary. They must work without a display server.
|
||||
|
||||
Entry Points
|
||||
------------
|
||||
dag_extract(input_path, output_path)
|
||||
Extract feature DAG and write JSON.
|
||||
validate(input_path, output_path)
|
||||
Rebuild all features and report pass/fail per node.
|
||||
export(input_path, output_path, format='step')
|
||||
Export geometry to STEP, IGES, STL, or OBJ.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import FreeCAD
|
||||
|
||||
|
||||
def dag_extract(input_path, output_path):
|
||||
"""Extract the feature DAG from a Create file.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
input_path : str
|
||||
Path to the ``.kc`` or ``.FCStd`` file.
|
||||
output_path : str
|
||||
Path to write the JSON output.
|
||||
|
||||
Output JSON::
|
||||
|
||||
{"nodes": [...], "edges": [...]}
|
||||
"""
|
||||
from dag import extract_dag
|
||||
|
||||
doc = FreeCAD.openDocument(input_path)
|
||||
try:
|
||||
nodes, edges = extract_dag(doc)
|
||||
with open(output_path, "w") as f:
|
||||
json.dump({"nodes": nodes, "edges": edges}, f)
|
||||
FreeCAD.Console.PrintMessage(
|
||||
f"DAG extracted: {len(nodes)} nodes, {len(edges)} edges -> {output_path}\n"
|
||||
)
|
||||
finally:
|
||||
FreeCAD.closeDocument(doc.Name)
|
||||
|
||||
|
||||
def validate(input_path, output_path):
|
||||
"""Validate a Create file by rebuilding all features.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
input_path : str
|
||||
Path to the ``.kc`` or ``.FCStd`` file.
|
||||
output_path : str
|
||||
Path to write the JSON output.
|
||||
|
||||
Output JSON::
|
||||
|
||||
{
|
||||
"valid": true/false,
|
||||
"nodes": [
|
||||
{"node_key": "Pad001", "state": "clean", "message": null, "properties_hash": "..."},
|
||||
...
|
||||
]
|
||||
}
|
||||
"""
|
||||
from dag import classify_type, compute_properties_hash
|
||||
|
||||
doc = FreeCAD.openDocument(input_path)
|
||||
try:
|
||||
doc.recompute()
|
||||
|
||||
results = []
|
||||
all_valid = True
|
||||
|
||||
for obj in doc.Objects:
|
||||
if not hasattr(obj, "TypeId"):
|
||||
continue
|
||||
node_type = classify_type(obj.TypeId)
|
||||
if node_type is None:
|
||||
continue
|
||||
|
||||
state = "clean"
|
||||
message = None
|
||||
if hasattr(obj, "isValid") and not obj.isValid():
|
||||
state = "failed"
|
||||
message = f"Feature {obj.Label} failed to recompute"
|
||||
all_valid = False
|
||||
|
||||
results.append(
|
||||
{
|
||||
"node_key": obj.Name,
|
||||
"state": state,
|
||||
"message": message,
|
||||
"properties_hash": compute_properties_hash(obj),
|
||||
}
|
||||
)
|
||||
|
||||
with open(output_path, "w") as f:
|
||||
json.dump({"valid": all_valid, "nodes": results}, f)
|
||||
|
||||
status = "PASS" if all_valid else "FAIL"
|
||||
FreeCAD.Console.PrintMessage(
|
||||
f"Validation {status}: {len(results)} nodes -> {output_path}\n"
|
||||
)
|
||||
finally:
|
||||
FreeCAD.closeDocument(doc.Name)
|
||||
|
||||
|
||||
def export(input_path, output_path, format="step"):
|
||||
"""Export a Create file to an external geometry format.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
input_path : str
|
||||
Path to the ``.kc`` or ``.FCStd`` file.
|
||||
output_path : str
|
||||
Path to write the exported file.
|
||||
format : str
|
||||
One of ``step``, ``iges``, ``stl``, ``obj``.
|
||||
"""
|
||||
import Part
|
||||
|
||||
doc = FreeCAD.openDocument(input_path)
|
||||
try:
|
||||
shapes = [
|
||||
obj.Shape for obj in doc.Objects if hasattr(obj, "Shape") and obj.Shape
|
||||
]
|
||||
if not shapes:
|
||||
raise ValueError("No geometry found in document")
|
||||
|
||||
compound = Part.makeCompound(shapes)
|
||||
|
||||
format_lower = format.lower()
|
||||
if format_lower == "step":
|
||||
compound.exportStep(output_path)
|
||||
elif format_lower == "iges":
|
||||
compound.exportIges(output_path)
|
||||
elif format_lower == "stl":
|
||||
import Mesh
|
||||
|
||||
Mesh.export([compound], output_path)
|
||||
elif format_lower == "obj":
|
||||
import Mesh
|
||||
|
||||
Mesh.export([compound], output_path)
|
||||
else:
|
||||
raise ValueError(f"Unsupported format: {format}")
|
||||
|
||||
FreeCAD.Console.PrintMessage(
|
||||
f"Exported {format_lower.upper()} -> {output_path}\n"
|
||||
)
|
||||
finally:
|
||||
FreeCAD.closeDocument(doc.Name)
|
||||
@@ -10,9 +10,6 @@ backward-compatible :class:`SchemaFormDialog` modal.
|
||||
"""
|
||||
|
||||
import json
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
import FreeCAD
|
||||
from PySide import QtCore, QtGui, QtWidgets
|
||||
@@ -267,17 +264,8 @@ class SchemaFormWidget(QtWidgets.QWidget):
|
||||
|
||||
def _fetch_properties(self, category: str) -> dict:
|
||||
"""Fetch merged property definitions for a category."""
|
||||
from silo_commands import _get_api_url, _get_auth_headers, _get_ssl_context
|
||||
|
||||
api_url = _get_api_url().rstrip("/")
|
||||
url = f"{api_url}/schemas/kindred-rd/properties?category={urllib.parse.quote(category)}"
|
||||
req = urllib.request.Request(url, method="GET")
|
||||
req.add_header("Accept", "application/json")
|
||||
for k, v in _get_auth_headers().items():
|
||||
req.add_header(k, v)
|
||||
try:
|
||||
resp = urllib.request.urlopen(req, context=_get_ssl_context(), timeout=5)
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
data = self._client.get_property_schema(category=category)
|
||||
return data.get("properties", data)
|
||||
except Exception as e:
|
||||
FreeCAD.Console.PrintWarning(
|
||||
@@ -287,19 +275,10 @@ class SchemaFormWidget(QtWidgets.QWidget):
|
||||
|
||||
def _generate_pn_preview(self, category: str) -> str:
|
||||
"""Call the server to preview the next part number."""
|
||||
from silo_commands import _get_api_url, _get_auth_headers, _get_ssl_context
|
||||
from silo_commands import _get_schema_name
|
||||
|
||||
api_url = _get_api_url().rstrip("/")
|
||||
url = f"{api_url}/generate-part-number"
|
||||
payload = json.dumps({"schema": "kindred-rd", "category": category}).encode()
|
||||
req = urllib.request.Request(url, data=payload, method="POST")
|
||||
req.add_header("Content-Type", "application/json")
|
||||
req.add_header("Accept", "application/json")
|
||||
for k, v in _get_auth_headers().items():
|
||||
req.add_header(k, v)
|
||||
try:
|
||||
resp = urllib.request.urlopen(req, context=_get_ssl_context(), timeout=5)
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
data = self._client.generate_part_number(_get_schema_name(), category)
|
||||
return data.get("part_number", "")
|
||||
except Exception:
|
||||
return ""
|
||||
@@ -574,8 +553,10 @@ class SchemaFormWidget(QtWidgets.QWidget):
|
||||
return
|
||||
|
||||
try:
|
||||
from silo_commands import _get_schema_name
|
||||
|
||||
result = self._client.create_item(
|
||||
"kindred-rd",
|
||||
_get_schema_name(),
|
||||
data["category"],
|
||||
data["description"],
|
||||
projects=data["projects"],
|
||||
|
||||
@@ -14,14 +14,7 @@ from typing import Any, Dict, List, Optional, Tuple
|
||||
import FreeCAD
|
||||
import FreeCADGui
|
||||
from PySide import QtCore
|
||||
from silo_client import (
|
||||
CATEGORY_NAMES,
|
||||
SiloClient,
|
||||
SiloSettings,
|
||||
get_category_folder_name,
|
||||
parse_part_number,
|
||||
sanitize_filename,
|
||||
)
|
||||
from silo_client import SiloClient, SiloSettings
|
||||
|
||||
# Preference group for Kindred Silo settings
|
||||
_PREF_GROUP = "User parameter:BaseApp/Preferences/Mod/KindredSilo"
|
||||
@@ -32,6 +25,27 @@ SILO_PROJECTS_DIR = os.environ.get(
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Local utility helpers (previously in silo_client, now server-driven)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _parse_part_number(part_number: str) -> Tuple[str, str]:
|
||||
"""Parse part number into ``(category, sequence)``. E.g. ``"F01-0001"`` -> ``("F01", "0001")``."""
|
||||
parts = part_number.split("-")
|
||||
if len(parts) >= 2:
|
||||
return parts[0], parts[1]
|
||||
return part_number, ""
|
||||
|
||||
|
||||
def _sanitize_filename(name: str) -> str:
|
||||
"""Sanitize a string for use in filenames."""
|
||||
sanitized = re.sub(r'[<>:"/\\|?*]', "_", name)
|
||||
sanitized = re.sub(r"[\s_]+", "_", sanitized)
|
||||
sanitized = sanitized.strip("_ ")
|
||||
return sanitized[:50]
|
||||
|
||||
|
||||
def _relative_time(dt):
|
||||
"""Format a datetime as a human-friendly relative string."""
|
||||
now = datetime.now()
|
||||
@@ -96,6 +110,13 @@ class FreeCADSiloSettings(SiloSettings):
|
||||
if token:
|
||||
param.SetString("ApiToken", token)
|
||||
|
||||
def get_schema_name(self) -> str:
|
||||
param = FreeCAD.ParamGet(_PREF_GROUP)
|
||||
name = param.GetString("SchemaName", "")
|
||||
if not name:
|
||||
name = os.environ.get("SILO_SCHEMA", "kindred-rd")
|
||||
return name
|
||||
|
||||
def clear_auth(self):
|
||||
param = FreeCAD.ParamGet(_PREF_GROUP)
|
||||
param.SetString("ApiToken", "")
|
||||
@@ -139,6 +160,10 @@ def _get_api_url() -> str:
|
||||
return _fc_settings.get_api_url()
|
||||
|
||||
|
||||
def _get_schema_name() -> str:
|
||||
return _fc_settings.get_schema_name()
|
||||
|
||||
|
||||
def _get_ssl_verify() -> bool:
|
||||
return _fc_settings.get_ssl_verify()
|
||||
|
||||
@@ -172,13 +197,13 @@ def _clear_auth():
|
||||
# Server mode tracking
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_server_mode = "offline" # "normal" | "read-only" | "degraded" | "offline"
|
||||
_server_mode = "offline" # "normal" | "read-only" | "offline"
|
||||
|
||||
|
||||
def _fetch_server_mode() -> str:
|
||||
"""Fetch server mode from the /ready endpoint.
|
||||
|
||||
Returns one of: "normal", "read-only", "degraded", "offline".
|
||||
Returns one of: "normal", "read-only", "offline".
|
||||
"""
|
||||
api_url = _get_api_url().rstrip("/")
|
||||
base_url = api_url[:-4] if api_url.endswith("/api") else api_url
|
||||
@@ -193,8 +218,6 @@ def _fetch_server_mode() -> str:
|
||||
return "normal"
|
||||
if status in ("read-only", "read_only", "readonly"):
|
||||
return "read-only"
|
||||
if status in ("degraded",):
|
||||
return "degraded"
|
||||
# Unknown status but server responded — treat as normal
|
||||
return "normal"
|
||||
except Exception:
|
||||
@@ -234,41 +257,43 @@ 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}.FCStd
|
||||
Path format: ~/projects/cad/{category_code}/{part_number}_{description}.kc
|
||||
"""
|
||||
category, _ = parse_part_number(part_number)
|
||||
folder_name = get_category_folder_name(category)
|
||||
category, _ = _parse_part_number(part_number)
|
||||
|
||||
if description:
|
||||
filename = f"{part_number}_{sanitize_filename(description)}.FCStd"
|
||||
filename = f"{part_number}_{_sanitize_filename(description)}.kc"
|
||||
else:
|
||||
filename = f"{part_number}.FCStd"
|
||||
filename = f"{part_number}.kc"
|
||||
|
||||
return get_projects_dir() / "cad" / folder_name / filename
|
||||
return get_projects_dir() / "cad" / category / filename
|
||||
|
||||
|
||||
def find_file_by_part_number(part_number: str) -> Optional[Path]:
|
||||
"""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
|
||||
"""Find existing CAD file for a part number. Prefers .kc over .FCStd."""
|
||||
category, _ = _parse_part_number(part_number)
|
||||
cad_dir = get_projects_dir() / "cad" / category
|
||||
|
||||
if cad_dir.exists():
|
||||
matches = list(cad_dir.glob(f"{part_number}*.FCStd"))
|
||||
if matches:
|
||||
return matches[0]
|
||||
|
||||
base_cad_dir = get_projects_dir() / "cad"
|
||||
if base_cad_dir.exists():
|
||||
for subdir in base_cad_dir.iterdir():
|
||||
if subdir.is_dir():
|
||||
matches = list(subdir.glob(f"{part_number}*.FCStd"))
|
||||
if matches:
|
||||
return matches[0]
|
||||
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]
|
||||
|
||||
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
|
||||
if base_cad_dir.exists():
|
||||
for subdir in base_cad_dir.iterdir():
|
||||
if subdir.is_dir() and subdir != category_dir:
|
||||
yield subdir
|
||||
|
||||
|
||||
def search_local_files(search_term: str = "", category_filter: str = "") -> list:
|
||||
"""Search for CAD files in local cad directory."""
|
||||
results = []
|
||||
@@ -288,7 +313,9 @@ 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 category_dir.glob("*.FCStd"):
|
||||
for fcstd_file in sorted(
|
||||
list(category_dir.glob("*.kc")) + list(category_dir.glob("*.FCStd"))
|
||||
):
|
||||
filename = fcstd_file.stem
|
||||
parts = filename.split("_", 1)
|
||||
part_number = parts[0]
|
||||
@@ -510,7 +537,7 @@ class SiloSync:
|
||||
)
|
||||
|
||||
# Add a Body for parts (not assemblies)
|
||||
body_label = sanitize_filename(description) if description else "Body"
|
||||
body_label = _sanitize_filename(description) if description else "Body"
|
||||
body = doc.addObject("PartDesign::Body", "_" + body_label)
|
||||
body.Label = body_label
|
||||
part_obj.addObject(body)
|
||||
@@ -541,7 +568,7 @@ class SiloSync:
|
||||
def upload_file(
|
||||
self, part_number: str, file_path: str, comment: str = "Auto-save"
|
||||
) -> Optional[Dict]:
|
||||
"""Upload file to MinIO."""
|
||||
"""Upload file to the server."""
|
||||
try:
|
||||
doc = FreeCAD.openDocument(file_path)
|
||||
if not doc:
|
||||
@@ -555,7 +582,7 @@ class SiloSync:
|
||||
return None
|
||||
|
||||
def download_file(self, part_number: str) -> Optional[Path]:
|
||||
"""Download latest file from MinIO."""
|
||||
"""Download the latest revision file from the server."""
|
||||
try:
|
||||
item = self.client.get_item(part_number)
|
||||
file_path = get_cad_file_path(part_number, item.get("description", ""))
|
||||
@@ -731,13 +758,35 @@ class Silo_New:
|
||||
return _server_mode == "normal"
|
||||
|
||||
|
||||
def _push_dag_after_upload(doc, part_number, revision_number):
|
||||
"""Extract and push the feature DAG after a successful upload.
|
||||
|
||||
Failures are logged as warnings -- DAG sync must never block save.
|
||||
"""
|
||||
try:
|
||||
from dag import extract_dag
|
||||
|
||||
nodes, edges = extract_dag(doc)
|
||||
if not nodes:
|
||||
return
|
||||
|
||||
result = _client.push_dag(part_number, revision_number, nodes, edges)
|
||||
node_count = result.get("node_count", len(nodes))
|
||||
edge_count = result.get("edge_count", len(edges))
|
||||
FreeCAD.Console.PrintMessage(
|
||||
f"DAG synced: {node_count} nodes, {edge_count} edges\n"
|
||||
)
|
||||
except Exception as e:
|
||||
FreeCAD.Console.PrintWarning(f"DAG sync failed: {e}\n")
|
||||
|
||||
|
||||
class Silo_Save:
|
||||
"""Save locally and upload to MinIO."""
|
||||
"""Save locally and upload to the server."""
|
||||
|
||||
def GetResources(self):
|
||||
return {
|
||||
"MenuText": "Save",
|
||||
"ToolTip": "Save locally and upload to MinIO (Ctrl+S)",
|
||||
"ToolTip": "Save locally and upload to server (Ctrl+S)",
|
||||
"Pixmap": _icon("save"),
|
||||
}
|
||||
|
||||
@@ -792,7 +841,7 @@ class Silo_Save:
|
||||
|
||||
FreeCAD.Console.PrintMessage(f"Saved: {file_path}\n")
|
||||
|
||||
# Try to upload to MinIO
|
||||
# Try to upload to server
|
||||
try:
|
||||
result = _client._upload_file(
|
||||
part_number, str(file_path), properties, "Auto-save"
|
||||
@@ -801,6 +850,8 @@ class Silo_Save:
|
||||
new_rev = result["revision_number"]
|
||||
FreeCAD.Console.PrintMessage(f"Uploaded as revision {new_rev}\n")
|
||||
|
||||
_push_dag_after_upload(doc, part_number, new_rev)
|
||||
|
||||
except Exception as e:
|
||||
FreeCAD.Console.PrintWarning(f"Upload failed: {e}\n")
|
||||
FreeCAD.Console.PrintMessage("File saved locally but not uploaded.\n")
|
||||
@@ -855,6 +906,8 @@ class Silo_Commit:
|
||||
new_rev = result["revision_number"]
|
||||
FreeCAD.Console.PrintMessage(f"Committed revision {new_rev}: {comment}\n")
|
||||
|
||||
_push_dag_after_upload(doc, part_number, new_rev)
|
||||
|
||||
except Exception as e:
|
||||
FreeCAD.Console.PrintError(f"Commit failed: {e}\n")
|
||||
|
||||
@@ -1067,12 +1120,12 @@ def _pull_dependencies(part_number, progress_callback=None):
|
||||
|
||||
|
||||
class Silo_Pull:
|
||||
"""Download from MinIO / sync from database."""
|
||||
"""Download revision file from the server."""
|
||||
|
||||
def GetResources(self):
|
||||
return {
|
||||
"MenuText": "Pull",
|
||||
"ToolTip": "Download from MinIO with revision selection",
|
||||
"ToolTip": "Download file with revision selection",
|
||||
"Pixmap": _icon("pull"),
|
||||
}
|
||||
|
||||
@@ -1228,12 +1281,12 @@ class Silo_Pull:
|
||||
|
||||
|
||||
class Silo_Push:
|
||||
"""Upload local files to MinIO."""
|
||||
"""Upload local files to the server."""
|
||||
|
||||
def GetResources(self):
|
||||
return {
|
||||
"MenuText": "Push",
|
||||
"ToolTip": "Upload local files that aren't in MinIO",
|
||||
"ToolTip": "Upload local files that aren't on the server",
|
||||
"Pixmap": _icon("push"),
|
||||
}
|
||||
|
||||
@@ -1354,7 +1407,7 @@ class Silo_Info:
|
||||
msg += f"<p><b>Local Revision:</b> {getattr(obj, 'SiloRevision', '-')}</p>"
|
||||
|
||||
has_file, _ = _client.has_file(part_number)
|
||||
msg += f"<p><b>File in MinIO:</b> {'Yes' if has_file else 'No'}</p>"
|
||||
msg += f"<p><b>File on Server:</b> {'Yes' if has_file else 'No'}</p>"
|
||||
|
||||
# Show current revision status
|
||||
if revisions:
|
||||
@@ -1737,6 +1790,31 @@ class Silo_Settings:
|
||||
|
||||
layout.addSpacing(10)
|
||||
|
||||
# Schema name
|
||||
schema_label = QtGui.QLabel("Schema Name:")
|
||||
layout.addWidget(schema_label)
|
||||
|
||||
schema_input = QtGui.QLineEdit()
|
||||
schema_input.setPlaceholderText("kindred-rd")
|
||||
current_schema = param.GetString("SchemaName", "")
|
||||
if current_schema:
|
||||
schema_input.setText(current_schema)
|
||||
else:
|
||||
env_schema = os.environ.get("SILO_SCHEMA", "")
|
||||
if env_schema:
|
||||
schema_input.setText(env_schema)
|
||||
layout.addWidget(schema_input)
|
||||
|
||||
schema_hint = QtGui.QLabel(
|
||||
"The part-numbering schema to use. Leave empty for "
|
||||
"SILO_SCHEMA env var or default (kindred-rd)."
|
||||
)
|
||||
schema_hint.setWordWrap(True)
|
||||
schema_hint.setStyleSheet("color: #888; font-size: 11px;")
|
||||
layout.addWidget(schema_hint)
|
||||
|
||||
layout.addSpacing(10)
|
||||
|
||||
# SSL
|
||||
ssl_checkbox = QtGui.QCheckBox("Verify SSL certificates")
|
||||
ssl_checkbox.setChecked(param.GetBool("SslVerify", True))
|
||||
@@ -1777,7 +1855,7 @@ class Silo_Settings:
|
||||
path, _ = QtGui.QFileDialog.getOpenFileName(
|
||||
dialog,
|
||||
"Select CA Certificate",
|
||||
os.path.dirname(cert_input.text()) or "/etc/ssl/certs",
|
||||
os.path.dirname(cert_input.text()) or os.path.expanduser("~"),
|
||||
"Certificates (*.pem *.crt *.cer);;All Files (*)",
|
||||
)
|
||||
if path:
|
||||
@@ -1879,6 +1957,7 @@ class Silo_Settings:
|
||||
auth_display = "not configured"
|
||||
status_label = QtGui.QLabel(
|
||||
f"<b>Active URL:</b> {_get_api_url()}<br>"
|
||||
f"<b>Schema:</b> {_get_schema_name()}<br>"
|
||||
f"<b>SSL verification:</b> {'enabled' if _get_ssl_verify() else 'disabled'}<br>"
|
||||
f"<b>CA certificate:</b> {cert_display}<br>"
|
||||
f"<b>Authentication:</b> {auth_display}"
|
||||
@@ -1900,6 +1979,7 @@ class Silo_Settings:
|
||||
def on_save():
|
||||
url = url_input.text().strip()
|
||||
param.SetString("ApiUrl", url)
|
||||
param.SetString("SchemaName", schema_input.text().strip())
|
||||
param.SetBool("SslVerify", ssl_checkbox.isChecked())
|
||||
cert_path = cert_input.text().strip()
|
||||
param.SetString("SslCertPath", cert_path)
|
||||
@@ -2324,7 +2404,19 @@ class SiloEventListener(QtCore.QThread):
|
||||
connection_status = QtCore.Signal(
|
||||
str, int, str
|
||||
) # (status, retry_count, error_message)
|
||||
server_mode_changed = QtCore.Signal(str) # "normal" / "read-only" / "degraded"
|
||||
server_mode_changed = QtCore.Signal(str) # "normal" / "read-only" / "offline"
|
||||
|
||||
# DAG events
|
||||
dag_updated = QtCore.Signal(str, int, int) # part_number, node_count, edge_count
|
||||
dag_validated = QtCore.Signal(str, bool, int) # part_number, valid, failed_count
|
||||
|
||||
# Job lifecycle events
|
||||
job_created = QtCore.Signal(str, str, str) # job_id, definition_name, part_number
|
||||
job_claimed = QtCore.Signal(str, str) # job_id, runner_id
|
||||
job_progress = QtCore.Signal(str, int, str) # job_id, progress, message
|
||||
job_completed = QtCore.Signal(str) # job_id
|
||||
job_failed = QtCore.Signal(str, str) # job_id, error
|
||||
job_cancelled = QtCore.Signal(str) # job_id
|
||||
|
||||
_MAX_RETRIES = 10
|
||||
_BASE_DELAY = 1 # seconds, doubles each retry
|
||||
@@ -2442,6 +2534,35 @@ class SiloEventListener(QtCore.QThread):
|
||||
self.server_mode_changed.emit(mode)
|
||||
return
|
||||
|
||||
# Job lifecycle events (keyed by job_id, not part_number)
|
||||
job_id = payload.get("job_id", "")
|
||||
if event_type == "job.created":
|
||||
self.job_created.emit(
|
||||
job_id,
|
||||
payload.get("definition_name", ""),
|
||||
payload.get("part_number", ""),
|
||||
)
|
||||
return
|
||||
if event_type == "job.claimed":
|
||||
self.job_claimed.emit(job_id, payload.get("runner_id", ""))
|
||||
return
|
||||
if event_type == "job.progress":
|
||||
self.job_progress.emit(
|
||||
job_id,
|
||||
int(payload.get("progress", 0)),
|
||||
payload.get("message", ""),
|
||||
)
|
||||
return
|
||||
if event_type == "job.completed":
|
||||
self.job_completed.emit(job_id)
|
||||
return
|
||||
if event_type == "job.failed":
|
||||
self.job_failed.emit(job_id, payload.get("error", ""))
|
||||
return
|
||||
if event_type == "job.cancelled":
|
||||
self.job_cancelled.emit(job_id)
|
||||
return
|
||||
|
||||
pn = payload.get("part_number", "")
|
||||
if not pn:
|
||||
return
|
||||
@@ -2451,6 +2572,18 @@ class SiloEventListener(QtCore.QThread):
|
||||
elif event_type == "revision_created":
|
||||
rev = payload.get("revision", 0)
|
||||
self.revision_created.emit(pn, int(rev))
|
||||
elif event_type == "dag.updated":
|
||||
self.dag_updated.emit(
|
||||
pn,
|
||||
int(payload.get("node_count", 0)),
|
||||
int(payload.get("edge_count", 0)),
|
||||
)
|
||||
elif event_type == "dag.validated":
|
||||
self.dag_validated.emit(
|
||||
pn,
|
||||
bool(payload.get("valid", False)),
|
||||
int(payload.get("failed_count", 0)),
|
||||
)
|
||||
|
||||
|
||||
class _SSEUnsupported(Exception):
|
||||
@@ -2582,7 +2715,11 @@ class SiloAuthDockWidget:
|
||||
btn_row.addWidget(settings_btn)
|
||||
|
||||
layout.addLayout(btn_row)
|
||||
layout.addStretch()
|
||||
|
||||
# Keep the auth panel compact so the Activity panel below gets more space
|
||||
self.widget.setSizePolicy(
|
||||
QtGui.QSizePolicy.Preferred, QtGui.QSizePolicy.Maximum
|
||||
)
|
||||
|
||||
# -- Status refresh -----------------------------------------------------
|
||||
|
||||
@@ -2674,6 +2811,14 @@ class SiloAuthDockWidget:
|
||||
self._event_listener.revision_created.connect(self._on_remote_revision)
|
||||
self._event_listener.connection_status.connect(self._on_sse_status)
|
||||
self._event_listener.server_mode_changed.connect(self._on_server_mode)
|
||||
self._event_listener.dag_updated.connect(self._on_dag_updated)
|
||||
self._event_listener.dag_validated.connect(self._on_dag_validated)
|
||||
self._event_listener.job_created.connect(self._on_job_created)
|
||||
self._event_listener.job_claimed.connect(self._on_job_claimed)
|
||||
self._event_listener.job_progress.connect(self._on_job_progress)
|
||||
self._event_listener.job_completed.connect(self._on_job_completed)
|
||||
self._event_listener.job_failed.connect(self._on_job_failed)
|
||||
self._event_listener.job_cancelled.connect(self._on_job_cancelled)
|
||||
self._event_listener.start()
|
||||
else:
|
||||
if self._event_listener is not None and self._event_listener.isRunning():
|
||||
@@ -2722,11 +2867,6 @@ class SiloAuthDockWidget:
|
||||
"background: #FFC107; color: #000; padding: 4px; font-size: 11px;",
|
||||
True,
|
||||
),
|
||||
"degraded": (
|
||||
"MinIO unavailable \u2014 file ops limited",
|
||||
"background: #FF9800; color: #000; padding: 4px; font-size: 11px;",
|
||||
True,
|
||||
),
|
||||
"offline": (
|
||||
"Disconnected from silo",
|
||||
"background: #F44336; color: #fff; padding: 4px; font-size: 11px;",
|
||||
@@ -2830,6 +2970,57 @@ class SiloAuthDockWidget:
|
||||
list_item.setData(QtCore.Qt.UserRole, pn)
|
||||
activity_list.addItem(list_item)
|
||||
|
||||
def _on_dag_updated(self, part_number, node_count, edge_count):
|
||||
FreeCAD.Console.PrintMessage(
|
||||
f"Silo: DAG updated for {part_number} ({node_count} nodes, {edge_count} edges)\n"
|
||||
)
|
||||
self._append_activity_event(
|
||||
f"\u25b6 {part_number} \u2013 DAG synced ({node_count} nodes, {edge_count} edges)",
|
||||
part_number,
|
||||
)
|
||||
|
||||
def _on_dag_validated(self, part_number, valid, failed_count):
|
||||
if valid:
|
||||
status = "\u2713 PASS"
|
||||
FreeCAD.Console.PrintMessage(f"Silo: Validation passed for {part_number}\n")
|
||||
else:
|
||||
status = f"\u2717 FAIL ({failed_count} failed)"
|
||||
FreeCAD.Console.PrintWarning(
|
||||
f"Silo: Validation failed for {part_number} ({failed_count} features failed)\n"
|
||||
)
|
||||
self._append_activity_event(f"{status} \u2013 {part_number}", part_number)
|
||||
|
||||
def _on_job_created(self, job_id, definition_name, part_number):
|
||||
FreeCAD.Console.PrintMessage(
|
||||
f"Silo: Job {definition_name} created for {part_number}\n"
|
||||
)
|
||||
self._append_activity_event(
|
||||
f"\u23f3 {part_number} \u2013 {definition_name} queued",
|
||||
part_number,
|
||||
)
|
||||
|
||||
def _on_job_completed(self, job_id):
|
||||
FreeCAD.Console.PrintMessage(f"Silo: Job {job_id} completed\n")
|
||||
self._rebuild_activity_feed()
|
||||
|
||||
def _on_job_failed(self, job_id, error):
|
||||
FreeCAD.Console.PrintError(f"Silo: Job {job_id} failed: {error}\n")
|
||||
self._append_activity_event(f"\u2717 Job {job_id[:8]} failed: {error}")
|
||||
|
||||
def _on_job_claimed(self, job_id, runner_id):
|
||||
FreeCAD.Console.PrintMessage(
|
||||
f"Silo: Job {job_id[:8]} claimed by runner {runner_id}\n"
|
||||
)
|
||||
|
||||
def _on_job_progress(self, job_id, progress, message):
|
||||
FreeCAD.Console.PrintMessage(
|
||||
f"Silo: Job {job_id[:8]} progress {progress}%: {message}\n"
|
||||
)
|
||||
|
||||
def _on_job_cancelled(self, job_id):
|
||||
FreeCAD.Console.PrintMessage(f"Silo: Job {job_id[:8]} cancelled\n")
|
||||
self._append_activity_event(f"\u2718 Job {job_id[:8]} cancelled")
|
||||
|
||||
def _on_activity_double_click(self, item):
|
||||
"""Open/checkout item from activity pane."""
|
||||
pn = item.data(256) # Qt.UserRole
|
||||
@@ -3024,6 +3215,470 @@ class Silo_Auth:
|
||||
return True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Jobs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_STATUS_ICONS = {
|
||||
"pending": "\u23f3", # hourglass
|
||||
"claimed": "\u2699", # gear
|
||||
"running": "\u25b6", # play
|
||||
"completed": "\u2714", # check
|
||||
"failed": "\u2717", # cross
|
||||
"cancelled": "\u2013", # dash
|
||||
}
|
||||
|
||||
|
||||
class JobMonitorDialog:
|
||||
"""Dialog showing job status, logs, and actions."""
|
||||
|
||||
def __init__(self, parent=None, part_number=None):
|
||||
from PySide import QtCore, QtGui
|
||||
|
||||
self._part_number = part_number
|
||||
self._jobs = []
|
||||
|
||||
self.dialog = QtGui.QDialog(parent)
|
||||
self.dialog.setWindowTitle("Jobs")
|
||||
self.dialog.setMinimumWidth(850)
|
||||
self.dialog.setMinimumHeight(500)
|
||||
layout = QtGui.QVBoxLayout(self.dialog)
|
||||
|
||||
# -- Filter bar --
|
||||
filter_layout = QtGui.QHBoxLayout()
|
||||
self._status_combo = QtGui.QComboBox()
|
||||
self._status_combo.addItems(
|
||||
["All", "pending", "claimed", "running", "completed", "failed", "cancelled"]
|
||||
)
|
||||
self._status_combo.currentIndexChanged.connect(self._refresh)
|
||||
filter_layout.addWidget(QtGui.QLabel("Status:"))
|
||||
filter_layout.addWidget(self._status_combo)
|
||||
|
||||
self._search_edit = QtGui.QLineEdit()
|
||||
self._search_edit.setPlaceholderText("Filter by item or definition...")
|
||||
self._search_edit.returnPressed.connect(self._refresh)
|
||||
filter_layout.addWidget(self._search_edit)
|
||||
filter_layout.addStretch()
|
||||
|
||||
trigger_btn = QtGui.QPushButton("Trigger Job...")
|
||||
trigger_btn.clicked.connect(self._trigger_job)
|
||||
filter_layout.addWidget(trigger_btn)
|
||||
|
||||
layout.addLayout(filter_layout)
|
||||
|
||||
# -- Splitter: table + detail --
|
||||
splitter = QtGui.QSplitter(QtCore.Qt.Vertical)
|
||||
layout.addWidget(splitter)
|
||||
|
||||
# Job table
|
||||
self._table = QtGui.QTableWidget()
|
||||
self._table.setColumnCount(7)
|
||||
self._table.setHorizontalHeaderLabels(
|
||||
[
|
||||
"Status",
|
||||
"Definition",
|
||||
"Item",
|
||||
"Runner",
|
||||
"Progress",
|
||||
"Created",
|
||||
"Duration",
|
||||
]
|
||||
)
|
||||
self._table.setSelectionBehavior(QtGui.QAbstractItemView.SelectRows)
|
||||
self._table.setSelectionMode(QtGui.QAbstractItemView.SingleSelection)
|
||||
self._table.setEditTriggers(QtGui.QAbstractItemView.NoEditTriggers)
|
||||
self._table.horizontalHeader().setStretchLastSection(True)
|
||||
self._table.currentCellChanged.connect(self._on_selection_changed)
|
||||
splitter.addWidget(self._table)
|
||||
|
||||
# Detail panel
|
||||
detail_widget = QtGui.QWidget()
|
||||
detail_layout = QtGui.QVBoxLayout(detail_widget)
|
||||
detail_layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
detail_header = QtGui.QHBoxLayout()
|
||||
self._detail_label = QtGui.QLabel("Select a job to view details")
|
||||
detail_header.addWidget(self._detail_label)
|
||||
detail_header.addStretch()
|
||||
self._cancel_btn = QtGui.QPushButton("Cancel Job")
|
||||
self._cancel_btn.setEnabled(False)
|
||||
self._cancel_btn.clicked.connect(self._cancel_job)
|
||||
detail_header.addWidget(self._cancel_btn)
|
||||
detail_layout.addLayout(detail_header)
|
||||
|
||||
self._log_view = QtGui.QTextEdit()
|
||||
self._log_view.setReadOnly(True)
|
||||
self._log_view.setFontFamily("monospace")
|
||||
detail_layout.addWidget(self._log_view)
|
||||
|
||||
splitter.addWidget(detail_widget)
|
||||
splitter.setSizes([300, 200])
|
||||
|
||||
self._refresh()
|
||||
|
||||
def _refresh(self):
|
||||
from PySide import QtGui
|
||||
|
||||
status_filter = self._status_combo.currentText()
|
||||
if status_filter == "All":
|
||||
status_filter = ""
|
||||
|
||||
try:
|
||||
self._jobs = _client.list_jobs(
|
||||
status=status_filter,
|
||||
definition=self._search_edit.text(),
|
||||
)
|
||||
except Exception as e:
|
||||
FreeCAD.Console.PrintError(f"Silo: Failed to list jobs: {e}\n")
|
||||
self._jobs = []
|
||||
|
||||
# Filter by part_number client-side if scoped
|
||||
if self._part_number:
|
||||
self._jobs = [
|
||||
j
|
||||
for j in self._jobs
|
||||
if j.get("part_number") == self._part_number
|
||||
or j.get("item_id") == self._part_number
|
||||
]
|
||||
|
||||
self._table.setRowCount(len(self._jobs))
|
||||
for row, job in enumerate(self._jobs):
|
||||
status = job.get("status", "")
|
||||
icon = _STATUS_ICONS.get(status, "?")
|
||||
self._table.setItem(row, 0, QtGui.QTableWidgetItem(f"{icon} {status}"))
|
||||
self._table.setItem(
|
||||
row, 1, QtGui.QTableWidgetItem(job.get("definition_name", ""))
|
||||
)
|
||||
self._table.setItem(
|
||||
row, 2, QtGui.QTableWidgetItem(job.get("part_number", ""))
|
||||
)
|
||||
self._table.setItem(
|
||||
row, 3, QtGui.QTableWidgetItem(job.get("runner_name", ""))
|
||||
)
|
||||
|
||||
progress = job.get("progress", 0)
|
||||
progress_msg = job.get("progress_message", "")
|
||||
progress_text = f"{progress}%" if progress else ""
|
||||
if progress_msg:
|
||||
progress_text += f" {progress_msg}"
|
||||
self._table.setItem(row, 4, QtGui.QTableWidgetItem(progress_text))
|
||||
|
||||
created = job.get("created_at", "")
|
||||
if created and len(created) > 16:
|
||||
created = created[:16].replace("T", " ")
|
||||
self._table.setItem(row, 5, QtGui.QTableWidgetItem(created))
|
||||
|
||||
duration = job.get("duration_seconds")
|
||||
dur_text = f"{duration}s" if duration else ""
|
||||
self._table.setItem(row, 6, QtGui.QTableWidgetItem(dur_text))
|
||||
|
||||
self._table.resizeColumnsToContents()
|
||||
|
||||
def _on_selection_changed(self, row, _col, _prev_row, _prev_col):
|
||||
from PySide import QtGui
|
||||
|
||||
if row < 0 or row >= len(self._jobs):
|
||||
self._detail_label.setText("Select a job to view details")
|
||||
self._log_view.clear()
|
||||
self._cancel_btn.setEnabled(False)
|
||||
return
|
||||
|
||||
job = self._jobs[row]
|
||||
job_id = job.get("id", "")
|
||||
status = job.get("status", "")
|
||||
defn = job.get("definition_name", "")
|
||||
pn = job.get("part_number", "")
|
||||
error = job.get("error_message", "")
|
||||
|
||||
self._detail_label.setText(f"<b>{defn}</b> \u2014 {pn} \u2014 {status}")
|
||||
self._cancel_btn.setEnabled(status in ("pending", "claimed", "running"))
|
||||
|
||||
# Load logs
|
||||
self._log_view.clear()
|
||||
if error:
|
||||
self._log_view.append(f"ERROR: {error}\n")
|
||||
try:
|
||||
logs = _client.get_job_logs(job_id)
|
||||
for entry in logs:
|
||||
level = entry.get("level", "info").upper()
|
||||
msg = entry.get("message", "")
|
||||
self._log_view.append(f"[{level}] {msg}")
|
||||
except Exception as e:
|
||||
self._log_view.append(f"(failed to load logs: {e})")
|
||||
|
||||
def _cancel_job(self):
|
||||
from PySide import QtGui
|
||||
|
||||
row = self._table.currentRow()
|
||||
if row < 0 or row >= len(self._jobs):
|
||||
return
|
||||
job = self._jobs[row]
|
||||
job_id = job.get("id", "")
|
||||
reply = QtGui.QMessageBox.question(
|
||||
self.dialog,
|
||||
"Cancel Job",
|
||||
f"Cancel job {job.get('definition_name', '')} for {job.get('part_number', '')}?",
|
||||
QtGui.QMessageBox.Yes | QtGui.QMessageBox.No,
|
||||
)
|
||||
if reply != QtGui.QMessageBox.Yes:
|
||||
return
|
||||
try:
|
||||
_client.cancel_job(job_id)
|
||||
FreeCAD.Console.PrintMessage(f"Silo: Job {job_id} cancelled\n")
|
||||
except Exception as e:
|
||||
QtGui.QMessageBox.warning(
|
||||
self.dialog, "Cancel Failed", f"Failed to cancel job:\n{e}"
|
||||
)
|
||||
self._refresh()
|
||||
|
||||
def _trigger_job(self):
|
||||
from PySide import QtGui
|
||||
|
||||
try:
|
||||
definitions = _client.list_job_definitions()
|
||||
except Exception as e:
|
||||
QtGui.QMessageBox.warning(
|
||||
self.dialog, "Error", f"Failed to load job definitions:\n{e}"
|
||||
)
|
||||
return
|
||||
|
||||
if not definitions:
|
||||
QtGui.QMessageBox.information(
|
||||
self.dialog,
|
||||
"No Definitions",
|
||||
"No job definitions are loaded on the server.",
|
||||
)
|
||||
return
|
||||
|
||||
names = [d.get("name", "") for d in definitions]
|
||||
name, ok = QtGui.QInputDialog.getItem(
|
||||
self.dialog, "Trigger Job", "Job definition:", names, editable=False
|
||||
)
|
||||
if not ok or not name:
|
||||
return
|
||||
|
||||
pn = self._part_number or ""
|
||||
if not pn:
|
||||
pn, ok = QtGui.QInputDialog.getText(
|
||||
self.dialog, "Trigger Job", "Part number (optional):"
|
||||
)
|
||||
if not ok:
|
||||
return
|
||||
|
||||
try:
|
||||
result = _client.trigger_job(name, part_number=pn)
|
||||
FreeCAD.Console.PrintMessage(
|
||||
f"Silo: Job triggered: {result.get('id', '')}\n"
|
||||
)
|
||||
except Exception as e:
|
||||
QtGui.QMessageBox.warning(
|
||||
self.dialog, "Trigger Failed", f"Failed to trigger job:\n{e}"
|
||||
)
|
||||
self._refresh()
|
||||
|
||||
def on_job_event(self):
|
||||
"""Called from SSE handlers to refresh the table."""
|
||||
if self.dialog.isVisible():
|
||||
self._refresh()
|
||||
|
||||
def exec_(self):
|
||||
self.dialog.exec_()
|
||||
|
||||
|
||||
class Silo_Jobs:
|
||||
"""View and manage compute jobs."""
|
||||
|
||||
def GetResources(self):
|
||||
return {
|
||||
"MenuText": "Jobs",
|
||||
"ToolTip": "View and manage compute jobs",
|
||||
"Pixmap": _icon("info"),
|
||||
}
|
||||
|
||||
def Activated(self):
|
||||
doc = FreeCAD.ActiveDocument
|
||||
part_number = None
|
||||
if doc:
|
||||
obj = get_tracked_object(doc)
|
||||
if obj and hasattr(obj, "SiloPartNumber"):
|
||||
part_number = obj.SiloPartNumber
|
||||
|
||||
monitor = JobMonitorDialog(
|
||||
parent=FreeCADGui.getMainWindow(),
|
||||
part_number=part_number,
|
||||
)
|
||||
monitor.exec_()
|
||||
|
||||
def IsActive(self):
|
||||
return _client.is_authenticated()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Runners (admin)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class RunnerAdminDialog:
|
||||
"""Dialog for managing runner registrations."""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
from PySide import QtCore, QtGui
|
||||
|
||||
self._runners = []
|
||||
|
||||
self.dialog = QtGui.QDialog(parent)
|
||||
self.dialog.setWindowTitle("Runners")
|
||||
self.dialog.setMinimumWidth(650)
|
||||
self.dialog.setMinimumHeight(350)
|
||||
layout = QtGui.QVBoxLayout(self.dialog)
|
||||
|
||||
# Runner table
|
||||
self._table = QtGui.QTableWidget()
|
||||
self._table.setColumnCount(5)
|
||||
self._table.setHorizontalHeaderLabels(
|
||||
["Name", "Tags", "Status", "Last Heartbeat", "Jobs"]
|
||||
)
|
||||
self._table.setSelectionBehavior(QtGui.QAbstractItemView.SelectRows)
|
||||
self._table.setSelectionMode(QtGui.QAbstractItemView.SingleSelection)
|
||||
self._table.setEditTriggers(QtGui.QAbstractItemView.NoEditTriggers)
|
||||
self._table.horizontalHeader().setStretchLastSection(True)
|
||||
layout.addWidget(self._table)
|
||||
|
||||
# Buttons
|
||||
btn_layout = QtGui.QHBoxLayout()
|
||||
register_btn = QtGui.QPushButton("Register Runner...")
|
||||
register_btn.clicked.connect(self._register_runner)
|
||||
btn_layout.addWidget(register_btn)
|
||||
|
||||
delete_btn = QtGui.QPushButton("Delete Runner")
|
||||
delete_btn.clicked.connect(self._delete_runner)
|
||||
btn_layout.addWidget(delete_btn)
|
||||
|
||||
btn_layout.addStretch()
|
||||
|
||||
refresh_btn = QtGui.QPushButton("Refresh")
|
||||
refresh_btn.clicked.connect(self._refresh)
|
||||
btn_layout.addWidget(refresh_btn)
|
||||
|
||||
layout.addLayout(btn_layout)
|
||||
|
||||
self._refresh()
|
||||
|
||||
def _refresh(self):
|
||||
from PySide import QtGui
|
||||
|
||||
try:
|
||||
self._runners = _client.list_runners()
|
||||
except Exception as e:
|
||||
FreeCAD.Console.PrintError(f"Silo: Failed to list runners: {e}\n")
|
||||
self._runners = []
|
||||
|
||||
self._table.setRowCount(len(self._runners))
|
||||
for row, runner in enumerate(self._runners):
|
||||
self._table.setItem(row, 0, QtGui.QTableWidgetItem(runner.get("name", "")))
|
||||
tags = ", ".join(runner.get("tags", []))
|
||||
self._table.setItem(row, 1, QtGui.QTableWidgetItem(tags))
|
||||
status = runner.get("status", "unknown")
|
||||
icon = "\u2705" if status == "online" else "\u26aa"
|
||||
self._table.setItem(row, 2, QtGui.QTableWidgetItem(f"{icon} {status}"))
|
||||
heartbeat = runner.get("last_heartbeat", "")
|
||||
if heartbeat and len(heartbeat) > 16:
|
||||
heartbeat = heartbeat[:16].replace("T", " ")
|
||||
self._table.setItem(row, 3, QtGui.QTableWidgetItem(heartbeat))
|
||||
jobs = runner.get("jobs_completed", 0)
|
||||
self._table.setItem(row, 4, QtGui.QTableWidgetItem(str(jobs)))
|
||||
|
||||
self._table.resizeColumnsToContents()
|
||||
|
||||
def _register_runner(self):
|
||||
from PySide import QtGui
|
||||
|
||||
name, ok = QtGui.QInputDialog.getText(
|
||||
self.dialog, "Register Runner", "Runner name:"
|
||||
)
|
||||
if not ok or not name:
|
||||
return
|
||||
|
||||
tags_str, ok = QtGui.QInputDialog.getText(
|
||||
self.dialog,
|
||||
"Register Runner",
|
||||
"Tags (comma-separated, e.g. create,linux):",
|
||||
)
|
||||
if not ok:
|
||||
return
|
||||
|
||||
tags = [t.strip() for t in tags_str.split(",") if t.strip()]
|
||||
|
||||
try:
|
||||
result = _client.register_runner(name, tags)
|
||||
token = result.get("token", "")
|
||||
QtGui.QMessageBox.information(
|
||||
self.dialog,
|
||||
"Runner Registered",
|
||||
f"Runner <b>{name}</b> registered.\n\n"
|
||||
f"Token (copy now — shown only once):\n\n"
|
||||
f"<code>{token}</code>",
|
||||
)
|
||||
except Exception as e:
|
||||
QtGui.QMessageBox.warning(
|
||||
self.dialog,
|
||||
"Registration Failed",
|
||||
f"Failed to register runner:\n{e}",
|
||||
)
|
||||
self._refresh()
|
||||
|
||||
def _delete_runner(self):
|
||||
from PySide import QtGui
|
||||
|
||||
row = self._table.currentRow()
|
||||
if row < 0 or row >= len(self._runners):
|
||||
return
|
||||
runner = self._runners[row]
|
||||
runner_name = runner.get("name", "")
|
||||
runner_id = runner.get("id", "")
|
||||
|
||||
reply = QtGui.QMessageBox.question(
|
||||
self.dialog,
|
||||
"Delete Runner",
|
||||
f"Delete runner <b>{runner_name}</b>?\n\nThis will invalidate its token.",
|
||||
QtGui.QMessageBox.Yes | QtGui.QMessageBox.No,
|
||||
)
|
||||
if reply != QtGui.QMessageBox.Yes:
|
||||
return
|
||||
|
||||
try:
|
||||
_client.delete_runner(runner_id)
|
||||
FreeCAD.Console.PrintMessage(f"Silo: Runner {runner_name} deleted\n")
|
||||
except Exception as e:
|
||||
QtGui.QMessageBox.warning(
|
||||
self.dialog,
|
||||
"Delete Failed",
|
||||
f"Failed to delete runner:\n{e}",
|
||||
)
|
||||
self._refresh()
|
||||
|
||||
def exec_(self):
|
||||
self.dialog.exec_()
|
||||
|
||||
|
||||
class Silo_Runners:
|
||||
"""Manage compute runners (admin)."""
|
||||
|
||||
def GetResources(self):
|
||||
return {
|
||||
"MenuText": "Runners",
|
||||
"ToolTip": "Manage compute runners (admin)",
|
||||
"Pixmap": _icon("info"),
|
||||
}
|
||||
|
||||
def Activated(self):
|
||||
admin = RunnerAdminDialog(parent=FreeCADGui.getMainWindow())
|
||||
admin.exec_()
|
||||
|
||||
def IsActive(self):
|
||||
return _client.is_authenticated()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Start panel
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -3305,6 +3960,8 @@ 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:
|
||||
@@ -3423,3 +4080,5 @@ FreeCADGui.addCommand("Silo_Settings", Silo_Settings())
|
||||
FreeCADGui.addCommand("Silo_Auth", Silo_Auth())
|
||||
FreeCADGui.addCommand("Silo_StartPanel", Silo_StartPanel())
|
||||
FreeCADGui.addCommand("Silo_Diag", Silo_Diag())
|
||||
FreeCADGui.addCommand("Silo_Jobs", Silo_Jobs())
|
||||
FreeCADGui.addCommand("Silo_Runners", Silo_Runners())
|
||||
|
||||
@@ -30,7 +30,7 @@ class SiloOrigin:
|
||||
Key behaviors:
|
||||
- Documents are always stored locally (hybrid local-remote model)
|
||||
- Database tracks metadata, part numbers, and revision history
|
||||
- MinIO stores revision snapshots for sync/backup
|
||||
- Server stores revision files for sync/backup
|
||||
- Identity is tracked by UUID (SiloItemId), displayed as part number
|
||||
"""
|
||||
|
||||
@@ -388,9 +388,7 @@ class SiloOrigin:
|
||||
|
||||
# Upload to Silo
|
||||
properties = collect_document_properties(doc)
|
||||
_client._upload_file(
|
||||
obj.SiloPartNumber, str(file_path), properties, comment=""
|
||||
)
|
||||
_client._upload_file(obj.SiloPartNumber, str(file_path), properties, comment="")
|
||||
|
||||
# Clear modified flag (Modified is on Gui.Document, not App.Document)
|
||||
gui_doc = FreeCADGui.getDocument(doc.Name)
|
||||
@@ -567,12 +565,9 @@ def register_silo_origin():
|
||||
This should be called during workbench initialization to make
|
||||
Silo available as a file origin.
|
||||
"""
|
||||
origin = get_silo_origin()
|
||||
try:
|
||||
FreeCADGui.addOrigin(origin)
|
||||
FreeCAD.Console.PrintLog("Registered Silo origin\n")
|
||||
except Exception as e:
|
||||
FreeCAD.Console.PrintWarning(f"Could not register Silo origin: {e}\n")
|
||||
from kindred_sdk import register_origin
|
||||
|
||||
register_origin(get_silo_origin())
|
||||
|
||||
|
||||
def unregister_silo_origin():
|
||||
@@ -582,9 +577,7 @@ def unregister_silo_origin():
|
||||
"""
|
||||
global _silo_origin
|
||||
if _silo_origin:
|
||||
try:
|
||||
FreeCADGui.removeOrigin(_silo_origin)
|
||||
FreeCAD.Console.PrintLog("Unregistered Silo origin\n")
|
||||
except Exception as e:
|
||||
FreeCAD.Console.PrintWarning(f"Could not unregister Silo origin: {e}\n")
|
||||
from kindred_sdk import unregister_origin
|
||||
|
||||
unregister_origin(_silo_origin)
|
||||
_silo_origin = None
|
||||
|
||||
@@ -19,23 +19,10 @@ from PySide import QtCore, QtGui, QtWidgets
|
||||
# ---------------------------------------------------------------------------
|
||||
# Catppuccin Mocha palette
|
||||
# ---------------------------------------------------------------------------
|
||||
_MOCHA = {
|
||||
"base": "#1e1e2e",
|
||||
"mantle": "#181825",
|
||||
"crust": "#11111b",
|
||||
"surface0": "#313244",
|
||||
"surface1": "#45475a",
|
||||
"surface2": "#585b70",
|
||||
"text": "#cdd6f4",
|
||||
"subtext0": "#a6adc8",
|
||||
"subtext1": "#bac2de",
|
||||
"blue": "#89b4fa",
|
||||
"green": "#a6e3a1",
|
||||
"red": "#f38ba8",
|
||||
"peach": "#fab387",
|
||||
"lavender": "#b4befe",
|
||||
"overlay0": "#6c7086",
|
||||
}
|
||||
# Catppuccin Mocha palette — sourced from kindred-addon-sdk
|
||||
from kindred_sdk.theme import get_theme_tokens
|
||||
|
||||
_MOCHA = get_theme_tokens()
|
||||
|
||||
_PREF_GROUP = "User parameter:BaseApp/Preferences/Mod/KindredSilo"
|
||||
|
||||
|
||||
Submodule silo-client updated: 68a4139251...285bd1fa11
Reference in New Issue
Block a user