Compare commits

...

5 Commits

Author SHA1 Message Date
27ddd6d750 Merge pull request 'feat: add Jobs and Runners commands with SSE event wiring' (#24) from feat/worker-client-ui into feat/dag-activity-display
Reviewed-on: #24
2026-02-15 14:20:12 +00:00
Zoe Forbes
e31321ac95 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
2026-02-15 05:07:33 -06:00
Zoe Forbes
dc64a66f0f feat: show DAG status and job events in Activity panel
Connects dag_updated, dag_validated, and job lifecycle signals
from SiloEventListener to the Database Activity dock widget.

- dag.updated: inserts DAG sync status (node/edge count)
- dag.validated: inserts pass/fail badge with failed count
- job.created: inserts queued job entry
- job.completed: refreshes the full activity list
- job.failed: inserts error entry

Live entries are inserted at the top of the activity list,
styled in Catppuccin Blue, capped at 50 entries.

Closes kindred/create#219
2026-02-14 15:28:40 -06:00
Zoe Forbes
3d38e4b4c3 feat: handle DAG and job SSE events in SiloEventListener
New signals:
- dag_updated(part_number, node_count, edge_count)
- dag_validated(part_number, valid, failed_count)
- job_created/claimed/progress/completed/failed/cancelled

Dispatch logic parses payloads and emits typed signals for
downstream UI and logging consumers.

Closes kindred/create#218
2026-02-14 15:22:29 -06:00
Zoe Forbes
da2a360c56 feat: add headless runner entry points
New file: runner.py with three entry points for silorunner:
- dag_extract(input_path, output_path): extract feature DAG as JSON
- validate(input_path, output_path): rebuild features, report pass/fail
- export(input_path, output_path, format): export to STEP/IGES/STL/OBJ

Invoked via: create --console -e 'from runner import dag_extract; ...'

Closes kindred/create#217
2026-02-14 15:17:30 -06:00
4 changed files with 766 additions and 1 deletions

View File

@@ -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
View 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)

View File

@@ -2338,6 +2338,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 +2466,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 +2504,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):
@@ -2679,6 +2732,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():
@@ -2758,6 +2819,84 @@ class SiloAuthDockWidget:
)
self._refresh_activity_panel()
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._add_activity_entry(
f"\u25b6 {part_number} \u2013 DAG synced"
f" ({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}"
f" ({failed_count} features failed)\n"
)
self._add_activity_entry(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._add_activity_entry(
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._refresh_activity_panel()
def _on_job_failed(self, job_id, error):
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
mw = FreeCADGui.getMainWindow()
if mw is None:
return
panel = mw.findChild(QtWidgets.QDockWidget, "SiloDatabaseActivity")
if panel is None:
return
activity_list = panel.findChild(QtWidgets.QListWidget)
if activity_list is None:
return
item = QtWidgets.QListWidgetItem(text)
if part_number:
item.setData(QtCore.Qt.UserRole, part_number)
item.setForeground(QtGui.QColor("#89b4fa"))
activity_list.insertItem(0, item)
# Cap the list at 50 entries
while activity_list.count() > 50:
activity_list.takeItem(activity_list.count() - 1)
def _refresh_activity_panel(self):
"""Refresh the Database Activity panel if it exists."""
from PySide import QtCore, QtGui, QtWidgets
@@ -3037,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"<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 +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())