feat: reflect server mode in client UI

Add server mode awareness to the FreeCAD client. The mode (normal,
read-only, degraded, offline) is fetched from the /ready endpoint on
each status refresh and updated in real-time via SSE server.state events.

Changes:
- Add _server_mode global and _fetch_server_mode() helper that queries
  /ready and maps response status to mode string
- Add server_mode_changed signal to SiloEventListener, handle
  server.state SSE events in _dispatch()
- Add mode banner to SiloAuthDockWidget: colored bar shown when server
  is not in normal mode (yellow=read-only, orange=degraded, red=offline)
- Update _refresh_status() to fetch mode and update banner
- Disable write commands (New, Save, Commit, Push) when server is not
  in normal mode via IsActive() checks

Closes #4
This commit is contained in:
Zoe Forbes
2026-02-08 16:06:48 -06:00
parent bf0b84310b
commit 026ed0cb8a

View File

@@ -143,6 +143,39 @@ def _clear_auth():
_fc_settings.clear_auth()
# ---------------------------------------------------------------------------
# Server mode tracking
# ---------------------------------------------------------------------------
_server_mode = "offline" # "normal" | "read-only" | "degraded" | "offline"
def _fetch_server_mode() -> str:
"""Fetch server mode from the /ready endpoint.
Returns one of: "normal", "read-only", "degraded", "offline".
"""
api_url = _get_api_url().rstrip("/")
base_url = api_url[:-4] if api_url.endswith("/api") else api_url
url = f"{base_url}/ready"
try:
req = urllib.request.Request(url, method="GET")
resp = urllib.request.urlopen(req, context=_get_ssl_context(), timeout=10)
body = resp.read(4096).decode("utf-8", errors="replace")
data = json.loads(body)
status = data.get("status", "")
if status in ("ok", "ready"):
return "normal"
if status in ("read-only", "read_only", "readonly"):
return "read-only"
if status in ("degraded",):
return "degraded"
# Unknown status but server responded — treat as normal
return "normal"
except Exception:
return "offline"
# ---------------------------------------------------------------------------
# Icon helper
# ---------------------------------------------------------------------------
@@ -813,7 +846,7 @@ class Silo_New:
QtGui.QMessageBox.critical(None, "Error", str(e))
def IsActive(self):
return True
return _server_mode == "normal"
class Silo_Save:
@@ -920,7 +953,7 @@ class Silo_Save:
FreeCAD.Console.PrintMessage("File saved locally but not uploaded.\n")
def IsActive(self):
return FreeCAD.ActiveDocument is not None
return FreeCAD.ActiveDocument is not None and _server_mode == "normal"
class Silo_Commit:
@@ -973,7 +1006,7 @@ class Silo_Commit:
FreeCAD.Console.PrintError(f"Commit failed: {e}\n")
def IsActive(self):
return FreeCAD.ActiveDocument is not None
return FreeCAD.ActiveDocument is not None and _server_mode == "normal"
def _check_pull_conflicts(part_number, local_path, doc=None):
@@ -1345,7 +1378,7 @@ class Silo_Push:
QtGui.QMessageBox.information(None, "Push", f"Uploaded {uploaded} files.")
def IsActive(self):
return True
return _server_mode == "normal"
class Silo_Info:
@@ -2438,6 +2471,7 @@ class SiloEventListener(QtCore.QThread):
connection_status = QtCore.Signal(
str
) # "connected" / "disconnected" / "unsupported"
server_mode_changed = QtCore.Signal(str) # "normal" / "read-only" / "degraded"
_MAX_FAST_RETRIES = 3
_FAST_RETRY_SECS = 5
@@ -2538,6 +2572,12 @@ class SiloEventListener(QtCore.QThread):
except (json.JSONDecodeError, ValueError):
return
if event_type == "server.state":
mode = payload.get("mode", "")
if mode:
self.server_mode_changed.emit(mode)
return
pn = payload.get("part_number", "")
if not pn:
return
@@ -2645,6 +2685,13 @@ class SiloAuthDockWidget:
sse_row.addStretch()
layout.addLayout(sse_row)
# Server mode banner (hidden when normal)
self._mode_banner = QtGui.QLabel("")
self._mode_banner.setWordWrap(True)
self._mode_banner.setContentsMargins(6, 4, 6, 4)
self._mode_banner.setVisible(False)
layout.addWidget(self._mode_banner)
layout.addSpacing(4)
# Buttons
@@ -2734,6 +2781,14 @@ class SiloAuthDockWidget:
self._login_btn.setText("Login")
self._login_btn.clicked.connect(self._on_login_clicked)
# Fetch and display server mode
global _server_mode
if reachable:
_server_mode = _fetch_server_mode()
else:
_server_mode = "offline"
self._update_mode_banner()
# Manage SSE listener based on auth state
self._sync_event_listener(authed)
@@ -2747,6 +2802,7 @@ class SiloAuthDockWidget:
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.server_mode_changed.connect(self._on_server_mode)
self._event_listener.start()
else:
if self._event_listener is not None and self._event_listener.isRunning():
@@ -2764,6 +2820,35 @@ class SiloAuthDockWidget:
self._sse_label.setText("Not available")
self._sse_label.setStyleSheet("font-size: 11px; color: #888;")
def _on_server_mode(self, mode):
global _server_mode
_server_mode = mode
self._update_mode_banner()
def _update_mode_banner(self):
_MODE_BANNERS = {
"normal": ("", "", False),
"read-only": (
"Server is in read-only mode",
"background: #FFC107; color: #000; padding: 4px; font-size: 11px;",
True,
),
"degraded": (
"MinIO unavailable \u2014 file ops limited",
"background: #FF9800; color: #000; padding: 4px; font-size: 11px;",
True,
),
"offline": (
"Disconnected from silo",
"background: #F44336; color: #fff; padding: 4px; font-size: 11px;",
True,
),
}
text, style, visible = _MODE_BANNERS.get(_server_mode, _MODE_BANNERS["offline"])
self._mode_banner.setText(text)
self._mode_banner.setStyleSheet(style)
self._mode_banner.setVisible(visible)
def _on_remote_change(self, part_number):
FreeCAD.Console.PrintMessage(f"Silo: Part {part_number} updated on server\n")
mw = FreeCADGui.getMainWindow()