feat(client): add SSE live updates, revision pull dialog, and auth dock support

- Add SiloEventListener class for SSE-based real-time notifications
- Add SiloPullDialog for revision selection when pulling
- Add conflict detection before pull (unsaved changes, stale revision, mtime)
- Add progress callback to _download_file() for UI feedback
- Integrate SSE listener into SiloAuthDockWidget with status indicator
- Refresh activity panel on remote change events
- Add top-level PySide.QtCore import for QThread/Signal usage
This commit is contained in:
forbes
2026-02-01 16:26:02 -06:00
parent 3a67d2082b
commit 17a10ab1b6

View File

@@ -12,6 +12,7 @@ from typing import Any, Dict, List, Optional, Tuple
import FreeCAD
import FreeCADGui
from PySide import QtCore
# Preference group for Kindred Silo settings
_PREF_GROUP = "User parameter:BaseApp/Preferences/Mod/KindredSilo"
@@ -383,19 +384,35 @@ class SiloClient:
except urllib.error.URLError as e:
raise RuntimeError(f"Connection error: {e.reason}")
def _download_file(self, part_number: str, revision: int, dest_path: str) -> bool:
"""Download a file from MinIO storage."""
def _download_file(
self,
part_number: str,
revision: int,
dest_path: str,
progress_callback=None,
) -> bool:
"""Download a file from MinIO storage.
Args:
progress_callback: Optional callable(bytes_downloaded, total_bytes).
total_bytes is -1 if the server did not send Content-Length.
"""
url = f"{self.base_url}/items/{part_number}/file/{revision}"
req = urllib.request.Request(url, headers=_get_auth_headers(), method="GET")
try:
with urllib.request.urlopen(req, context=_get_ssl_context()) as resp:
total = int(resp.headers.get("Content-Length", -1))
downloaded = 0
with open(dest_path, "wb") as f:
while True:
chunk = resp.read(8192)
if not chunk:
break
f.write(chunk)
downloaded += len(chunk)
if progress_callback is not None:
progress_callback(downloaded, total)
return True
except urllib.error.HTTPError as e:
if e.code == 404:
@@ -1675,13 +1692,154 @@ class Silo_Commit:
return FreeCAD.ActiveDocument is not None
def _check_pull_conflicts(part_number, local_path, doc=None):
"""Check for conflicts between local file and server state.
Returns a list of conflict description strings, or an empty list if clean.
"""
conflicts = []
# Check for unsaved changes in an open document
if doc is not None and doc.IsModified():
conflicts.append("Document has unsaved local changes.")
# Check local revision vs server latest
if doc is not None:
obj = get_tracked_object(doc)
if obj and hasattr(obj, "SiloRevision"):
local_rev = getattr(obj, "SiloRevision", 0)
latest = _client.latest_file_revision(part_number)
if latest and local_rev and latest["revision_number"] > local_rev:
conflicts.append(
f"Local file is at revision {local_rev}, "
f"server has revision {latest['revision_number']}."
)
# Check local file mtime vs server timestamp
if local_path and local_path.exists():
import datetime
local_mtime = datetime.datetime.fromtimestamp(
local_path.stat().st_mtime, tz=datetime.timezone.utc
)
try:
item = _client.get_item(part_number)
server_updated = item.get("updated_at", "")
if server_updated:
# Parse ISO format timestamp
server_dt = datetime.datetime.fromisoformat(
server_updated.replace("Z", "+00:00")
)
if server_dt > local_mtime:
conflicts.append("Server version is newer than local file.")
except Exception:
pass
return conflicts
class SiloPullDialog:
"""Dialog for selecting which revision to pull."""
def __init__(self, part_number, revisions, parent=None):
from PySide import QtCore, QtGui
self._selected_revision = None
self._dialog = QtGui.QDialog(parent)
self._dialog.setWindowTitle(f"Pull - {part_number}")
self._dialog.setMinimumWidth(600)
self._dialog.setMinimumHeight(350)
layout = QtGui.QVBoxLayout(self._dialog)
info = QtGui.QLabel(f"Select a revision to download for {part_number}:")
layout.addWidget(info)
# Revision table
self._table = QtGui.QTableWidget()
self._table.setColumnCount(5)
self._table.setHorizontalHeaderLabels(
["Rev", "Date", "Comment", "Status", "File"]
)
self._table.setSelectionBehavior(QtGui.QAbstractItemView.SelectRows)
self._table.setSelectionMode(QtGui.QAbstractItemView.SingleSelection)
self._table.setEditTriggers(QtGui.QAbstractItemView.NoEditTriggers)
self._table.verticalHeader().setVisible(False)
header = self._table.horizontalHeader()
header.setStretchLastSection(True)
# Populate rows
file_revisions = []
self._table.setRowCount(len(revisions))
for i, rev in enumerate(revisions):
rev_num = rev.get("revision_number", "")
date = rev.get("created_at", "")[:16].replace("T", " ")
comment = rev.get("comment", "")
status = rev.get("status", "")
has_file = "\u2713" if rev.get("file_key") else ""
self._table.setItem(i, 0, QtGui.QTableWidgetItem(str(rev_num)))
self._table.setItem(i, 1, QtGui.QTableWidgetItem(date))
self._table.setItem(i, 2, QtGui.QTableWidgetItem(comment))
self._table.setItem(i, 3, QtGui.QTableWidgetItem(status))
file_item = QtGui.QTableWidgetItem(has_file)
file_item.setTextAlignment(QtCore.Qt.AlignCenter)
self._table.setItem(i, 4, file_item)
if rev.get("file_key"):
file_revisions.append(i)
self._table.resizeColumnsToContents()
layout.addWidget(self._table)
# Pre-select the latest revision with a file
if file_revisions:
self._table.selectRow(file_revisions[0])
# Store revision data for lookup
self._revisions = revisions
# Buttons
btn_layout = QtGui.QHBoxLayout()
btn_layout.addStretch()
download_btn = QtGui.QPushButton("Download")
cancel_btn = QtGui.QPushButton("Cancel")
download_btn.clicked.connect(self._on_download)
cancel_btn.clicked.connect(self._dialog.reject)
self._table.doubleClicked.connect(self._on_download)
btn_layout.addWidget(download_btn)
btn_layout.addWidget(cancel_btn)
layout.addLayout(btn_layout)
def _on_download(self):
row = self._table.currentRow()
if row < 0:
return
rev = self._revisions[row]
if not rev.get("file_key"):
from PySide import QtGui
QtGui.QMessageBox.information(
self._dialog, "Pull", "Selected revision has no file attached."
)
return
self._selected_revision = rev
self._dialog.accept()
def exec_(self):
if self._dialog.exec_() == 1: # QDialog.Accepted
return self._selected_revision
return None
class Silo_Pull:
"""Download from MinIO / sync from database."""
def GetResources(self):
return {
"MenuText": "Pull",
"ToolTip": "Download latest from MinIO or create from database",
"ToolTip": "Download from MinIO with revision selection",
"Pixmap": _icon("pull"),
}
@@ -1690,6 +1848,7 @@ class Silo_Pull:
doc = FreeCAD.ActiveDocument
part_number = None
obj = None
if doc:
obj = get_tracked_object(doc)
@@ -1702,43 +1861,25 @@ class Silo_Pull:
return
part_number = part_number.strip().upper()
# Check if local file exists
# Fetch revisions from server
try:
revisions = _client.get_revisions(part_number)
except Exception as e:
QtGui.QMessageBox.warning(None, "Pull", f"Cannot reach server: {e}")
return
existing_local = find_file_by_part_number(part_number)
# Check if file exists in MinIO
has_file, rev_num = _client.has_file(part_number)
# If no revisions have files, fall back to create-from-database
has_any_file = any(r.get("file_key") for r in revisions)
if has_file:
# File exists in MinIO
if not has_any_file:
if existing_local:
# Local file exists - ask before overwriting
reply = QtGui.QMessageBox.question(
None,
"Pull",
f"Download revision {rev_num} and overwrite local file?",
QtGui.QMessageBox.Yes | QtGui.QMessageBox.No,
)
if reply != QtGui.QMessageBox.Yes:
return
# Download from MinIO (creates local file automatically)
downloaded = _sync.download_file(part_number)
if downloaded:
FreeCAD.Console.PrintMessage(f"Downloaded: {downloaded}\n")
# Automatically open the downloaded file
FreeCAD.openDocument(str(downloaded))
else:
QtGui.QMessageBox.warning(None, "Pull", "Download failed")
else:
# No file in MinIO - create from database
if existing_local:
# Local file already exists, just open it
FreeCAD.Console.PrintMessage(
f"Opening existing local file: {existing_local}\n"
)
FreeCAD.openDocument(str(existing_local))
else:
# No local file and no MinIO file - create new from DB
try:
item = _client.get_item(part_number)
new_doc = _sync.create_document_for_item(item, save=True)
@@ -1748,10 +1889,93 @@ class Silo_Pull:
)
else:
QtGui.QMessageBox.warning(
None, "Pull", f"Failed to create document for {part_number}"
None,
"Pull",
f"Failed to create document for {part_number}",
)
except Exception as e:
QtGui.QMessageBox.warning(None, "Pull", f"Failed: {e}")
return
# Conflict detection
conflicts = _check_pull_conflicts(part_number, existing_local, doc)
if conflicts:
detail = "\n".join(f" - {c}" for c in conflicts)
reply = QtGui.QMessageBox.warning(
None,
"Pull - Conflicts Detected",
f"Potential conflicts found:\n{detail}\n\n"
"Download anyway and overwrite local file?",
QtGui.QMessageBox.Yes | QtGui.QMessageBox.No,
)
if reply != QtGui.QMessageBox.Yes:
return
# Show revision selection dialog
dlg = SiloPullDialog(part_number, revisions)
selected = dlg.exec_()
if selected is None:
return
rev_num = selected["revision_number"]
# Determine destination path
try:
item = _client.get_item(part_number)
except Exception:
item = {}
dest_path = get_cad_file_path(part_number, item.get("description", ""))
dest_path.parent.mkdir(parents=True, exist_ok=True)
# Download with progress
progress = QtGui.QProgressDialog(
f"Downloading {part_number} rev {rev_num}...", "Cancel", 0, 100
)
progress.setWindowModality(2) # Qt.WindowModal
progress.setMinimumDuration(0)
progress.setValue(0)
def on_progress(downloaded, total):
if progress.wasCanceled():
return
if total > 0:
pct = int(downloaded * 100 / total)
progress.setValue(min(pct, 99))
else:
# Indeterminate - pulse between 0-90
progress.setValue(min(int(downloaded / 1024) % 90, 89))
QtGui.QApplication.processEvents()
try:
ok = _client._download_file(
part_number, rev_num, str(dest_path), progress_callback=on_progress
)
except Exception as e:
progress.close()
QtGui.QMessageBox.warning(None, "Pull", f"Download failed: {e}")
return
progress.setValue(100)
progress.close()
if not ok:
QtGui.QMessageBox.warning(None, "Pull", "Download failed.")
return
FreeCAD.Console.PrintMessage(f"Pulled revision {rev_num} of {part_number}\n")
# Close existing document if open, then reopen
if doc and doc.FileName == str(dest_path):
FreeCAD.closeDocument(doc.Name)
FreeCAD.openDocument(str(dest_path))
# Update SiloRevision property on the tracked object
new_doc = FreeCAD.ActiveDocument
if new_doc:
new_obj = get_tracked_object(new_doc)
if new_obj and hasattr(new_obj, "SiloRevision"):
new_obj.SiloRevision = rev_num
new_doc.save()
def IsActive(self):
return True
@@ -2914,6 +3138,139 @@ class Silo_ToggleMode:
return True
# ---------------------------------------------------------------------------
# SSE live-update listener
# ---------------------------------------------------------------------------
class SiloEventListener(QtCore.QThread):
"""Background thread that listens to Server-Sent Events from the Silo API.
Emits Qt signals when items are updated or new revisions are created.
Degrades gracefully if the server does not support the ``/api/events``
endpoint.
"""
item_updated = QtCore.Signal(str) # part_number
revision_created = QtCore.Signal(str, int) # part_number, revision
connection_status = QtCore.Signal(
str
) # "connected" / "disconnected" / "unsupported"
_MAX_FAST_RETRIES = 3
_FAST_RETRY_SECS = 5
_SLOW_RETRY_SECS = 30
def __init__(self, parent=None):
super().__init__(parent)
self._stop_flag = False
self._response = None
# -- public API ---------------------------------------------------------
def stop(self):
self._stop_flag = True
# Close the socket so readline() unblocks immediately
try:
if self._response is not None:
self._response.close()
except Exception:
pass
self.wait(5000)
# -- thread entry -------------------------------------------------------
def run(self):
retries = 0
while not self._stop_flag:
try:
self._listen()
# _listen returns normally only on clean EOF / stop
if self._stop_flag:
return
retries += 1
except _SSEUnsupported:
self.connection_status.emit("unsupported")
return
except Exception:
retries += 1
self.connection_status.emit("disconnected")
if retries <= self._MAX_FAST_RETRIES:
delay = self._FAST_RETRY_SECS
else:
delay = self._SLOW_RETRY_SECS
# Interruptible sleep
for _ in range(delay):
if self._stop_flag:
return
self.msleep(1000)
# -- SSE stream reader --------------------------------------------------
def _listen(self):
url = f"{_get_api_url().rstrip('/')}/api/events"
headers = {"Accept": "text/event-stream"}
headers.update(_get_auth_headers())
req = urllib.request.Request(url, headers=headers, method="GET")
try:
self._response = urllib.request.urlopen(
req, context=_get_ssl_context(), timeout=90
)
except urllib.error.HTTPError as e:
if e.code in (404, 501):
raise _SSEUnsupported()
raise
except urllib.error.URLError:
raise
self.connection_status.emit("connected")
event_type = ""
data_buf = ""
for raw_line in self._response:
if self._stop_flag:
return
line = raw_line.decode("utf-8", errors="replace").rstrip("\r\n")
if line == "":
# Blank line = dispatch event
if data_buf:
self._dispatch(event_type or "message", data_buf.strip())
event_type = ""
data_buf = ""
elif line.startswith("event:"):
event_type = line[6:].strip()
elif line.startswith("data:"):
data_buf += line[5:].strip() + "\n"
# Ignore comments (lines starting with ':') and other fields
def _dispatch(self, event_type, data):
try:
payload = json.loads(data)
except (json.JSONDecodeError, ValueError):
return
pn = payload.get("part_number", "")
if not pn:
return
if event_type in ("item_updated", "message"):
self.item_updated.emit(pn)
elif event_type == "revision_created":
rev = payload.get("revision", 0)
self.revision_created.emit(pn, int(rev))
class _SSEUnsupported(Exception):
"""Raised when the server does not support the SSE endpoint."""
# ---------------------------------------------------------------------------
# Auth dock widget
# ---------------------------------------------------------------------------
@@ -2926,6 +3283,7 @@ class SiloAuthDockWidget:
from PySide import QtCore, QtGui
self.widget = QtGui.QWidget()
self._event_listener = None
self._build_ui()
self._refresh_status()
@@ -2993,6 +3351,20 @@ class SiloAuthDockWidget:
layout.addSpacing(4)
# Live updates row
sse_row = QtGui.QHBoxLayout()
sse_row.setSpacing(6)
sse_lbl = QtGui.QLabel("Live:")
sse_lbl.setStyleSheet("color: #888;")
self._sse_label = QtGui.QLabel("")
self._sse_label.setStyleSheet("font-size: 11px;")
sse_row.addWidget(sse_lbl)
sse_row.addWidget(self._sse_label)
sse_row.addStretch()
layout.addLayout(sse_row)
layout.addSpacing(4)
# Buttons
btn_row = QtGui.QHBoxLayout()
btn_row.setSpacing(6)
@@ -3080,6 +3452,83 @@ class SiloAuthDockWidget:
self._login_btn.setText("Login")
self._login_btn.clicked.connect(self._on_login_clicked)
# Manage SSE listener based on auth state
self._sync_event_listener(authed)
# -- SSE listener management --------------------------------------------
def _sync_event_listener(self, authed):
"""Start or stop the SSE listener depending on authentication state."""
if authed:
if self._event_listener is None or not self._event_listener.isRunning():
self._event_listener = SiloEventListener()
self._event_listener.item_updated.connect(self._on_remote_change)
self._event_listener.revision_created.connect(self._on_remote_revision)
self._event_listener.connection_status.connect(self._on_sse_status)
self._event_listener.start()
else:
if self._event_listener is not None and self._event_listener.isRunning():
self._event_listener.stop()
self._sse_label.setText("")
def _on_sse_status(self, status):
if status == "connected":
self._sse_label.setText("Listening")
self._sse_label.setStyleSheet("font-size: 11px; color: #4CAF50;")
elif status == "disconnected":
self._sse_label.setText("Reconnecting...")
self._sse_label.setStyleSheet("font-size: 11px; color: #FF9800;")
elif status == "unsupported":
self._sse_label.setText("Not available")
self._sse_label.setStyleSheet("font-size: 11px; color: #888;")
def _on_remote_change(self, part_number):
FreeCAD.Console.PrintMessage(f"Silo: Part {part_number} updated on server\n")
mw = FreeCADGui.getMainWindow()
if mw is not None:
mw.statusBar().showMessage(f"Silo: {part_number} updated on server", 5000)
self._refresh_activity_panel()
def _on_remote_revision(self, part_number, revision):
FreeCAD.Console.PrintMessage(
f"Silo: New revision {revision} for {part_number}\n"
)
mw = FreeCADGui.getMainWindow()
if mw is not None:
mw.statusBar().showMessage(
f"Silo: {part_number} rev {revision} available", 5000
)
self._refresh_activity_panel()
def _refresh_activity_panel(self):
"""Refresh the Database Activity panel if it exists."""
from PySide import 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
activity_list.clear()
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]
activity_list.addItem(f"{pn} - {desc} - {updated}")
if activity_list.count() == 0:
activity_list.addItem("(No items in database)")
except Exception:
activity_list.addItem("(Unable to refresh activity)")
# -- Actions ------------------------------------------------------------
def _on_login_clicked(self):