diff options
Diffstat (limited to 'packages')
19 files changed, 492 insertions, 1 deletions
diff --git a/packages/meshbay-common/src/meshbay_common/protocol.py b/packages/meshbay-common/src/meshbay_common/protocol.py index a3fb4de..ff951e7 100644 --- a/packages/meshbay-common/src/meshbay_common/protocol.py +++ b/packages/meshbay-common/src/meshbay_common/protocol.py @@ -166,6 +166,8 @@ class MNP: GROUP_ATTACH_ACK = "group_attach_ack" GROUP_DETACH = "group_detach" # operator → node: stop hosting a group GROUP_DETACH_ACK = "group_detach_ack" + NODE_SETTINGS_SET = "node_settings_set" # operator → node: change daemon settings + NODE_SETTINGS_SET_ACK = "node_settings_set_ack" NODE_RELOAD = "node_reload" # operator → node: re-read node.toml NODE_RELOAD_ACK = "node_reload_ack" diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js index 4aab150..c31653a 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js @@ -2373,6 +2373,9 @@ function NodePage({ token, username, userId, groups }) { const [rosterGroup, setRosterGroup] = useState(''); const [denylist, setDenylist] = useState(null); const [showDenylist, setShowDenylist] = useState(false); + const [nodeSettings, setNodeSettings] = useState(null); + const [editSettings, setEditSettings] = useState(null); + const [savingSettings, setSavingSettings] = useState(false); const [attachGroup_, setAttachGroup_] = useState(''); const [attachDir, setAttachDir] = useState(''); const [attachUpload, setAttachUpload] = useState(''); @@ -2434,6 +2437,7 @@ function NodePage({ token, username, userId, groups }) { transportRef.current = transport; setNodeGroups(result.groups || []); setOperatorPaired(!!result.operator_paired); + setNodeSettings(result.settings || null); setStatus('connected'); return; } catch (err) { @@ -2460,6 +2464,10 @@ function NodePage({ token, username, userId, groups }) { }; }, [connectAndFetch]); + useEffect(() => { + if (nodeSettings && !editSettings) setEditSettings({ ...nodeSettings }); + }, [nodeSettings]); + const refresh = useCallback(async () => { const transport = transportRef.current; if (!transport || !transport.connected) return; @@ -2467,6 +2475,7 @@ function NodePage({ token, username, userId, groups }) { const result = await transport.fetchNodeStatus(); setNodeGroups(result.groups || []); setOperatorPaired(!!result.operator_paired); + setNodeSettings(result.settings || null); } catch {} }, []); @@ -2673,6 +2682,22 @@ function NodePage({ token, username, userId, groups }) { } }, [refresh]); + const saveSettings = useCallback(async () => { + const transport = transportRef.current; + if (!transport || !transport.connected || !editSettings) return; + setSavingSettings(true); + setActionMsg(''); + try { + await transport.updateNodeSettings(editSettings); + setNodeSettings({ ...editSettings }); + setActionMsg(t('node.settings_saved')); + } catch (err) { + setActionMsg(err.message); + } finally { + setSavingSettings(false); + } + }, [editSettings]); + if (status === 'idle' || status === 'connecting') { return html`<div class="page-content"> <h2>${t('node.title')}</h2> @@ -2888,6 +2913,58 @@ function NodePage({ token, username, userId, groups }) { `} </div> + ${editSettings && html` + <div class="node-group"> + <span class="settings-heading">${t('node.settings')}</span> + <p class="node-hint">${t('node.settings_edit_hint')}</p> + <div class="node-settings-grid"> + <label class="node-setting"> + <span class="node-setting-label">${t('node.setting_invite_ttl')}</span> + <div class="node-setting-input"> + <input type="number" min="1" value=${editSettings.invite_ttl_hours} + onInput=${e => setEditSettings(s => ({...s, invite_ttl_hours: parseInt(e.target.value) || 1}))} /> + <span class="node-setting-unit">${t('node.setting_unit_hours')}</span> + </div> + </label> + <label class="node-setting"> + <span class="node-setting-label">${t('node.setting_pair_ttl')}</span> + <div class="node-setting-input"> + <input type="number" min="1" value=${editSettings.pair_ttl_hours} + onInput=${e => setEditSettings(s => ({...s, pair_ttl_hours: parseInt(e.target.value) || 1}))} /> + <span class="node-setting-unit">${t('node.setting_unit_hours')}</span> + </div> + </label> + <label class="node-setting"> + <span class="node-setting-label">${t('node.setting_device_ttl')}</span> + <div class="node-setting-input"> + <input type="number" min="1" value=${editSettings.device_request_ttl_minutes} + onInput=${e => setEditSettings(s => ({...s, device_request_ttl_minutes: parseInt(e.target.value) || 1}))} /> + <span class="node-setting-unit">${t('node.setting_unit_minutes')}</span> + </div> + </label> + <label class="node-setting"> + <span class="node-setting-label">${t('node.setting_max_streams')}</span> + <div class="node-setting-input"> + <input type="number" min="1" value=${editSettings.max_concurrent_streams} + onInput=${e => setEditSettings(s => ({...s, max_concurrent_streams: parseInt(e.target.value) || 1}))} /> + </div> + </label> + <label class="node-setting"> + <span class="node-setting-label">${t('node.setting_transcode')}</span> + <div class="node-setting-input"> + <input type="checkbox" checked=${editSettings.transcode_incompatible_video} + onChange=${e => setEditSettings(s => ({...s, transcode_incompatible_video: e.target.checked}))} /> + </div> + </label> + </div> + <div class="node-settings-actions"> + <button class="btn btn-primary btn-small" disabled=${savingSettings || busy} + onClick=${saveSettings}> + ${savingSettings ? t('node.settings_saving') : t('node.settings_save')}</button> + </div> + </div> + `} + ${/* In Electron, the Create Group wizard handles attaching. Keep for browser users who manage nodes via MNP. */ !platform.node.available && (() => { diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js index 7017736..d1cbf2b 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js @@ -608,6 +608,20 @@ export default { 'node.restart_needed': 'Nach dem Hinzufügen: Gruppenschlüssel initialisieren (meshbay-node gek-init --group <name>).', 'node.no_groups': 'Keine Gruppen auf diesem Node konfiguriert.', 'node.stale': 'nicht auf dem Hub', + 'node.settings': 'Node settings', + 'node.settings_edit_hint': 'Changes are applied immediately and saved to node.toml.', + 'node.settings_save': 'Save', + 'node.settings_saving': 'Saving…', + 'node.settings_saved': 'Settings saved.', + 'node.setting_invite_ttl': 'Invitation TTL', + 'node.setting_pair_ttl': 'Pairing code TTL', + 'node.setting_device_ttl': 'Device request TTL', + 'node.setting_max_streams': 'Max concurrent streams', + 'node.setting_transcode': 'Transcode incompatible video', + 'node.setting_unit_hours': 'hours', + 'node.setting_unit_minutes': 'min', + 'node.setting_on': 'On', + 'node.setting_off': 'Off', 'node.unavailable': 'nicht verfügbar', 'node.files': { one: '1 Datei', other: '{n} Dateien' }, 'node.peers': { one: '1 Peer', other: '{n} Peers' }, diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js index 50f9ac5..6ddfa91 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js @@ -709,4 +709,18 @@ export default { 'node.detach_confirm': 'Remove "{name}" from this node?', 'node.detached': 'Group removed.', 'node.stale': 'not on hub', + 'node.settings': 'Node settings', + 'node.settings_edit_hint': 'Changes are applied immediately and saved to node.toml.', + 'node.settings_save': 'Save', + 'node.settings_saving': 'Saving…', + 'node.settings_saved': 'Settings saved.', + 'node.setting_invite_ttl': 'Invitation TTL', + 'node.setting_pair_ttl': 'Pairing code TTL', + 'node.setting_device_ttl': 'Device request TTL', + 'node.setting_max_streams': 'Max concurrent streams', + 'node.setting_transcode': 'Transcode incompatible video', + 'node.setting_unit_hours': 'hours', + 'node.setting_unit_minutes': 'min', + 'node.setting_on': 'On', + 'node.setting_off': 'Off', }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js index 2aa4599..c942869 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js @@ -604,6 +604,20 @@ export default { 'node.restart_needed': 'Después de añadir: inicialice la clave de grupo (meshbay-node gek-init --group <nombre>).', 'node.no_groups': 'No hay grupos configurados en este node.', 'node.stale': 'no está en el hub', + 'node.settings': 'Node settings', + 'node.settings_edit_hint': 'Changes are applied immediately and saved to node.toml.', + 'node.settings_save': 'Save', + 'node.settings_saving': 'Saving…', + 'node.settings_saved': 'Settings saved.', + 'node.setting_invite_ttl': 'Invitation TTL', + 'node.setting_pair_ttl': 'Pairing code TTL', + 'node.setting_device_ttl': 'Device request TTL', + 'node.setting_max_streams': 'Max concurrent streams', + 'node.setting_transcode': 'Transcode incompatible video', + 'node.setting_unit_hours': 'hours', + 'node.setting_unit_minutes': 'min', + 'node.setting_on': 'On', + 'node.setting_off': 'Off', 'node.unavailable': 'no disponible', 'node.files': { one: '1 archivo', other: '{n} archivos' }, 'node.peers': { one: '1 par', other: '{n} pares' }, diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js index 4750349..8eefb29 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js @@ -608,6 +608,20 @@ export default { 'node.restart_needed': 'Après l\'ajout : initialisez la clé de groupe (meshbay-node gek-init --group <name>).', 'node.no_groups': 'Aucun groupe configuré sur ce node.', 'node.stale': 'absent du hub', + 'node.settings': 'Paramètres du node', + 'node.settings_edit_hint': 'Les modifications sont appliquées immédiatement et sauvegardées dans node.toml.', + 'node.settings_save': 'Enregistrer', + 'node.settings_saving': 'Enregistrement…', + 'node.settings_saved': 'Paramètres enregistrés.', + 'node.setting_invite_ttl': 'Durée des invitations', + 'node.setting_pair_ttl': 'Durée du code d\'appairage', + 'node.setting_device_ttl': 'Durée des demandes d\'appareil', + 'node.setting_max_streams': 'Flux vidéo simultanés max', + 'node.setting_transcode': 'Transcoder les vidéos incompatibles', + 'node.setting_unit_hours': 'heures', + 'node.setting_unit_minutes': 'min', + 'node.setting_on': 'Activé', + 'node.setting_off': 'Désactivé', 'node.unavailable': 'indisponible', 'node.files': { one: '1 fichier', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js index 12e0c77..4ee261c 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js @@ -606,6 +606,20 @@ export default { 'node.restart_needed': "Dopo l'aggiunta: inizializzi la chiave di gruppo (meshbay-node gek-init --group <nome>).", 'node.no_groups': 'Nessun gruppo configurato su questo node.', 'node.stale': 'non presente sul hub', + 'node.settings': 'Node settings', + 'node.settings_edit_hint': 'Changes are applied immediately and saved to node.toml.', + 'node.settings_save': 'Save', + 'node.settings_saving': 'Saving…', + 'node.settings_saved': 'Settings saved.', + 'node.setting_invite_ttl': 'Invitation TTL', + 'node.setting_pair_ttl': 'Pairing code TTL', + 'node.setting_device_ttl': 'Device request TTL', + 'node.setting_max_streams': 'Max concurrent streams', + 'node.setting_transcode': 'Transcode incompatible video', + 'node.setting_unit_hours': 'hours', + 'node.setting_unit_minutes': 'min', + 'node.setting_on': 'On', + 'node.setting_off': 'Off', 'node.unavailable': 'non disponibile', 'node.files': { one: '1 file', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js index 7f4a0c2..e30f5bf 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js @@ -594,6 +594,20 @@ export default { 'node.restart_needed': '追加後、グループ鍵を初期化してください(meshbay-node gek-init --group <name>)。', 'node.no_groups': 'この node にはグループが設定されていません。', 'node.stale': 'hub に未登録', + 'node.settings': 'Node settings', + 'node.settings_edit_hint': 'Changes are applied immediately and saved to node.toml.', + 'node.settings_save': 'Save', + 'node.settings_saving': 'Saving…', + 'node.settings_saved': 'Settings saved.', + 'node.setting_invite_ttl': 'Invitation TTL', + 'node.setting_pair_ttl': 'Pairing code TTL', + 'node.setting_device_ttl': 'Device request TTL', + 'node.setting_max_streams': 'Max concurrent streams', + 'node.setting_transcode': 'Transcode incompatible video', + 'node.setting_unit_hours': 'hours', + 'node.setting_unit_minutes': 'min', + 'node.setting_on': 'On', + 'node.setting_off': 'Off', 'node.unavailable': '利用不可', 'node.files': { other: '{n} 件のファイル', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js index 5ea6371..2f66e00 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js @@ -608,6 +608,20 @@ export default { 'node.restart_needed': 'Na het toevoegen: initialiseer de groepssleutel (meshbay-node gek-init --group <naam>).', 'node.no_groups': 'Geen groepen geconfigureerd op deze node.', 'node.stale': 'niet op hub', + 'node.settings': 'Node settings', + 'node.settings_edit_hint': 'Changes are applied immediately and saved to node.toml.', + 'node.settings_save': 'Save', + 'node.settings_saving': 'Saving…', + 'node.settings_saved': 'Settings saved.', + 'node.setting_invite_ttl': 'Invitation TTL', + 'node.setting_pair_ttl': 'Pairing code TTL', + 'node.setting_device_ttl': 'Device request TTL', + 'node.setting_max_streams': 'Max concurrent streams', + 'node.setting_transcode': 'Transcode incompatible video', + 'node.setting_unit_hours': 'hours', + 'node.setting_unit_minutes': 'min', + 'node.setting_on': 'On', + 'node.setting_off': 'Off', 'node.unavailable': 'niet beschikbaar', 'node.files': { one: '1 bestand', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js index 6aa6cd1..f809182 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js @@ -627,6 +627,20 @@ export default { 'node.restart_needed': 'Po dodaniu: zainicjalizuj klucz grupy (meshbay-node gek-init --group <nazwa>).', 'node.no_groups': 'Na tym node nie skonfigurowano żadnych grup.', 'node.stale': 'brak na hub', + 'node.settings': 'Node settings', + 'node.settings_edit_hint': 'Changes are applied immediately and saved to node.toml.', + 'node.settings_save': 'Save', + 'node.settings_saving': 'Saving…', + 'node.settings_saved': 'Settings saved.', + 'node.setting_invite_ttl': 'Invitation TTL', + 'node.setting_pair_ttl': 'Pairing code TTL', + 'node.setting_device_ttl': 'Device request TTL', + 'node.setting_max_streams': 'Max concurrent streams', + 'node.setting_transcode': 'Transcode incompatible video', + 'node.setting_unit_hours': 'hours', + 'node.setting_unit_minutes': 'min', + 'node.setting_on': 'On', + 'node.setting_off': 'Off', 'node.unavailable': 'niedostępny', 'node.files': { one: '{n} plik', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js index 2cff7a6..2e1a9f5 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js @@ -605,6 +605,20 @@ export default { 'node.restart_needed': 'Após adicionar: inicialize a chave do grupo (meshbay-node gek-init --group <nome>).', 'node.no_groups': 'Nenhum grupo configurado neste node.', 'node.stale': 'fora do hub', + 'node.settings': 'Node settings', + 'node.settings_edit_hint': 'Changes are applied immediately and saved to node.toml.', + 'node.settings_save': 'Save', + 'node.settings_saving': 'Saving…', + 'node.settings_saved': 'Settings saved.', + 'node.setting_invite_ttl': 'Invitation TTL', + 'node.setting_pair_ttl': 'Pairing code TTL', + 'node.setting_device_ttl': 'Device request TTL', + 'node.setting_max_streams': 'Max concurrent streams', + 'node.setting_transcode': 'Transcode incompatible video', + 'node.setting_unit_hours': 'hours', + 'node.setting_unit_minutes': 'min', + 'node.setting_on': 'On', + 'node.setting_off': 'Off', 'node.unavailable': 'indisponível', 'node.files': { one: '1 arquivo', other: '{n} arquivos' }, 'node.peers': { one: '1 par', other: '{n} pares' }, diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js index 704204d..1ec23eb 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js @@ -580,6 +580,20 @@ export default { 'node.restart_needed': '添加后请初始化群组密钥(meshbay-node gek-init --group <名称>)。', 'node.no_groups': '此 node 上未配置任何群组。', 'node.stale': '不在 hub 上', + 'node.settings': 'Node settings', + 'node.settings_edit_hint': 'Changes are applied immediately and saved to node.toml.', + 'node.settings_save': 'Save', + 'node.settings_saving': 'Saving…', + 'node.settings_saved': 'Settings saved.', + 'node.setting_invite_ttl': 'Invitation TTL', + 'node.setting_pair_ttl': 'Pairing code TTL', + 'node.setting_device_ttl': 'Device request TTL', + 'node.setting_max_streams': 'Max concurrent streams', + 'node.setting_transcode': 'Transcode incompatible video', + 'node.setting_unit_hours': 'hours', + 'node.setting_unit_minutes': 'min', + 'node.setting_on': 'On', + 'node.setting_off': 'Off', 'node.unavailable': '不可用', 'node.files': { other: '{n} 个文件', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css index 2a2bd8a..739f9c6 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/style.css +++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css @@ -2660,6 +2660,56 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; } } .node-attach-dir-row input { flex: 1; } +.node-settings-grid { + display: flex; + flex-direction: column; + gap: 0; + margin-top: 8px; +} +.node-setting { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 0; + border-bottom: 1px solid var(--border); + cursor: default; +} +.node-setting-label { + font-size: 0.85em; + color: var(--text-secondary); +} +.node-setting-value { + font-size: 0.85em; + font-weight: 500; +} +.node-setting-input { + display: flex; + align-items: center; + gap: 6px; +} +.node-setting-input input[type="number"] { + width: 80px; + padding: 4px 8px; + border: 1px solid var(--border); + border-radius: 5px; + background: var(--bg-base); + color: var(--text); + font-size: 0.85em; + text-align: right; +} +.node-setting-input input[type="checkbox"] { + width: 16px; + height: 16px; + accent-color: var(--accent); +} +.node-setting-unit { + font-size: 0.8em; + color: var(--text-secondary); +} +.node-settings-actions { + margin-top: 12px; +} + /* ── Create Group Wizard ──────────────────────────────────────────────────── */ .wizard-root { diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js index 5a6e36b..b60bd93 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js @@ -1310,6 +1310,14 @@ class MeshBayTransport { return msg; } + async updateNodeSettings(settings) { + const msg = await this._sendAndWait({ + type: 'node_settings_set', v: '0.1', settings, + }); + if (msg.type === 'error') throw new Error(msg.detail); + return msg; + } + async addRoot(groupId, path, { name, kind, upload } = {}, signFn) { const msg = await this._sendAndWait({ type: 'root_add', v: '0.1', diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py index 266693a..e1e4dee 100644 --- a/packages/meshbay-node/src/meshbay_node/daemon.py +++ b/packages/meshbay-node/src/meshbay_node/daemon.py @@ -267,6 +267,20 @@ class NodeDaemon: await self._roster.open() await self._roster.purge_expired() + # Apply any roster overrides to node config (panel-edited values + # take precedence over node.toml defaults). + nd = self._config.node + defaults = { + "invite_ttl_hours": nd.invite_ttl_hours, + "pair_ttl_hours": nd.pair_ttl_hours, + "device_request_ttl_minutes": nd.device_request_ttl_minutes, + "max_concurrent_streams": nd.max_concurrent_streams, + "transcode_incompatible_video": nd.transcode_incompatible_video, + } + effective = await self._roster.node_settings(defaults) + for k, v in effective.items(): + setattr(nd, k, v) + # X25519 key material for GEK unwrapping from cryptography.hazmat.primitives import serialization sk_x_raw = keys.sk_x25519.private_bytes( 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: diff --git a/packages/meshbay-node/src/meshbay_node/roster.py b/packages/meshbay-node/src/meshbay_node/roster.py index ff5282d..cf0d421 100644 --- a/packages/meshbay-node/src/meshbay_node/roster.py +++ b/packages/meshbay-node/src/meshbay_node/roster.py @@ -754,6 +754,41 @@ class Roster: str(float(debounce_secs)), set_by) return await self.scan_settings(group_id) + # ── Node-wide daemon settings ─────────────────────────────────────────── + # Same pattern as TMDB config: stored under NODE_WIDE_GROUP_ID. + # On startup, node.toml values are the defaults; the roster override + # takes precedence at runtime. Changing a setting writes to both + # roster.db (immediate) and node.toml (survives a DB wipe). + SETTING_INVITE_TTL = "invite_ttl_hours" + SETTING_PAIR_TTL = "pair_ttl_hours" + SETTING_DEVICE_TTL = "device_request_ttl_minutes" + SETTING_MAX_STREAMS = "max_concurrent_streams" + SETTING_TRANSCODE = "transcode_incompatible_video" + + async def node_settings(self, defaults: dict) -> dict: + """Current effective settings: roster override if present, else config default.""" + result = {} + for key, setting in [ + ("invite_ttl_hours", self.SETTING_INVITE_TTL), + ("pair_ttl_hours", self.SETTING_PAIR_TTL), + ("device_request_ttl_minutes", self.SETTING_DEVICE_TTL), + ("max_concurrent_streams", self.SETTING_MAX_STREAMS), + ("transcode_incompatible_video", self.SETTING_TRANSCODE), + ]: + stored = await self.get_setting(self.NODE_WIDE_GROUP_ID, setting) + if stored is not None: + if key == "transcode_incompatible_video": + result[key] = stored != "0" + else: + result[key] = int(stored) + else: + result[key] = defaults.get(key) + return result + + async def set_node_setting(self, key: str, value: str, + set_by: str = "") -> None: + await self.set_setting(self.NODE_WIDE_GROUP_ID, key, value, set_by) + async def create_invite( self, group_id: str, diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py index 1bd7203..a934418 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py +++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py @@ -504,6 +504,8 @@ class WebRTCPeerSession: self._do_group_attach(msg) elif mtype == MNP.GROUP_DETACH: self._do_group_detach(msg) + elif mtype == MNP.NODE_SETTINGS_SET: + self._spawn(self._do_node_settings_set(msg)) elif mtype == MNP.NODE_RELOAD: self._spawn(self._do_node_reload(msg)) elif mtype == MNP.KEYPAIR_BUNDLE_STORE: @@ -2187,6 +2189,24 @@ class WebRTCPeerSession: log.error("node_status failed: %s", e, exc_info=True) self._send({"type": "error", "detail": "Internal error"}) + async def _do_node_settings_set(self, msg: dict) -> None: + if not self._is_node_admin(): + self._send({"type": "error", "detail": "Not the node operator"}) + return + settings = msg.get("settings", {}) + if not settings: + self._send({"type": "error", "detail": "No settings provided"}) + return + try: + result = await self._run_op(ops.set_node_settings, settings) + self._send({"type": MNP.NODE_SETTINGS_SET_ACK, "v": MNP_VERSION, + **result}) + except ops.OpError as e: + self._send({"type": "error", "detail": e.message}) + except Exception as e: + log.error("node_settings_set failed: %s", e, exc_info=True) + self._send({"type": "error", "detail": "Internal error"}) + async def _do_roster_read(self, msg: dict) -> None: if not self._is_node_admin(): self._send({"type": "error", "detail": "Not the node operator"}) diff --git a/packages/meshbay-node/src/meshbay_node/ui/app.py b/packages/meshbay-node/src/meshbay_node/ui/app.py index 514a143..b25b36a 100644 --- a/packages/meshbay-node/src/meshbay_node/ui/app.py +++ b/packages/meshbay-node/src/meshbay_node/ui/app.py @@ -291,6 +291,13 @@ def create_ui_app(state: dict) -> FastAPI: "quic_port": config.node.quic_port, "ui_port": config.node.ui_port, "data_dir": str(config.data_dir), + "settings": { + "invite_ttl_hours": config.node.invite_ttl_hours, + "pair_ttl_hours": config.node.pair_ttl_hours, + "device_request_ttl_minutes": config.node.device_request_ttl_minutes, + "max_concurrent_streams": config.node.max_concurrent_streams, + "transcode_incompatible_video": config.node.transcode_incompatible_video, + }, "groups": [ { "id": g.id, @@ -438,6 +445,12 @@ def create_ui_app(state: dict) -> FastAPI: # seconds, on a real library) — see ops.start_reload for why. return await _op(lambda: ops.start_reload(state)) + # ── Node settings (operator only, localhost) ─────────────────────────── + + @app.put("/api/node-settings") + async def update_node_settings(payload: dict): + return await _op(lambda: ops.set_node_settings(state, payload)) + # ── Chat endpoints ─────────────────────────────────────────────────────── _chat_subscribers: list[WebSocket] = [] @@ -521,6 +534,24 @@ def _fmt_size(n: int) -> str: return f"{n / (1024 * 1024 * 1024):.2f} GB" +def _render_node_settings(config) -> str: + if not config: + return "" + nd = config.node + transcode = "on" if nd.transcode_incompatible_video else "off" + return f""" + <table style="margin-top:10px"> + <thead><tr><th>Setting</th><th>Value</th></tr></thead> + <tbody> + <tr><td>Invitation TTL</td><td>{nd.invite_ttl_hours} hours</td></tr> + <tr><td>Pairing code TTL</td><td>{nd.pair_ttl_hours} hours</td></tr> + <tr><td>Device request TTL</td><td>{nd.device_request_ttl_minutes} minutes</td></tr> + <tr><td>Max concurrent streams</td><td>{nd.max_concurrent_streams}</td></tr> + <tr><td>Transcode incompatible video</td><td>{transcode}</td></tr> + </tbody> + </table>""" + + def _render_roster(roster_view: dict | None) -> str: """ Who this node recognises, and which keys are theirs. @@ -766,6 +797,7 @@ def _render_page(state: dict, roster_view: dict | None = None, <p><b>Hub:</b> {state.get("hub_url", "—")}</p> <p><b>QUIC port:</b> {state.get("quic_port", "—")}</p> <p><b>Node ID:</b> <code>{state.get("endpoint_hint") or "—"}</code></p> + {_render_node_settings(config)} </div> <h2>Maintenance</h2> |