diff --git a/freecad/InitGui.py b/freecad/InitGui.py
index 6687c52..aae3b2a 100644
--- a/freecad/InitGui.py
+++ b/freecad/InitGui.py
@@ -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",
]
diff --git a/freecad/silo_commands.py b/freecad/silo_commands.py
index 91960ba..b57f48a 100644
--- a/freecad/silo_commands.py
+++ b/freecad/silo_commands.py
@@ -2735,8 +2735,11 @@ class SiloAuthDockWidget:
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():
@@ -2856,6 +2859,20 @@ class SiloAuthDockWidget:
FreeCAD.Console.PrintError(f"Silo: Job {job_id} failed: {error}\n")
self._add_activity_entry(f"\u2717 Job {job_id[:8]} failed: {error}", None)
+ 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._add_activity_entry(f"\u2718 Job {job_id[:8]} cancelled", None)
+
def _add_activity_entry(self, text, part_number):
"""Insert a live event entry at the top of the Activity panel."""
from PySide import QtCore, QtGui, QtWidgets
@@ -3159,6 +3176,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"{defn} \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 {name} registered.\n\n"
+ f"Token (copy now — shown only once):\n\n"
+ f"{token}",
+ )
+ 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 {runner_name}?\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
# ---------------------------------------------------------------------------
@@ -3560,3 +4042,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())
diff --git a/silo-client b/silo-client
index fb658c5..9b71cf0 160000
--- a/silo-client
+++ b/silo-client
@@ -1 +1 @@
-Subproject commit fb658c5a249275700eab5a5e863e46038f613950
+Subproject commit 9b71cf037555b111bb1345c9d93743693d40e68d