From e31321ac951483f9c16bfd3dadfdfcd1770c5b3e Mon Sep 17 00:00:00 2001 From: Zoe Forbes Date: Sun, 15 Feb 2026 05:07:33 -0600 Subject: [PATCH] feat: add Jobs and Runners commands with SSE event wiring - Add JobMonitorDialog (Silo_Jobs): filter, view, trigger, cancel jobs - Add RunnerAdminDialog (Silo_Runners): list, register, delete runners - Wire job_claimed, job_progress, job_cancelled SSE signals to handlers - Add activity panel entries for job lifecycle events - Register Silo_Jobs in toolbar and menu, Silo_Runners in menu - Update silo-client submodule with worker API methods --- freecad/InitGui.py | 3 + freecad/silo_commands.py | 484 +++++++++++++++++++++++++++++++++++++++ silo-client | 2 +- 3 files changed, 488 insertions(+), 1 deletion(-) 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