Compare commits

...

9 Commits

Author SHA1 Message Date
Zoe Forbes
3fe43710fa fix(ui): remove addStretch from auth panel, use compact size policy
Replace layout.addStretch() with QSizePolicy.Maximum so the
Database Auth dock panel only takes the height its content needs,
leaving more vertical space for the Database Activity panel below.

Closes #190
2026-02-15 09:43:15 -06:00
Zoe Forbes
8cbd872e5c Merge branch 'feat/worker-client-ui' into main
Resolve conflicts in silo_commands.py:
- Keep event-based activity feed (_append_activity_event, _rebuild_activity_feed)
- Adapt DAG/job SSE handlers to use _append_activity_event
- Keep _relative_time formatting for activity entries
- Include DNS diagnostic IP display from feature branch
2026-02-15 08:32:55 -06: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
0f407360ed Merge pull request 'feat: use .kc extension for new files, find both .kc and .FCStd' (#23) from feat/kc-file-format-layer1 into main
Reviewed-on: #23
2026-02-13 19:42:03 +00:00
fa4f3145c6 Merge branch 'main' into feat/kc-file-format-layer1 2026-02-13 19:41:55 +00:00
d3e27010d8 Merge pull request 'feat: live SSE-based activity feed for Database Activity panel' (#22) from feat/live-activity-panel into main
Reviewed-on: #22
2026-02-13 01:57:03 +00:00
Zoe Forbes
d7c6066030 feat: live activity panel with SSE event feed and relative timestamps
Replace static item list refresh with real-time event feed:
- Add _relative_time() helper for human-friendly timestamps
- Prepend SSE events (item updates, new revisions, mode changes) instantly
- Seed feed with 10 recent items on first SSE connect (no per-item revision calls)
- Refresh relative timestamps every 60 seconds
- Cap activity feed at 50 events
- Remove expensive list_items + get_revisions calls on every SSE event
2026-02-12 17:27:25 -06:00
3 changed files with 668 additions and 60 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",
]

View File

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