diff --git a/repeater/web/api_endpoints.py b/repeater/web/api_endpoints.py index d2bb2c0..c8f4dc5 100644 --- a/repeater/web/api_endpoints.py +++ b/repeater/web/api_endpoints.py @@ -2315,10 +2315,109 @@ class APIEndpoints: logger.error(f"Error updating advert rate limit config: {e}") return self._error(str(e)) + @staticmethod + def _read_ui_version(html_dir: str) -> Optional[str]: + """Best-effort version for a static UI tree (VERSION / version.json / package.json).""" + if not html_dir or not os.path.isdir(html_dir): + return None + version_file = os.path.join(html_dir, "VERSION") + if os.path.isfile(version_file): + try: + value = open(version_file, "r", encoding="utf-8").read().strip() + return value or None + except OSError: + pass + for name in ("version.json", "package.json"): + candidate = os.path.join(html_dir, name) + if not os.path.isfile(candidate): + continue + try: + import json as _json + + data = _json.loads(open(candidate, "r", encoding="utf-8").read()) + except (OSError, ValueError): + continue + if isinstance(data, dict): + ver = data.get("version") + if isinstance(ver, str) and ver.strip(): + return ver.strip() + elif isinstance(data, str) and data.strip(): + return data.strip() + return None + + def _plugin_ui_frontends(self) -> list: + """Installed application-UI plugins usable as primary web frontends.""" + out = [] + try: + from repeater.config import resolve_storage_dir + from repeater.plugins.storage import PluginStorage, resolve_plugins_root, safe_join + except Exception as exc: + logger.debug("plugin UI frontends unavailable: %s", exc) + return out + + try: + storage_dir = resolve_storage_dir(self.config, config_path=self._config_path) + plugins_root = resolve_plugins_root(self.config, storage_dir=storage_dir) + storage = PluginStorage(plugins_root) + except Exception as exc: + logger.debug("plugin storage resolve failed: %s", exc) + return out + + for plugin_id in storage.list_plugin_ids(): + try: + state = storage.read_state(plugin_id) or {} + manifest = storage.load_current_manifest(plugin_id) + if manifest is None or manifest.ui is None: + continue + paths = storage.paths_for(plugin_id) + if paths.current_link.exists() or paths.current_link.is_symlink(): + release_root = paths.current_link.resolve() + else: + version = state.get("version") or (manifest.version if manifest else None) + if not version: + continue + release_root = paths.release_dir(str(version)) + if not release_root.is_dir(): + continue + entry = str(manifest.ui.entry or "").replace("\\", "/") + entry_parts = tuple(p for p in entry.split("/") if p) + if not entry_parts: + continue + if len(entry_parts) > 1: + doc_root = safe_join(release_root, *entry_parts[:-1]) + entry_name = entry_parts[-1] + else: + doc_root = release_root + entry_name = entry_parts[0] + if not doc_root.is_dir(): + continue + index_path = doc_root / entry_name + available = bool(state.get("enabled", False)) and index_path.is_file() + version = str(state.get("version") or manifest.version or "") or None + out.append( + { + "id": f"plugin:{plugin_id}", + "plugin_id": plugin_id, + "kind": "plugin", + "name": manifest.name or plugin_id, + "description": (manifest.description or "Application UI plugin").strip() + or "Application UI plugin", + "path": str(doc_root), + "version": version, + "enabled": bool(state.get("enabled", False)), + "available": available, + "source": state.get("source") or "local", + } + ) + except Exception as exc: + logger.debug("skip plugin frontend %s: %s", plugin_id, exc) + continue + return out + @cherrypy.expose @cherrypy.tools.json_out() def check_pymc_console(self): - """Check if PyMC Console directory exists.""" + """Check if openHop Console directory exists (and report version when known).""" self._set_cors_headers() if cherrypy.request.method == "OPTIONS": @@ -2327,12 +2426,122 @@ class APIEndpoints: try: pymc_console_path = "/opt/pymc_console/web/html" exists = os.path.isdir(pymc_console_path) + version = self._read_ui_version(pymc_console_path) if exists else None - return self._success({"exists": exists, "path": pymc_console_path}) + return self._success( + { + "exists": exists, + "path": pymc_console_path, + "version": version, + } + ) except Exception as e: logger.error(f"Error checking PyMC Console directory: {e}") return self._error(str(e)) + @cherrypy.expose + @cherrypy.tools.json_out() + def web_frontends(self): + """List selectable primary web frontends (built-in, Console, UI plugins).""" + self._set_cors_headers() + + if cherrypy.request.method == "OPTIONS": + return "" + if cherrypy.request.method not in ("GET", "HEAD"): + raise cherrypy.HTTPError(405, "Method Not Allowed") + + try: + web_cfg = (self.config or {}).get("web") or {} + current_path = web_cfg.get("web_path") + if isinstance(current_path, str): + current_path = current_path.strip() or None + else: + current_path = None + + try: + from repeater import __version__ as repeater_version + except Exception: + repeater_version = None + + default_html = os.path.join(os.path.dirname(__file__), "html") + frontends = [ + { + "id": "builtin", + "kind": "builtin", + "name": "Default Frontend", + "description": "Built-in Repeater web interface", + "path": None, + "version": repeater_version, + "available": True, + "enabled": True, + } + ] + + console_path = "/opt/pymc_console/web/html" + console_exists = os.path.isdir(console_path) + frontends.append( + { + "id": "console", + "kind": "console", + "name": "openHop Console", + "description": "Alternative web interface for Repeater", + "path": console_path, + "version": self._read_ui_version(console_path) if console_exists else None, + "available": console_exists, + "enabled": console_exists, + } + ) + + frontends.extend(self._plugin_ui_frontends()) + + selected_id = "builtin" + if current_path: + # Prefer exact path match; fall back to path prefix for console. + matched = None + for item in frontends: + path = item.get("path") + if not path: + continue + if os.path.abspath(str(path)) == os.path.abspath(str(current_path)): + matched = item["id"] + break + if matched is None and ( + current_path == console_path + or str(current_path).startswith("/opt/pymc_console/") + ): + matched = "console" + if matched is None: + # Unknown custom path — surface as selected custom entry + frontends.append( + { + "id": "custom", + "kind": "custom", + "name": "Custom path", + "description": "Configured web_path not matching a known frontend", + "path": current_path, + "version": self._read_ui_version(current_path), + "available": os.path.isdir(current_path), + "enabled": True, + } + ) + matched = "custom" + selected_id = matched + + for item in frontends: + item["selected"] = item["id"] == selected_id + + return self._success( + { + "frontends": frontends, + "selected_id": selected_id, + "web_path": current_path, + "default_html_dir": default_html, + } + ) + except Exception as e: + logger.error(f"Error listing web frontends: {e}") + return self._error(str(e)) + @cherrypy.expose @cherrypy.tools.json_out() @cherrypy.tools.json_in() diff --git a/repeater/web/openapi.yaml b/repeater/web/openapi.yaml index 80e7fbe..ee9603a 100644 --- a/repeater/web/openapi.yaml +++ b/repeater/web/openapi.yaml @@ -3435,7 +3435,23 @@ paths: schema: type: object + /web_frontends: + get: + tags: [System] + summary: List selectable primary web frontends + description: > + Returns built-in Repeater UI, openHop Console (if installed), and enabled + application UI plugins that can be selected via web.web_path. + responses: + '200': + description: Frontend catalogue for the Web Frontend switcher + content: + application/json: + schema: + type: object + /check_pymc_console: + get: tags: [System] summary: Check pyMC console availability diff --git a/tests/test_web_frontends.py b/tests/test_web_frontends.py new file mode 100644 index 0000000..bea2d57 --- /dev/null +++ b/tests/test_web_frontends.py @@ -0,0 +1,49 @@ +"""web_frontends listing for primary UI switcher.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from repeater.web.api_endpoints import APIEndpoints + + +def test_read_ui_version_from_version_file(tmp_path: Path): + (tmp_path / "VERSION").write_text("9.9.9\n", encoding="utf-8") + assert APIEndpoints._read_ui_version(str(tmp_path)) == "9.9.9" + + +def test_web_frontends_builtin_and_plugin(tmp_path: Path, monkeypatch): + # Minimal plugin layout with UI + plugins_root = tmp_path / "plugins" + plugin_id = "openhop.demo" + release = plugins_root / plugin_id / "releases" / "1.2.3" + ui = release / "ui" + ui.mkdir(parents=True) + (ui / "index.html").write_text("", encoding="utf-8") + manifest = { + "schema": 1, + "id": plugin_id, + "name": "Demo UI", + "version": "1.2.3", + "description": "Demo", + "ui": {"type": "application", "entry": "ui/index.html"}, + } + (release / "openhop-plugin.json").write_text(json.dumps(manifest), encoding="utf-8") + (plugins_root / plugin_id / "state.json").write_text( + json.dumps({"id": plugin_id, "version": "1.2.3", "enabled": True, "source": "catalogue"}), + encoding="utf-8", + ) + current = plugins_root / plugin_id / "current" + current.symlink_to(Path("releases/1.2.3")) + + cfg = {"plugins": {"root": str(plugins_root)}, "web": {"web_path": None}} + api = APIEndpoints(config=cfg, config_path=str(tmp_path / "config.yaml")) + + # Avoid needing cherrypy request context for CORS; call internals + items = api._plugin_ui_frontends() + assert len(items) == 1 + assert items[0]["plugin_id"] == plugin_id + assert items[0]["version"] == "1.2.3" + assert items[0]["available"] is True + assert items[0]["path"].endswith("/ui")