From 1f8a52484412b48205e5ff6aac506428e2fb77ed Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Sat, 29 Aug 2026 18:42:43 +0200 Subject: feat(node): editable node settings in the Node page (D5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expose invite_ttl_hours, pair_ttl_hours, device_request_ttl_minutes, max_concurrent_streams and transcode_incompatible_video in the Node management panel. Changes are applied immediately via roster.db and written back to node.toml so they survive a DB wipe. On startup, roster overrides take precedence over node.toml defaults. Draft v6 §2.11 documents the design; MNP gains node_settings_set / node_settings_set_ack for the browser path. Co-Authored-By: Claude Opus 4.6 --- packages/meshbay-node/src/meshbay_node/ops.py | 115 +++++++++++++++++++++++++- 1 file changed, 114 insertions(+), 1 deletion(-) (limited to 'packages/meshbay-node/src/meshbay_node/ops.py') diff --git a/packages/meshbay-node/src/meshbay_node/ops.py b/packages/meshbay-node/src/meshbay_node/ops.py index c921b05..d1314f0 100644 --- a/packages/meshbay-node/src/meshbay_node/ops.py +++ b/packages/meshbay-node/src/meshbay_node/ops.py @@ -332,7 +332,19 @@ async def list_groups(state: dict) -> dict: members = await roster.list_members() has_operator = any(m["role"] == "operator" and m["status"] == "active" for m in members) - return {"groups": out, "operator_paired": has_operator} + nd = config.node if config else None + defaults = { + "invite_ttl_hours": nd.invite_ttl_hours if nd else 168, + "pair_ttl_hours": nd.pair_ttl_hours if nd else 24, + "device_request_ttl_minutes": nd.device_request_ttl_minutes if nd else 60, + "max_concurrent_streams": nd.max_concurrent_streams if nd else 8, + "transcode_incompatible_video": nd.transcode_incompatible_video if nd else True, + } + if roster: + settings = await roster.node_settings(defaults) + else: + settings = defaults + return {"groups": out, "operator_paired": has_operator, "settings": settings} async def attach_group(state: dict, name: str, shared_dir: str, @@ -472,6 +484,57 @@ def _find_group_range(lines: list[str], group_id: str) -> tuple[int, int] | None return None +def _update_node_toml(conf_path: Path, updates: dict) -> None: + """Write changed [node] settings back to node.toml without disturbing comments. + + For each key, if the line exists (commented or not) it is replaced in place; + otherwise the key is appended to the end of the [node] section. + """ + if not conf_path.exists(): + return + text = conf_path.read_text() + lines = text.split("\n") + + node_start = None + node_end = len(lines) + for i, line in enumerate(lines): + stripped = line.strip() + if stripped == "[node]": + node_start = i + elif node_start is not None and re.match(r'^\[', stripped): + node_end = i + break + + if node_start is None: + lines.append("") + lines.append("[node]") + node_start = len(lines) - 1 + node_end = len(lines) + + remaining = dict(updates) + for i in range(node_start + 1, node_end): + for key in list(remaining): + pattern = re.compile( + r'^(\s*#?\s*)' + re.escape(key) + r'\s*=\s*.*$') + if pattern.match(lines[i]): + value = remaining.pop(key) + if isinstance(value, bool): + lines[i] = f"{key} = {'true' if value else 'false'}" + else: + lines[i] = f"{key} = {value}" + break + + for key, value in remaining.items(): + if isinstance(value, bool): + insert_line = f"{key} = {'true' if value else 'false'}" + else: + insert_line = f"{key} = {value}" + lines.insert(node_end, insert_line) + node_end += 1 + + conf_path.write_text("\n".join(lines)) + + def _insert_roots_block(conf_path: Path, group_id: str, root_block: str) -> None: """Append a [[groups.roots]] block inside the matching [[groups]] section.""" @@ -718,6 +781,56 @@ async def set_member_upload(state: dict, group_id: str, allowed: bool) -> dict: return {"allowed": allowed, "group_id": group_id} +# ── Node settings ──────────────────────────────────────────────────────────── + +async def set_node_settings(state: dict, settings: dict) -> dict: + """Update node-level daemon settings. Writes to both roster.db and node.toml.""" + roster = _roster(state) + config = _config(state) + nd = config.node + conf_path = Path(state.get("config_path") or DEFAULT_CONFIG_PATH) + + allowed_keys = { + "invite_ttl_hours": ("int", roster.SETTING_INVITE_TTL), + "pair_ttl_hours": ("int", roster.SETTING_PAIR_TTL), + "device_request_ttl_minutes": ("int", roster.SETTING_DEVICE_TTL), + "max_concurrent_streams": ("int", roster.SETTING_MAX_STREAMS), + "transcode_incompatible_video": ("bool", roster.SETTING_TRANSCODE), + } + + set_by = state.get("node_user_id", "") + updated = {} + for key, value in settings.items(): + if key not in allowed_keys: + continue + kind, setting_key = allowed_keys[key] + if kind == "int": + try: + v = int(value) + except (TypeError, ValueError): + raise OpError(f"{key} must be an integer") + if v < 1: + raise OpError(f"{key} must be positive") + setattr(nd, key, v) + await roster.set_node_setting(setting_key, str(v), set_by) + updated[key] = v + elif kind == "bool": + v = bool(value) + setattr(nd, key, v) + await roster.set_node_setting(setting_key, "1" if v else "0", set_by) + updated[key] = v + + if updated: + _update_node_toml(conf_path, updated) + if "max_concurrent_streams" in updated: + webrtc = state.get("webrtc") + if webrtc and hasattr(webrtc, '_stream_sem'): + webrtc._stream_sem = asyncio.Semaphore(updated["max_concurrent_streams"]) + + log.info("Node settings updated: %s", updated) + return {"updated": updated} + + # ── Applications ───────────────────────────────────────────────────────────── async def set_enabled_apps(state: dict, group_id: str, apps: list[str]) -> dict: -- cgit v1.2.3