From bf56b60b95c5413d592f0199320d637978792756 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Sun, 30 Aug 2026 21:07:09 +0200 Subject: refactor(ui): extract Node page to dedicated module, use local API Move NodePage and NodeServicePanel from app.js into node-page.js, following the existing pattern (group-page.js, search-page.js). Lazy-loaded via dynamic import so browser users never fetch it. Replace all MNP/WebRTC calls with platform.node.call() (loopback HTTP API), eliminating the ~6s ICE gathering delay. The MNP protocol types and server-side handlers are kept for potential future browser use. Co-Authored-By: Claude Opus 4.6 --- .../src/meshbay_hub/static/node-page.js | 564 +++++++++++++++++++++ 1 file changed, 564 insertions(+) create mode 100644 packages/meshbay-hub/src/meshbay_hub/static/node-page.js (limited to 'packages/meshbay-hub/src/meshbay_hub/static/node-page.js') diff --git a/packages/meshbay-hub/src/meshbay_hub/static/node-page.js b/packages/meshbay-hub/src/meshbay_hub/static/node-page.js new file mode 100644 index 0000000..9a6db7d --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/static/node-page.js @@ -0,0 +1,564 @@ +import { + html, useState, useEffect, useCallback, useRef, +} from './vendor/htm-preact.js'; +import { t } from './i18n.js'; +import * as platform from './platform.js'; +import { Icon } from './icon.js'; + +// ── Node management (D5) ──────────────────────────────────────────────────── +// +// Electron-only: talks to the local node daemon via its loopback HTTP API +// (platform.node.call), not over MNP/WebRTC. The MNP protocol types remain +// for potential future browser-side use. + +function NodeServicePanel({ onChanged }) { + const [info, setInfo] = useState(null); + const [busy, setBusy] = useState(''); + const [err, setErr] = useState(''); + + const refresh = useCallback(async () => { + try { + const r = await platform.node.service.status(); + setInfo(r); + setErr(''); + } catch (e) { + setErr(platform.bridgeMessage(e)); + } + }, []); + + useEffect(() => { + if (!platform.node.service.available) return; + refresh(); + const timer = setInterval(refresh, 5000); + return () => clearInterval(timer); + }, [refresh]); + + const act = useCallback(async (name, fn) => { + setBusy(name); + setErr(''); + try { + await fn(); + await refresh(); + if (onChanged) onChanged(); + } catch (e) { + setErr(platform.bridgeMessage(e)); + } finally { + setBusy(''); + } + }, [refresh, onChanged]); + + if (!platform.node.service.available) return null; + if (!info || info.supported === false) { + return html`
+ ${' '}${t('node.service_checking')} +
`; + } + + const KNOWN_STATES = ['active', 'inactive', 'failed', 'activating', 'deactivating']; + const stateKey = KNOWN_STATES.includes(info.activeState) ? info.activeState : 'unknown'; + const running = info.activeState === 'active' || info.activeState === 'activating'; + const dot = info.activeState === 'active' ? 'online' + : info.activeState === 'failed' ? 'offline' : 'unknown'; + const label = info.installed ? t('node.service_state_' + stateKey) + : t('node.service_not_installed'); + + return html` +
+
+ + ${label} +
+ ${err && html`
${err}
`} +
+ + ${info.installed && html` + + + `} +
+
`; +} + +async function nodeCall(method, path, body) { + return platform.node.call(method, path, body); +} + +export function NodePage({ groups }) { + const [status, setStatus] = useState('idle'); + const [error, setError] = useState(''); + const [nodeGroups, setNodeGroups] = useState([]); + const [busy, setBusy] = useState(false); + const [actionMsg, setActionMsg] = useState(''); + const [roster, setRoster] = useState(null); + 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 [operatorPaired, setOperatorPaired] = useState(false); + const [pairBusy, setPairBusy] = useState(false); + const [pairStatus, setPairStatus] = useState(''); + + const fetchStatus = useCallback(async () => { + setStatus('connecting'); + setError(''); + try { + const detection = await platform.node.detect(); + if (!detection.detected) { + setError(t('node.offline')); + setStatus('error'); + return; + } + const result = await nodeCall('GET', '/api/groups'); + setNodeGroups(result.groups || []); + setOperatorPaired(!!result.operator_paired); + setNodeSettings(result.settings || null); + setStatus('connected'); + } catch (err) { + setError(platform.bridgeMessage(err)); + setStatus('error'); + } + }, []); + + useEffect(() => { fetchStatus(); }, [fetchStatus]); + + useEffect(() => { + if (nodeSettings && !editSettings) setEditSettings({ ...nodeSettings }); + }, [nodeSettings]); + + const refresh = useCallback(async () => { + try { + const result = await nodeCall('GET', '/api/groups'); + setNodeGroups(result.groups || []); + setOperatorPaired(!!result.operator_paired); + setNodeSettings(result.settings || null); + } catch {} + }, []); + + const addRoot = useCallback(async (groupId) => { + const chosen = await platform.rootPicker.choose(); + if (!chosen) return; + setBusy(true); + setActionMsg(''); + try { + await nodeCall('POST', `/api/groups/${groupId}/roots`, { path: chosen.path }); + await nodeCall('POST', '/api/reload'); + await refresh(); + setActionMsg(t('node.root_added')); + } catch (err) { + setActionMsg(platform.bridgeMessage(err)); + } finally { + setBusy(false); + } + }, [refresh]); + + const removeRoot = useCallback(async (groupId, rootName) => { + if (!confirm(t('node.root_remove_confirm', { name: rootName }))) return; + setBusy(true); + setActionMsg(''); + try { + await nodeCall('DELETE', `/api/groups/${groupId}/roots/${encodeURIComponent(rootName)}`); + await nodeCall('POST', '/api/reload'); + await refresh(); + setActionMsg(t('node.root_removed')); + } catch (err) { + setActionMsg(platform.bridgeMessage(err)); + } finally { + setBusy(false); + } + }, [refresh]); + + const loadRoster = useCallback(async (groupId) => { + setBusy(true); + try { + const result = await nodeCall('GET', `/api/roster?group_id=${groupId}`); + setRoster(result); + setRosterGroup(groupId); + } catch (err) { + setActionMsg(platform.bridgeMessage(err)); + } finally { + setBusy(false); + } + }, []); + + const unpinMember = useCallback(async (userId) => { + if (!confirm(t('node.unpin_confirm', { name: userId }))) return; + setBusy(true); + setActionMsg(''); + try { + await nodeCall('POST', `/api/members/${userId}/unpin`); + setActionMsg(t('node.unpin_done')); + if (rosterGroup) await loadRoster(rosterGroup); + } catch (err) { + setActionMsg(platform.bridgeMessage(err)); + } finally { + setBusy(false); + } + }, [rosterGroup, loadRoster]); + + const rotateGek = useCallback(async (groupId) => { + if (!confirm(t('node.gek_rotate_confirm'))) return; + setBusy(true); + setActionMsg(''); + try { + await nodeCall('POST', `/api/groups/${groupId}/gek?rotate=true`); + setActionMsg(t('node.gek_rotated')); + } catch (err) { + setActionMsg(platform.bridgeMessage(err)); + } finally { + setBusy(false); + } + }, []); + + const loadDenylist = useCallback(async () => { + setBusy(true); + try { + const result = await nodeCall('GET', '/api/denylist'); + setDenylist(result); + setShowDenylist(true); + } catch (err) { + setActionMsg(platform.bridgeMessage(err)); + } finally { + setBusy(false); + } + }, []); + + const clearDenylist = useCallback(async (subject) => { + const label = subject || t('node.denylist_clear_all'); + if (!confirm(t('node.denylist_clear_confirm', { subject: label }))) return; + setBusy(true); + setActionMsg(''); + try { + await nodeCall('POST', `/api/denylist/clear?subject=${encodeURIComponent(subject)}`); + setActionMsg(t('node.denylist_cleared')); + await loadDenylist(); + } catch (err) { + setActionMsg(platform.bridgeMessage(err)); + } finally { + setBusy(false); + } + }, [loadDenylist]); + + const detachGroup = useCallback(async (name) => { + if (!confirm(t('node.detach_confirm', { name }))) return; + setBusy(true); + setActionMsg(''); + try { + await nodeCall('POST', '/api/groups/detach', { name }); + await nodeCall('POST', '/api/reload'); + setActionMsg(t('node.detached')); + await refresh(); + } catch (err) { + setActionMsg(platform.bridgeMessage(err)); + } finally { + setBusy(false); + } + }, [refresh]); + + const reloadConfig = useCallback(async () => { + setBusy(true); + setActionMsg(''); + try { + await nodeCall('POST', '/api/reload'); + setActionMsg(t('node.reloaded')); + await refresh(); + } catch (err) { + setActionMsg(platform.bridgeMessage(err)); + } finally { + setBusy(false); + } + }, [refresh]); + + const saveSettings = useCallback(async () => { + if (!editSettings) return; + setSavingSettings(true); + setActionMsg(''); + try { + await nodeCall('PUT', '/api/node-settings', editSettings); + setNodeSettings({ ...editSettings }); + setActionMsg(t('node.settings_saved')); + } catch (err) { + setActionMsg(platform.bridgeMessage(err)); + } finally { + setSavingSettings(false); + } + }, [editSettings]); + + const doPairOperator = useCallback(async () => { + setPairBusy(true); + setPairStatus(''); + try { + const result = await nodeCall('POST', '/api/operator/pair'); + if (result && result.code) { + await platform.node.setPairingCode(result.code); + } + setPairStatus('paired'); + setOperatorPaired(true); + await refresh(); + } catch (err) { + setPairStatus(platform.bridgeMessage(err)); + } finally { + setPairBusy(false); + } + }, [refresh]); + + if (status === 'idle' || status === 'connecting') { + return html`
+

${t('node.title')}

+ <${NodeServicePanel} onChanged=${fetchStatus} /> +

${' '}${t('status.connecting')}

+
`; + } + if (status === 'error') { + return html`
+

${t('node.title')}

+ <${NodeServicePanel} onChanged=${fetchStatus} /> +

${error}

+ +
`; + } + + return html` +
+
+

${t('node.title')}

+ +
+ <${NodeServicePanel} onChanged=${fetchStatus} /> + ${actionMsg && html`
${actionMsg}
`} + ${!operatorPaired && html` +
+

${t('node.pair_needed')}

+ + ${pairStatus === 'paired' + ? html`

${t('node.pair_success')}

` + : pairStatus ? html`

${pairStatus}

` : null} +
+ `} + ${(() => { + const hubIds = new Set((groups || []).map(g => g.id)); + return nodeGroups.map(g => { + const stale = !hubIds.has(g.id); + return html` +
+
+

${g.name}${stale ? html` ${t('node.stale')}` : ''}

+
+ ${t('node.files', { n: g.file_count })} + ${t('node.peers', { n: g.peers })} + ${!g.has_gek && html` + ${t('node.no_gek')}`} +
+
+
+
+ ${t('node.roots')} +
+ ${(g.roots || []).map(r => html` +
+
+ + <${Icon} name="folder" /> + ${r.name} + + ${r.upload && html` + ${t('node.upload_root')}`} + ${!r.available && html` + + ${t('node.unavailable')}`} +
+ ${(g.roots || []).length > 1 && !r.upload && html` + `} +
+ `)} + +
+ +
+ ${t('node.gek')} + ${g.has_gek ? (g.visibility !== 'public' ? html` + + ` : html`

${t('node.gek_public_hint')}

`) : html` +

${t('node.gek_init_hint')}

+ `} +
+ +
+
+ ${t('node.roster')} + +
+ ${roster && rosterGroup === g.id && html` +
+ ${(roster.members || []).length === 0 ? html` +

${t('node.roster_empty')}

+ ` : html` + + + + + + + + + + ${(roster.members || []).map(m => html` + + + + + + + + `)} + +
${t('node.roster_user')}${t('node.roster_role')}${t('node.roster_status')}${t('node.roster_via')}
${m.username || m.user_id.slice(0, 12)}${m.role}${m.status}${m.pinned_via || ''}${m.pk_ed25519 && html` + + `}
+ `} + ${(roster.invites || []).length > 0 && html` +

${t('node.roster_invites', + { n: roster.invites.length })}

+ `} +
+ `} +
+ + ${stale && html` +
+ +
+ `} +
+ `; + }); + })()} + +
+
+ ${t('node.denylist')} + +
+ ${showDenylist && denylist && html` +
+ ${denylist.count === 0 ? html` +

${t('node.denylist_empty')}

+ ` : html` +

${t('node.denylist_count', + { n: denylist.count })}

+ ${(denylist.users || []).map(u => html` +
+ user: ${u} + +
+ `)} + ${(denylist.groups || []).map(g => html` +
+ group: ${g} + +
+ `)} + ${(denylist.jtis || []).map(j => html` +
+ token: ${j.slice(0, 16)} + +
+ `)} + + `} +
+ `} +
+ + ${editSettings && html` +
+ ${t('node.settings')} +

${t('node.settings_edit_hint')}

+
+ + + + + +
+
+ +
+
+ `} +
+ `; +} -- cgit v1.2.3