Compare commits
10 Commits
3dd0da3964
...
feat/subsc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3fe43710fa | ||
|
|
8cbd872e5c | ||
|
|
e31321ac95 | ||
|
|
dc64a66f0f | ||
|
|
3d38e4b4c3 | ||
|
|
da2a360c56 | ||
| 0f407360ed | |||
| fa4f3145c6 | |||
| d3e27010d8 | |||
|
|
d7c6066030 |
@@ -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",
|
||||
]
|
||||
|
||||
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)
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -2338,6 +2358,18 @@ class SiloEventListener(QtCore.QThread):
|
||||
) # (status, retry_count, error_message)
|
||||
server_mode_changed = QtCore.Signal(str) # "normal" / "read-only" / "degraded"
|
||||
|
||||
# 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
|
||||
_MAX_DELAY = 60 # seconds, backoff cap
|
||||
@@ -2454,6 +2486,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
|
||||
@@ -2463,6 +2524,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):
|
||||
@@ -2482,6 +2555,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()
|
||||
|
||||
@@ -2489,6 +2564,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):
|
||||
@@ -2587,7 +2667,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 -----------------------------------------------------
|
||||
|
||||
@@ -2679,6 +2763,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():
|
||||
@@ -2691,6 +2783,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})..."
|
||||
@@ -2715,6 +2808,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 = {
|
||||
@@ -2745,7 +2840,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(
|
||||
@@ -2756,11 +2851,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:
|
||||
@@ -2782,66 +2914,72 @@ 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]
|
||||
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)
|
||||
|
||||
# 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
|
||||
def _on_dag_updated(self, part_number, node_count, edge_count):
|
||||
FreeCAD.Console.PrintMessage(
|
||||
f"Silo: DAG updated for {part_number}"
|
||||
f" ({node_count} nodes, {edge_count} edges)\n"
|
||||
)
|
||||
self._append_activity_event(
|
||||
f"\u25b6 {part_number} \u2013 DAG synced"
|
||||
f" ({node_count} nodes, {edge_count} edges)",
|
||||
part_number,
|
||||
)
|
||||
|
||||
# Truncate long descriptions
|
||||
desc_display = desc
|
||||
if len(desc_display) > 40:
|
||||
desc_display = desc_display[:37] + "..."
|
||||
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}"
|
||||
f" ({failed_count} features failed)\n"
|
||||
)
|
||||
self._append_activity_event(f"{status} \u2013 {part_number}", part_number)
|
||||
|
||||
# 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}"
|
||||
)
|
||||
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,
|
||||
)
|
||||
|
||||
if comment:
|
||||
line1 += f'\n "{comment}"'
|
||||
else:
|
||||
line1 += "\n (no comment)"
|
||||
def _on_job_completed(self, job_id):
|
||||
FreeCAD.Console.PrintMessage(f"Silo: Job {job_id} completed\n")
|
||||
self._rebuild_activity_feed()
|
||||
|
||||
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)
|
||||
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}")
|
||||
|
||||
if activity_list.count() == 0:
|
||||
activity_list.addItem("(No items in database)")
|
||||
except Exception:
|
||||
activity_list.addItem("(Unable to refresh activity)")
|
||||
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."""
|
||||
@@ -3037,6 +3175,471 @@ 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 "
|
||||
f"{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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -3438,3 +4041,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())
|
||||
|
||||
Submodule silo-client updated: fb658c5a24...9b71cf0375
Reference in New Issue
Block a user