Compare commits

..

3 Commits

Author SHA1 Message Date
d605844cb5 refactor: migrate to kindred-addon-sdk for overlay, origin, and theme (#250)
Replace FreeCADGui.registerEditingOverlay() with kindred_sdk.register_overlay().
Replace FreeCADGui.addOrigin()/removeOrigin() with kindred_sdk wrappers.
Replace hardcoded _MOCHA dict with kindred_sdk.get_theme_tokens().
Add sdk dependency to package.xml <kindred> element.
2026-02-17 08:59:45 -06:00
7a4ed3550a feat: add <kindred> element to package.xml
Declares min_create_version=0.1.0, load_priority=60, pure_python=true,
and documents universal overlay context.
2026-02-16 14:10:03 -06:00
c88dd19672 chore: update silo-client submodule to latest main (5276ff2) 2026-02-16 11:27:03 -06:00
9 changed files with 147 additions and 638 deletions

View File

@@ -45,7 +45,6 @@ class SiloWorkbench(FreeCADGui.Workbench):
"Separator",
"Silo_Info",
"Silo_BOM",
"Silo_Jobs",
]
self.appendToolbar("Silo Origin", self.silo_toolbar_commands, "Unavailable")
@@ -53,14 +52,12 @@ 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",
]
@@ -106,7 +103,9 @@ def _register_silo_overlay():
return False
try:
FreeCADGui.registerEditingOverlay(
from kindred_sdk import register_overlay
register_overlay(
"silo", # overlay id
["Silo Origin"], # toolbar names to append
_silo_overlay_match, # match function

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -12,4 +12,17 @@
<subdirectory>./</subdirectory>
</workbench>
</content>
<!-- Kindred Create extensions -->
<kindred>
<min_create_version>0.1.0</min_create_version>
<load_priority>60</load_priority>
<pure_python>true</pure_python>
<dependencies>
<dependency>sdk</dependency>
</dependencies>
<contexts>
<context id="*" action="overlay"/>
</contexts>
</kindred>
</package>

View File

@@ -7,7 +7,6 @@ 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
@@ -32,25 +31,6 @@ 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
# ---------------------------------------------------------------------------
@@ -2555,8 +2535,6 @@ 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()
@@ -2564,11 +2542,6 @@ 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):
@@ -2667,11 +2640,7 @@ class SiloAuthDockWidget:
btn_row.addWidget(settings_btn)
layout.addLayout(btn_row)
# Keep the auth panel compact so the Activity panel below gets more space
self.widget.setSizePolicy(
QtGui.QSizePolicy.Preferred, QtGui.QSizePolicy.Maximum
)
layout.addStretch()
# -- Status refresh -----------------------------------------------------
@@ -2766,11 +2735,8 @@ 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():
@@ -2783,7 +2749,6 @@ 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})..."
@@ -2808,8 +2773,6 @@ 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 = {
@@ -2840,7 +2803,7 @@ class SiloAuthDockWidget:
mw = FreeCADGui.getMainWindow()
if mw is not None:
mw.statusBar().showMessage(f"Silo: {part_number} updated on server", 5000)
self._append_activity_event(f"{part_number} updated", part_number)
self._refresh_activity_panel()
def _on_remote_revision(self, part_number, revision):
FreeCAD.Console.PrintMessage(
@@ -2851,48 +2814,75 @@ class SiloAuthDockWidget:
mw.statusBar().showMessage(
f"Silo: {part_number} rev {revision} available", 5000
)
self._append_activity_event(
f"{part_number} Rev {revision} created", part_number
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 _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 _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 _seed_activity_feed(self):
"""One-time: populate the feed with recent items from the database."""
if self._activity_seeded:
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 _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
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
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
mw = FreeCADGui.getMainWindow()
if mw is None:
@@ -2914,72 +2904,66 @@ class SiloAuthDockWidget:
)
activity_list._silo_connected = True
if not self._activity_events:
item = QtWidgets.QListWidgetItem("(No activity yet)")
item.setFlags(QtCore.Qt.NoItemFlags)
activity_list.addItem(item)
return
# 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
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)
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]
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,
)
# 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_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)
# Truncate long descriptions
desc_display = desc
if len(desc_display) > 40:
desc_display = desc_display[:37] + "..."
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,
)
# 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_completed(self, job_id):
FreeCAD.Console.PrintMessage(f"Silo: Job {job_id} completed\n")
self._rebuild_activity_feed()
if comment:
line1 += f'\n "{comment}"'
else:
line1 += "\n (no comment)"
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}")
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_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")
if activity_list.count() == 0:
activity_list.addItem("(No items in database)")
except Exception:
activity_list.addItem("(Unable to refresh activity)")
def _on_activity_double_click(self, item):
"""Open/checkout item from activity pane."""
@@ -3175,471 +3159,6 @@ 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
# ---------------------------------------------------------------------------
@@ -4041,5 +3560,3 @@ 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())

View File

@@ -388,9 +388,7 @@ class SiloOrigin:
# Upload to Silo
properties = collect_document_properties(doc)
_client._upload_file(
obj.SiloPartNumber, str(file_path), properties, comment=""
)
_client._upload_file(obj.SiloPartNumber, str(file_path), properties, comment="")
# Clear modified flag (Modified is on Gui.Document, not App.Document)
gui_doc = FreeCADGui.getDocument(doc.Name)
@@ -567,12 +565,9 @@ def register_silo_origin():
This should be called during workbench initialization to make
Silo available as a file origin.
"""
origin = get_silo_origin()
try:
FreeCADGui.addOrigin(origin)
FreeCAD.Console.PrintLog("Registered Silo origin\n")
except Exception as e:
FreeCAD.Console.PrintWarning(f"Could not register Silo origin: {e}\n")
from kindred_sdk import register_origin
register_origin(get_silo_origin())
def unregister_silo_origin():
@@ -582,9 +577,7 @@ def unregister_silo_origin():
"""
global _silo_origin
if _silo_origin:
try:
FreeCADGui.removeOrigin(_silo_origin)
FreeCAD.Console.PrintLog("Unregistered Silo origin\n")
except Exception as e:
FreeCAD.Console.PrintWarning(f"Could not unregister Silo origin: {e}\n")
from kindred_sdk import unregister_origin
unregister_origin(_silo_origin)
_silo_origin = None

View File

@@ -19,23 +19,10 @@ from PySide import QtCore, QtGui, QtWidgets
# ---------------------------------------------------------------------------
# Catppuccin Mocha palette
# ---------------------------------------------------------------------------
_MOCHA = {
"base": "#1e1e2e",
"mantle": "#181825",
"crust": "#11111b",
"surface0": "#313244",
"surface1": "#45475a",
"surface2": "#585b70",
"text": "#cdd6f4",
"subtext0": "#a6adc8",
"subtext1": "#bac2de",
"blue": "#89b4fa",
"green": "#a6e3a1",
"red": "#f38ba8",
"peach": "#fab387",
"lavender": "#b4befe",
"overlay0": "#6c7086",
}
# Catppuccin Mocha palette — sourced from kindred-addon-sdk
from kindred_sdk.theme import get_theme_tokens
_MOCHA = get_theme_tokens()
_PREF_GROUP = "User parameter:BaseApp/Preferences/Mod/KindredSilo"