feat(create): server integration for silo viewer widgets
All checks were successful
Build and Test / build (pull_request) Successful in 29m45s

Wire live data fetching, SSE subscriptions, and server write-back into
the History, Metadata, and Dependency viewer widgets.

Changes:
- Add server integration helpers (_init_server, _is_online,
  _get_part_number, offline banner) with lazy silo_commands import
- SiloHistoryViewer: Refresh button fetches live revisions via
  SiloClient.get_revisions(); SSE revision_created auto-refreshes
- SiloMetadataEditor: Save pushes to server (update_metadata,
  patch_lifecycle, patch_tags); SSE item_updated refreshes form
  when no local edits pending; offline banner
- SiloDependencyTable: Server-side UUID resolution via
  resolve_dependencies(); Download button for unresolved items;
  Refresh re-checks status; three-state icons (resolved/
  downloadable/missing)
- All viewers show 'Offline — showing cached data' banner when
  disconnected and disable server-dependent controls

Bump silo submodule to track new silo-client API methods:
  get_metadata, update_metadata, patch_lifecycle, patch_tags,
  resolve_dependencies (silo-client PR #19)

Closes kindred/silo-mod#43
This commit is contained in:
forbes
2026-02-18 19:36:04 -06:00
parent 40fac46862
commit 264e82179d
2 changed files with 367 additions and 40 deletions

View File

@@ -12,6 +12,63 @@ import json
import FreeCAD
from PySide import QtCore, QtWidgets
# ---------------------------------------------------------------------------
# Server integration — optional, degrades gracefully offline
# ---------------------------------------------------------------------------
_silo_client = None # SiloClient instance or None
_silo_server_mode = None # callable returning "normal"/"read-only"/"offline"
def _init_server():
"""Lazy-import silo_commands and cache client + mode accessor."""
global _silo_client, _silo_server_mode
if _silo_server_mode is not None:
return
try:
import silo_commands
_silo_client = silo_commands._client
_silo_server_mode = lambda: silo_commands._server_mode
except ImportError:
_silo_server_mode = lambda: "offline"
def _is_online():
"""Check if silo server is reachable."""
_init_server()
return _silo_server_mode() == "normal"
def _get_part_number(doc=None):
"""Get the Silo part number from the document's tracked object."""
if doc is None:
doc = FreeCAD.ActiveDocument
if doc is None:
return None
for obj in doc.Objects:
if hasattr(obj, "SiloPartNumber") and obj.SiloPartNumber:
return obj.SiloPartNumber
return None
def _make_offline_banner():
"""Create a hidden banner label for offline state."""
banner = QtWidgets.QLabel("Offline \u2014 showing cached data")
banner.setStyleSheet(
"QLabel { background: #f38ba8; color: #11111b; "
"padding: 4px 8px; border-radius: 4px; font-size: 11px; }"
)
banner.setVisible(False)
return banner
def _update_banner(banner):
"""Show banner if offline, hide if online."""
_init_server()
banner.setVisible(_silo_server_mode() != "normal")
# ---------------------------------------------------------------------------
# Manifest Viewer
# ---------------------------------------------------------------------------
@@ -133,7 +190,7 @@ _LIFECYCLE_OPTIONS = ["draft", "review", "released", "obsolete"]
class SiloMetadataEditor(QtWidgets.QWidget):
"""Editable form for ``silo/metadata.json`` fields."""
"""Editable form for ``silo/metadata.json`` with server write-back."""
WINDOW_TITLE = "Silo \u2014 Metadata"
@@ -147,8 +204,11 @@ class SiloMetadataEditor(QtWidgets.QWidget):
self._lifecycle_combo = None
self._save_btn = None
self._reset_btn = None
self._banner = None
self._event_listener = None
self._schema_name = ""
self._build_ui(obj.RawContent)
self._start_sse()
# -- layout --------------------------------------------------------------
@@ -185,6 +245,11 @@ class SiloMetadataEditor(QtWidgets.QWidget):
header.addWidget(self._lifecycle_combo)
outer.addLayout(header)
# Offline banner
self._banner = _make_offline_banner()
_update_banner(self._banner)
outer.addWidget(self._banner)
# Separator
line = QtWidgets.QFrame()
line.setFrameShape(QtWidgets.QFrame.HLine)
@@ -354,7 +419,7 @@ class SiloMetadataEditor(QtWidgets.QWidget):
}
def _on_save(self):
"""Write current form state to obj.RawContent."""
"""Write current form state to obj.RawContent and push to server."""
new_data = self._collect_data()
self._obj.RawContent = json.dumps(new_data, indent=2)
self._obj.Proxy.clear_dirty()
@@ -362,6 +427,26 @@ class SiloMetadataEditor(QtWidgets.QWidget):
self._save_btn.setEnabled(False)
self._reset_btn.setEnabled(False)
# Server write-back (best-effort)
pn = _get_part_number()
if pn and _is_online():
self._push_to_server(pn, new_data)
def _push_to_server(self, pn, data):
"""Push metadata changes to server. Failures are non-fatal."""
try:
_silo_client.update_metadata(pn, {"fields": data.get("fields", {})})
except Exception as exc:
FreeCAD.Console.PrintWarning(f"Metadata push failed: {exc}\n")
try:
_silo_client.patch_lifecycle(pn, data.get("lifecycle", "draft"))
except Exception as exc:
FreeCAD.Console.PrintWarning(f"Lifecycle push failed: {exc}\n")
try:
_silo_client.patch_tags(pn, data.get("tags", []))
except Exception as exc:
FreeCAD.Console.PrintWarning(f"Tags push failed: {exc}\n")
def _on_reset(self):
"""Revert all fields to last-saved state."""
# Clear existing field widgets and tags
@@ -405,9 +490,88 @@ class SiloMetadataEditor(QtWidgets.QWidget):
self._save_btn.setEnabled(False)
self._reset_btn.setEnabled(False)
# -- server integration --------------------------------------------------
def _rebuild_form(self, data):
"""Rebuild lifecycle, tags, and fields from a data dict."""
self._schema_name = data.get("schema", self._schema_name)
# Lifecycle
lc = data.get("lifecycle", "draft")
idx = self._lifecycle_combo.findText(lc)
if idx >= 0:
self._lifecycle_combo.blockSignals(True)
self._lifecycle_combo.setCurrentIndex(idx)
self._lifecycle_combo.blockSignals(False)
# Tags — clear and rebuild
for i in reversed(range(self._tags_layout.count())):
w = self._tags_layout.itemAt(i).widget()
if w:
w.deleteLater()
for tag in data.get("tags", []):
self._add_tag_chip(tag)
# Fields — find QGroupBox and rebuild
self._field_widgets.clear()
for child in self.findChildren(QtWidgets.QGroupBox):
if child.title() == "Fields":
form = child.layout()
while form.count():
item = form.takeAt(0)
if item.widget():
item.widget().deleteLater()
for key, value in data.get("fields", {}).items():
widget = self._make_field_widget(value)
self._field_widgets[key] = widget
form.addRow(key + ":", widget)
break
def _start_sse(self):
if not _is_online():
return
try:
import silo_commands
self._event_listener = silo_commands.SiloEventListener()
self._event_listener.item_updated.connect(self._on_sse_item_updated)
self._event_listener.connection_status.connect(self._on_sse_status)
self._event_listener.start()
except Exception:
pass
def _on_sse_item_updated(self, part_number):
pn = _get_part_number()
if not pn or pn != part_number:
return
if self._obj.Proxy.is_dirty():
FreeCAD.Console.PrintWarning(
"Metadata: remote update received but local edits exist. "
"Save or reset to sync.\n"
)
return
try:
server_data = _silo_client.get_metadata(pn)
local_data = {
"schema": server_data.get("schema_name", self._schema_name),
"lifecycle": server_data.get("lifecycle_state", "draft"),
"tags": server_data.get("tags", []),
"fields": server_data.get("fields", {}),
}
self._obj.RawContent = json.dumps(local_data, indent=2)
self._original_data = copy.deepcopy(local_data)
self._rebuild_form(local_data)
except Exception as exc:
FreeCAD.Console.PrintWarning(f"Metadata SSE refresh failed: {exc}\n")
def _on_sse_status(self, status, retry_count, error_msg):
_update_banner(self._banner)
# -- close guard ---------------------------------------------------------
def closeEvent(self, event):
if self._event_listener is not None:
self._event_listener.stop()
if self._obj.Proxy.is_dirty():
reply = QtWidgets.QMessageBox.question(
self,
@@ -436,14 +600,20 @@ _BADGE_STYLES = {
class SiloHistoryViewer(QtWidgets.QWidget):
"""Read-only revision timeline from ``silo/history.json``."""
"""Revision timeline from ``silo/history.json`` with live refresh."""
WINDOW_TITLE = "Silo \u2014 History"
def __init__(self, obj, parent=None):
super().__init__(parent)
self.setObjectName(f"SiloViewer_{obj.Name}")
self._obj = obj
self._cards_layout = None
self._banner = None
self._refresh_btn = None
self._event_listener = None
self._build_ui(obj.RawContent)
self._start_sse()
def _build_ui(self, raw_content):
try:
@@ -465,42 +635,112 @@ class SiloHistoryViewer(QtWidgets.QWidget):
title.setFont(font)
header.addWidget(title)
header.addStretch()
self._refresh_btn = QtWidgets.QPushButton("Refresh")
self._refresh_btn.setEnabled(_is_online())
self._refresh_btn.clicked.connect(self._on_refresh)
header.addWidget(self._refresh_btn)
outer.addLayout(header)
# Offline banner
self._banner = _make_offline_banner()
_update_banner(self._banner)
outer.addWidget(self._banner)
# Separator
line = QtWidgets.QFrame()
line.setFrameShape(QtWidgets.QFrame.HLine)
line.setFrameShadow(QtWidgets.QFrame.Sunken)
outer.addWidget(line)
if not revisions:
placeholder = QtWidgets.QLabel("No revision history available.")
placeholder.setAlignment(QtCore.Qt.AlignCenter)
outer.addWidget(placeholder)
outer.addStretch()
return
# Scroll area with revision cards
scroll = QtWidgets.QScrollArea()
scroll.setWidgetResizable(True)
scroll.setFrameShape(QtWidgets.QFrame.NoFrame)
content = QtWidgets.QWidget()
cards_layout = QtWidgets.QVBoxLayout(content)
cards_layout.setContentsMargins(0, 0, 0, 0)
cards_layout.setSpacing(0)
self._cards_layout = QtWidgets.QVBoxLayout(content)
self._cards_layout.setContentsMargins(0, 0, 0, 0)
self._cards_layout.setSpacing(0)
if not revisions:
placeholder = QtWidgets.QLabel("No revision history available.")
placeholder.setAlignment(QtCore.Qt.AlignCenter)
self._cards_layout.addWidget(placeholder)
for i, rev in enumerate(revisions):
cards_layout.addWidget(self._make_revision_card(rev))
self._cards_layout.addWidget(self._make_revision_card(rev))
if i < len(revisions) - 1:
sep = QtWidgets.QFrame()
sep.setFrameShape(QtWidgets.QFrame.HLine)
sep.setFrameShadow(QtWidgets.QFrame.Sunken)
cards_layout.addWidget(sep)
self._cards_layout.addWidget(sep)
cards_layout.addStretch()
self._cards_layout.addStretch()
scroll.setWidget(content)
outer.addWidget(scroll, 1)
# -- server integration --------------------------------------------------
def _on_refresh(self):
pn = _get_part_number()
if not pn or not _is_online():
return
try:
revisions = _silo_client.get_revisions(pn)
except Exception as exc:
FreeCAD.Console.PrintWarning(f"History refresh failed: {exc}\n")
return
data = {"revisions": revisions}
self._obj.RawContent = json.dumps(data, indent=2)
self._obj.Proxy.mark_dirty()
self._rebuild_cards(revisions)
def _rebuild_cards(self, revisions):
"""Clear and rebuild revision cards."""
while self._cards_layout.count():
item = self._cards_layout.takeAt(0)
if item.widget():
item.widget().deleteLater()
if not revisions:
placeholder = QtWidgets.QLabel("No revision history available.")
placeholder.setAlignment(QtCore.Qt.AlignCenter)
self._cards_layout.addWidget(placeholder)
else:
for i, rev in enumerate(revisions):
self._cards_layout.addWidget(self._make_revision_card(rev))
if i < len(revisions) - 1:
sep = QtWidgets.QFrame()
sep.setFrameShape(QtWidgets.QFrame.HLine)
sep.setFrameShadow(QtWidgets.QFrame.Sunken)
self._cards_layout.addWidget(sep)
self._cards_layout.addStretch()
def _start_sse(self):
if not _is_online():
return
try:
import silo_commands
self._event_listener = silo_commands.SiloEventListener()
self._event_listener.revision_created.connect(self._on_sse_revision)
self._event_listener.connection_status.connect(self._on_sse_status)
self._event_listener.start()
except Exception:
pass
def _on_sse_revision(self, part_number, revision):
pn = _get_part_number()
if pn and pn == part_number:
self._on_refresh()
def _on_sse_status(self, status, retry_count, error_msg):
_update_banner(self._banner)
self._refresh_btn.setEnabled(_is_online())
def closeEvent(self, event):
if self._event_listener is not None:
self._event_listener.stop()
event.accept()
def _make_revision_card(self, rev):
"""Build a widget for a single revision entry."""
card = QtWidgets.QWidget()
@@ -697,13 +937,18 @@ def _open_url(url):
class SiloDependencyTable(QtWidgets.QWidget):
"""Table view of assembly dependencies from ``silo/dependencies.json``."""
"""Table view of assembly dependencies with server resolution."""
WINDOW_TITLE = "Silo \u2014 Dependencies"
def __init__(self, obj, parent=None):
super().__init__(parent)
self.setObjectName(f"SiloViewer_{obj.Name}")
self._obj = obj
self._links = []
self._model = None
self._table = None
self._banner = None
self._build_ui(obj.RawContent)
def _build_ui(self, raw_content):
@@ -711,65 +956,147 @@ class SiloDependencyTable(QtWidgets.QWidget):
data = json.loads(raw_content)
except Exception:
data = {}
links = data.get("links", [])
self._links = data.get("links", [])
outer = QtWidgets.QVBoxLayout(self)
outer.setContentsMargins(16, 16, 16, 16)
outer.setSpacing(12)
# Header
# Header with buttons
header = QtWidgets.QHBoxLayout()
title = QtWidgets.QLabel("Assembly Dependencies")
font = title.font()
font.setPointSize(font.pointSize() + 2)
font.setBold(True)
title.setFont(font)
outer.addWidget(title)
header.addWidget(title)
header.addStretch()
self._refresh_btn = QtWidgets.QPushButton("Refresh")
self._refresh_btn.setEnabled(_is_online())
self._refresh_btn.clicked.connect(self._on_refresh)
header.addWidget(self._refresh_btn)
self._download_btn = QtWidgets.QPushButton("Download")
self._download_btn.setEnabled(_is_online())
self._download_btn.setToolTip("Download selected unresolved dependencies")
self._download_btn.clicked.connect(self._on_download)
header.addWidget(self._download_btn)
outer.addLayout(header)
# Offline banner
self._banner = _make_offline_banner()
_update_banner(self._banner)
outer.addWidget(self._banner)
sep = QtWidgets.QFrame()
sep.setFrameShape(QtWidgets.QFrame.HLine)
sep.setFrameShadow(QtWidgets.QFrame.Sunken)
outer.addWidget(sep)
if not links:
if not self._links:
outer.addWidget(QtWidgets.QLabel("No dependencies defined."))
outer.addStretch()
return
# Table
from PySide.QtGui import QStandardItemModel, QStandardItem
from PySide.QtGui import QStandardItem, QStandardItemModel
table = QtWidgets.QTableView()
model = QStandardItemModel(len(links), 5)
model.setHorizontalHeaderLabels(["Label", "Part Number", "Rev", "Qty", "Local"])
self._table = QtWidgets.QTableView()
self._model = QStandardItemModel(len(self._links), 5)
self._model.setHorizontalHeaderLabels(
["Label", "Part Number", "Rev", "Qty", "Status"]
)
for row, link in enumerate(links):
# Try server-side resolution first
server_map = self._resolve_via_server()
for row, link in enumerate(self._links):
label_item = QStandardItem(link.get("label", ""))
label_item.setEditable(False)
model.setItem(row, 0, label_item)
self._model.setItem(row, 0, label_item)
pn_item = QStandardItem(link.get("part_number", ""))
pn_item.setEditable(False)
model.setItem(row, 1, pn_item)
self._model.setItem(row, 1, pn_item)
rev_item = QStandardItem(str(link.get("revision", "")))
rev_item.setEditable(False)
model.setItem(row, 2, rev_item)
self._model.setItem(row, 2, rev_item)
qty_item = QStandardItem(str(link.get("quantity", "")))
qty_item.setEditable(False)
model.setItem(row, 3, qty_item)
self._model.setItem(row, 3, qty_item)
# Resolution: check if UUID is in any open document
resolved = _is_dependency_resolved(link.get("uuid", ""))
status_item = QStandardItem("\u2713" if resolved else "\u2717")
uuid = link.get("uuid", "")
icon = self._status_icon(uuid, server_map)
status_item = QStandardItem(icon)
status_item.setEditable(False)
model.setItem(row, 4, status_item)
self._model.setItem(row, 4, status_item)
table.setModel(model)
table.horizontalHeader().setStretchLastSection(True)
table.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows)
table.setEditTriggers(QtWidgets.QAbstractItemView.NoEditTriggers)
outer.addWidget(table, 1)
self._table.setModel(self._model)
self._table.horizontalHeader().setStretchLastSection(True)
self._table.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows)
self._table.setEditTriggers(QtWidgets.QAbstractItemView.NoEditTriggers)
outer.addWidget(self._table, 1)
# -- server integration --------------------------------------------------
def _resolve_via_server(self):
"""Use server-side resolution if online, else return None."""
pn = _get_part_number()
if pn and _is_online():
try:
resolved = _silo_client.resolve_dependencies(pn)
return {r["uuid"]: r for r in resolved}
except Exception as exc:
FreeCAD.Console.PrintWarning(f"Dependency resolve failed: {exc}\n")
return None
def _status_icon(self, uuid, server_map):
"""Return status icon for a dependency UUID."""
if server_map and uuid in server_map:
r = server_map[uuid]
if r.get("resolved", False):
return "\u2713" # check mark
if r.get("file_available", False):
return "\u2193" # down arrow — downloadable
return "\u2717" # cross mark
return "\u2713" if _is_dependency_resolved(uuid) else "\u2717"
def _on_refresh(self):
"""Re-resolve dependencies and update status column."""
if self._model is None:
return
server_map = self._resolve_via_server()
for row, link in enumerate(self._links):
uuid = link.get("uuid", "")
icon = self._status_icon(uuid, server_map)
item = self._model.item(row, 4)
if item:
item.setText(icon)
def _on_download(self):
"""Download selected unresolved dependencies."""
if self._table is None:
return
indexes = self._table.selectionModel().selectedRows()
if not indexes:
return
for idx in indexes:
pn_item = self._model.item(idx.row(), 1)
pn = pn_item.text() if pn_item else ""
if not pn:
continue
try:
import silo_commands
path = silo_commands._sync.download_file(pn)
if path:
FreeCAD.Console.PrintMessage(f"Downloaded: {path}\n")
except Exception as exc:
FreeCAD.Console.PrintWarning(f"Download {pn} failed: {exc}\n")
self._on_refresh()
def _is_dependency_resolved(uuid_str):