diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-30 21:07:09 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-30 21:07:18 +0200 |
| commit | bf56b60b95c5413d592f0199320d637978792756 (patch) | |
| tree | d8f4b4dcb386378a7881954418ce11aec745bf79 /packages | |
| parent | d5fc45ff907e618d884a8d2d91c1f2b45e6898d1 (diff) | |
| download | meshbay-bf56b60b95c5413d592f0199320d637978792756.tar.gz | |
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 <noreply@anthropic.com>
Diffstat (limited to 'packages')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/app.js | 747 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/node-page.js | 564 |
2 files changed, 574 insertions, 737 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js index c31653a..268ec3f 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js @@ -2277,745 +2277,19 @@ function BlocklistForm({ onAdd }) { `; } -// ── Node management (D5) ──────────────────────────────────────────────────── - -/** - * The systemd unit's own state, independent of the MNP connection below it. - * - * The rest of NodePage talks to the daemon over a signed MNP session, which - * requires the daemon to already be up and answering — no use at all for - * "the node is stopped, start it" or "it is crash-looping, tell me". This - * asks systemd directly (main process → `systemctl --user`), the same way - * `node:installed` and the wizard's `node:start` already do, so it works - * from every state the connection below can be in. - */ -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)); - } - }, []); +// ── Lazy-loaded Node page (Electron only) ─────────────────────────────────── +let _NodePage = null; +function LazyNodePage(props) { + const [loaded, setLoaded] = useState(!!_NodePage); 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(''); + if (!_NodePage) { + import('./node-page.js').then(m => { _NodePage = m.NodePage; setLoaded(true); }); } - }, [refresh, onChanged]); - - if (!platform.node.service.available) return null; - if (!info || info.supported === false) { - return html`<div class="node-service"> - <span class="spinner"></span>${' '}${t('node.service_checking')} - </div>`; - } - - 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` - <div class="node-service"> - <div class="node-service-status"> - <span class="presence presence-${dot}" title="${label}" aria-label="${label}"></span> - <span>${label}</span> - </div> - ${err && html`<div class="error-msg">${err}</div>`} - <div class="node-service-actions"> - <button class="btn btn-small btn-secondary" disabled=${!!busy || running} - onClick=${() => act('start', () => platform.node.start())}> - ${busy === 'start' ? t('node.service_starting') : t('node.service_start')}</button> - ${info.installed && html` - <button class="btn btn-small btn-secondary" disabled=${!!busy || !running} - onClick=${() => act('stop', () => platform.node.service.stop())}> - ${busy === 'stop' ? t('node.service_stopping') : t('node.service_stop')}</button> - <button class="btn btn-small btn-secondary" disabled=${!!busy} - onClick=${() => act('restart', () => platform.node.service.restart())}> - ${busy === 'restart' ? t('node.service_restarting') : t('node.service_restart')}</button> - `} - </div> - </div>`; -} - -function NodePage({ token, username, userId, 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 [attachGroup_, setAttachGroup_] = useState(''); - const [attachDir, setAttachDir] = useState(''); - const [attachUpload, setAttachUpload] = useState(''); - const [operatorPaired, setOperatorPaired] = useState(false); - const [pairCode, setPairCode] = useState(''); - const [pairStatus, setPairStatus] = useState(''); - const [pairing, setPairing] = useState(false); - const transportRef = useRef(null); - - const connectAndFetch = useCallback(async () => { - if (!groups.length) { - setStatus('no_groups'); - return; - } - setStatus('connecting'); - setError(''); - try { - if (!session.bundleKey) session.bundleKey = await _loadBundleKey(); - const live = (await ensureFreshToken()) || token; - - const nodeCandidates = (await Promise.all( - groups.map(g => - hubFetch(`/v1/groups/${g.id}/nodes`, { token: live }) - .then(d => (d.nodes || []).map(n => ({ groupId: g.id, nodeId: n.node_id }))) - .catch(() => [])) - )).flat(); - - if (!nodeCandidates.length) { - setError(t('node.offline')); - setStatus('error'); - return; - } - - // Group candidates by node, preserving all groupIds. The same node - // is reachable through any group it serves; a group that was just - // attached but not yet loaded will fail, so we try every groupId - // before giving up on a node. - const byNode = new Map(); - for (const c of nodeCandidates) { - if (!byNode.has(c.nodeId)) byNode.set(c.nodeId, []); - const ids = byNode.get(c.nodeId); - if (!ids.includes(c.groupId)) ids.push(c.groupId); - } - - console.log('[NodePage] candidates:', byNode.size, 'node(s)', - [...byNode.entries()].map(([n, gs]) => n.slice(0, 8) + '×' + gs.length)); - let lastErr = null; - for (const [nodeId, groupIds] of byNode) { - for (const groupId of groupIds) { - const transport = new window.MeshBayTransport(HUB, live); - try { - console.log('[NodePage] connecting via group', groupId.slice(0, 8), - 'node', nodeId.slice(0, 8)); - await transport.connect(nodeId, live, groupId, - null, null, session.bundleKey, username, userId); - console.log('[NodePage] connected, fetching status'); - const result = await transport.fetchNodeStatus(); - console.log('[NodePage] got status:', result.groups?.length, 'groups'); - transportRef.current = transport; - setNodeGroups(result.groups || []); - setOperatorPaired(!!result.operator_paired); - setNodeSettings(result.settings || null); - setStatus('connected'); - return; - } catch (err) { - console.warn('[NodePage] candidate failed:', nodeId.slice(0, 8), - 'group', groupId.slice(0, 8), err.message); - try { transport.close(); } catch {} - lastErr = err; - } - } - } - setError(lastErr ? lastErr.message : t('node.not_operator')); - setStatus('error'); - } catch (err) { - setError(err.message); - setStatus('error'); - } - }, [token, username, userId, groups]); - - useEffect(() => { - connectAndFetch(); - return () => { - const tr = transportRef.current; - if (tr && tr.connected) try { tr.close(); } catch {} - }; - }, [connectAndFetch]); - - useEffect(() => { - if (nodeSettings && !editSettings) setEditSettings({ ...nodeSettings }); - }, [nodeSettings]); - - const refresh = useCallback(async () => { - const transport = transportRef.current; - if (!transport || !transport.connected) return; - try { - const result = await transport.fetchNodeStatus(); - setNodeGroups(result.groups || []); - setOperatorPaired(!!result.operator_paired); - setNodeSettings(result.settings || null); - } catch {} - }, []); - - const signFn = useCallback(async (transcript) => { - const transport = transportRef.current; - if (!transport || !transport.sessionKeys || !transport.sessionKeys.skEdB64) - throw new Error('No signing key'); - return window.MeshBayKeys.signBytes(transport.sessionKeys.skEdB64, transcript); - }, []); - - const addRoot = useCallback(async (groupId) => { - const chosen = await platform.rootPicker.choose(); - if (!chosen) return; - const transport = transportRef.current; - if (!transport || !transport.connected) return; - setBusy(true); - setActionMsg(''); - try { - await transport.addRoot(groupId, chosen.path, {}, signFn); - await refresh(); - setActionMsg(t('node.root_added')); - } catch (err) { - setActionMsg(err.message); - } finally { - setBusy(false); - } - }, [signFn, refresh]); - - const removeRoot = useCallback(async (groupId, rootName) => { - if (!confirm(t('node.root_remove_confirm', { name: rootName }))) return; - const transport = transportRef.current; - if (!transport || !transport.connected) return; - setBusy(true); - setActionMsg(''); - try { - await transport.removeRoot(groupId, rootName, signFn); - await refresh(); - setActionMsg(t('node.root_removed')); - } catch (err) { - setActionMsg(err.message); - } finally { - setBusy(false); - } - }, [signFn, refresh]); - - const loadRoster = useCallback(async (groupId) => { - const transport = transportRef.current; - if (!transport || !transport.connected) return; - setBusy(true); - try { - const result = await transport.fetchRoster(groupId); - setRoster(result); - setRosterGroup(groupId); - } catch (err) { - setActionMsg(err.message); - } finally { - setBusy(false); - } - }, []); - - const unpinMember = useCallback(async (userId) => { - if (!confirm(t('node.unpin_confirm', { name: userId }))) return; - const transport = transportRef.current; - if (!transport || !transport.connected) return; - setBusy(true); - setActionMsg(''); - try { - await transport.unpinMember(userId, signFn); - setActionMsg(t('node.unpin_done')); - if (rosterGroup) await loadRoster(rosterGroup); - } catch (err) { - setActionMsg(err.message); - } finally { - setBusy(false); - } - }, [signFn, rosterGroup, loadRoster]); - - const rotateGek = useCallback(async (groupId) => { - if (!confirm(t('node.gek_rotate_confirm'))) return; - const transport = transportRef.current; - if (!transport || !transport.connected) return; - setBusy(true); - setActionMsg(''); - try { - await transport.rotateGek(groupId, signFn); - setActionMsg(t('node.gek_rotated')); - } catch (err) { - setActionMsg(err.message); - } finally { - setBusy(false); - } - }, [signFn]); - - const loadDenylist = useCallback(async () => { - const transport = transportRef.current; - if (!transport || !transport.connected) return; - setBusy(true); - try { - const result = await transport.fetchDenylist(); - setDenylist(result); - setShowDenylist(true); - } catch (err) { - setActionMsg(err.message); - } 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; - const transport = transportRef.current; - if (!transport || !transport.connected) return; - setBusy(true); - setActionMsg(''); - try { - await transport.clearDenylist(subject); - setActionMsg(t('node.denylist_cleared')); - await loadDenylist(); - } catch (err) { - setActionMsg(err.message); - } finally { - setBusy(false); - } - }, [loadDenylist]); - - const pickAttachDir = useCallback(async (setter) => { - const chosen = await platform.rootPicker.choose(); - if (chosen) setter(chosen.path); }, []); - - const detachGroup = useCallback(async (name) => { - if (!confirm(t('node.detach_confirm', { name }))) return; - const transport = transportRef.current; - if (!transport || !transport.connected) return; - setBusy(true); - setActionMsg(''); - try { - await transport.detachGroup(name, signFn); - setActionMsg(t('node.detached')); - await refresh(); - } catch (err) { - setActionMsg(err.message); - } finally { - setBusy(false); - } - }, [signFn, refresh]); - - const attachGroup = useCallback(async () => { - if (!attachGroup_ || !attachDir.trim()) return; - const transport = transportRef.current; - if (!transport || !transport.connected) return; - setBusy(true); - setActionMsg(''); - try { - const ack = await transport.attachGroup(attachGroup_, attachDir.trim(), - attachUpload.trim(), signFn); - setActionMsg(t('node.attached', { name: ack.name || attachGroup_ })); - setAttachGroup_(''); - setAttachDir(''); - setAttachUpload(''); - await refresh(); - } catch (err) { - setActionMsg(err.message); - } finally { - setBusy(false); - } - }, [attachGroup_, attachDir, attachUpload, signFn]); - - const doPairOperator = useCallback(async (e) => { - e.preventDefault(); - const code = pairCode.trim(); - if (!code) return; - setPairing(true); - setPairStatus(''); - try { - const transport = transportRef.current; - if (!transport || !transport.connected) throw new Error('Not connected'); - await transport.pairOperator(userId, code); - setPairCode(''); - setPairStatus('paired'); - setOperatorPaired(true); - await refresh(); - } catch (err) { - setPairStatus(err.message); - } finally { - setPairing(false); - } - }, [pairCode, userId, refresh]); - - const reloadConfig = useCallback(async () => { - const transport = transportRef.current; - if (!transport || !transport.connected) return; - setBusy(true); - setActionMsg(''); - try { - await transport.reloadConfig(); - setActionMsg(t('node.reloaded')); - await refresh(); - } catch (err) { - setActionMsg(err.message); - } finally { - setBusy(false); - } - }, [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> - <${NodeServicePanel} onChanged=${connectAndFetch} /> - <p class="page-message"><span class="spinner"></span>${' '}${t('status.connecting')}</p> - </div>`; - } - if (status === 'no_groups') { - return html`<div class="page-content"> - <h2>${t('node.title')}</h2> - <${NodeServicePanel} onChanged=${connectAndFetch} /> - <p class="page-message">${t('node.no_groups')}</p> - </div>`; - } - if (status === 'error') { - return html`<div class="page-content"> - <h2>${t('node.title')}</h2> - <${NodeServicePanel} onChanged=${connectAndFetch} /> - <p class="error-msg">${error}</p> - <button class="btn btn-primary" onClick=${connectAndFetch}> - ${t('node.retry')}</button> - </div>`; - } - - return html` - <div class="page-content node-page"> - <div class="node-header"> - <h2>${t('node.title')}</h2> - <button class="btn btn-small btn-secondary" disabled=${busy} - onClick=${reloadConfig}> - ${t('node.reload')}</button> - </div> - <${NodeServicePanel} onChanged=${connectAndFetch} /> - ${actionMsg && html`<div class="node-message">${actionMsg}</div>`} - ${!operatorPaired && html` - <div class="node-pair-banner"> - <p>${t('node.pair_needed')}</p> - <form onSubmit=${doPairOperator} class="node-pair-form"> - <input type="text" placeholder=${t('node.pair_code_placeholder')} - value=${pairCode} onInput=${e => setPairCode(e.target.value)} - disabled=${pairing} /> - <button class="btn btn-primary btn-small" type="submit" - disabled=${pairing || !pairCode.trim()}> - ${t('node.pair_button')}</button> - </form> - ${pairStatus === 'paired' - ? html`<p class="success-msg">${t('node.pair_success')}</p>` - : pairStatus ? html`<p class="error-msg">${pairStatus}</p>` : null} - </div> - `} - ${(() => { - const hubIds = new Set((groups || []).map(g => g.id)); - return nodeGroups.map(g => { - const stale = !hubIds.has(g.id); - return html` - <div class="node-group ${stale ? 'node-group-stale' : ''}" key=${g.id}> - <div class="node-group-header"> - <h3>${g.name}${stale ? html` <span class="node-warn">${t('node.stale')}</span>` : ''}</h3> - <div class="node-group-stats"> - <span>${t('node.files', { n: g.file_count })}</span> - <span>${t('node.peers', { n: g.peers })}</span> - ${!g.has_gek && html` - <span class="node-warn">${t('node.no_gek')}</span>`} - </div> - </div> - <div class="node-roots"> - <div class="node-roots-header"> - <span class="settings-heading">${t('node.roots')}</span> - </div> - ${(g.roots || []).map(r => html` - <div class="node-root ${!r.available ? 'node-root-unavailable' : ''}" - key=${r.name}> - <div class="node-root-info"> - <span class="node-root-name"> - <${Icon} name="folder" /> - ${r.name} - </span> - ${r.upload && html` - <span class="node-root-badge">${t('node.upload_root')}</span>`} - ${!r.available && html` - <span class="node-root-badge node-root-badge-warn"> - ${t('node.unavailable')}</span>`} - </div> - ${(g.roots || []).length > 1 && !r.upload && html` - <button class="btn btn-small btn-danger" - disabled=${busy} onClick=${() => removeRoot(g.id, r.name)}> - ${t('node.remove_root')}</button>`} - </div> - `)} - <button class="btn btn-small btn-secondary" - disabled=${busy} onClick=${() => addRoot(g.id)}> - <${Icon} name="folder-plus" /> ${t('node.add_root')} - </button> - </div> - - <div class="node-section"> - <span class="settings-heading">${t('node.gek')}</span> - ${g.has_gek ? (g.visibility !== 'public' ? html` - <button class="btn btn-small btn-secondary" disabled=${busy} - onClick=${() => rotateGek(g.id)}> - ${t('node.gek_rotate')}</button> - ` : html`<p class="node-hint">${t('node.gek_public_hint')}</p>`) : html` - <p class="node-hint">${t('node.gek_init_hint')}</p> - `} - </div> - - <div class="node-section"> - <div class="node-section-header"> - <span class="settings-heading">${t('node.roster')}</span> - <button class="btn btn-small btn-secondary" disabled=${busy} - onClick=${() => loadRoster(g.id)}> - ${t('node.roster_load')}</button> - </div> - ${roster && rosterGroup === g.id && html` - <div class="node-roster"> - ${(roster.members || []).length === 0 ? html` - <p class="node-hint">${t('node.roster_empty')}</p> - ` : html` - <table class="node-table"> - <thead><tr> - <th>${t('node.roster_user')}</th> - <th>${t('node.roster_role')}</th> - <th>${t('node.roster_status')}</th> - <th>${t('node.roster_via')}</th> - <th></th> - </tr></thead> - <tbody> - ${(roster.members || []).map(m => html` - <tr key=${m.user_id}> - <td>${m.username || m.user_id.slice(0, 12)}</td> - <td>${m.role}</td> - <td>${m.status}</td> - <td>${m.pinned_via || ''}</td> - <td>${m.pk_ed25519 && m.user_id !== userId && html` - <button class="btn btn-small btn-danger" - disabled=${busy} - onClick=${() => unpinMember(m.user_id)}> - ${t('node.unpin')}</button> - `}</td> - </tr> - `)} - </tbody> - </table> - `} - ${(roster.invites || []).length > 0 && html` - <p class="node-hint">${t('node.roster_invites', - { n: roster.invites.length })}</p> - `} - </div> - `} - </div> - - ${/* Only for a group the hub no longer knows: "Remove group" here - just drops it from this node's config. While the group still - exists on the hub that would strand it with no host — deleting - it is the group Settings tab's job, which detaches and deletes - together. */''} - ${stale && html` - <div class="node-section"> - <button class="btn btn-small btn-danger" disabled=${busy} - onClick=${() => detachGroup(g.name)}> - ${t('node.detach_group')}</button> - </div> - `} - </div> - `; - }); - })()} - - <div class="node-group"> - <div class="node-section-header"> - <span class="settings-heading">${t('node.denylist')}</span> - <button class="btn btn-small btn-secondary" disabled=${busy} - onClick=${loadDenylist}> - ${t('node.denylist_load')}</button> - </div> - ${showDenylist && denylist && html` - <div class="node-denylist"> - ${denylist.count === 0 ? html` - <p class="node-hint">${t('node.denylist_empty')}</p> - ` : html` - <p class="node-hint">${t('node.denylist_count', - { n: denylist.count })}</p> - ${(denylist.users || []).map(u => html` - <div class="node-deny-entry" key=${'u:' + u}> - <span>user: ${u}</span> - <button class="btn btn-small btn-danger" disabled=${busy} - onClick=${() => clearDenylist(u)}> - ${t('node.denylist_remove')}</button> - </div> - `)} - ${(denylist.groups || []).map(g => html` - <div class="node-deny-entry" key=${'g:' + g}> - <span>group: ${g}</span> - <button class="btn btn-small btn-danger" disabled=${busy} - onClick=${() => clearDenylist(g)}> - ${t('node.denylist_remove')}</button> - </div> - `)} - ${(denylist.jtis || []).map(j => html` - <div class="node-deny-entry" key=${'j:' + j}> - <span>token: ${j.slice(0, 16)}</span> - <button class="btn btn-small btn-danger" disabled=${busy} - onClick=${() => clearDenylist(j)}> - ${t('node.denylist_remove')}</button> - </div> - `)} - <button class="btn btn-small btn-danger" disabled=${busy} - onClick=${() => clearDenylist('')}> - ${t('node.denylist_clear_all')}</button> - `} - </div> - `} - </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 && (() => { - const hostedIds = new Set(nodeGroups.map(g => g.id)); - const unhosted = (groups || []).filter(g => !hostedIds.has(g.id)); - if (!unhosted.length) return null; - return html` - <div class="node-group"> - <span class="settings-heading">${t('node.attach_group')}</span> - <div class="node-attach-form"> - <label>${t('node.attach_pick')}</label> - <select value=${attachGroup_} - onChange=${e => setAttachGroup_(e.target.value)}> - <option value="">---</option> - ${unhosted.map(g => html` - <option key=${g.id} value=${g.name}>${g.name}</option> - `)} - </select> - <label>${t('node.attach_dir')}</label> - <div class="node-attach-dir-row"> - <input type="text" placeholder=${t('node.attach_dir')} - value=${attachDir} readOnly - onInput=${e => setAttachDir(e.target.value)} /> - ${platform.rootPicker && html` - <button class="btn btn-small btn-secondary" - onClick=${() => pickAttachDir(setAttachDir)}> - <${Icon} name="folder" /></button> - `} - </div> - <label>${t('node.attach_upload_label')}</label> - <div class="node-attach-dir-row"> - <input type="text" placeholder=${t('node.attach_upload_dir')} - value=${attachUpload} readOnly - onInput=${e => setAttachUpload(e.target.value)} /> - ${platform.rootPicker && html` - <button class="btn btn-small btn-secondary" - onClick=${() => pickAttachDir(setAttachUpload)}> - <${Icon} name="folder" /></button> - `} - </div> - <button class="btn btn-primary" - disabled=${busy || !attachGroup_ || !attachDir.trim()} - onClick=${attachGroup}> - ${t('node.attach_group')}</button> - </div> - <p class="node-hint">${t('node.restart_needed')}</p> - </div> - `; - })()} - </div> - `; + if (!loaded) return html`<div class="page-content"> + <p class="page-message"><span class="spinner"></span></p></div>`; + return html`<${_NodePage} ...${props} />`; } // ── App ────────────────────────────────────────────────────────────────────── @@ -3379,8 +2653,7 @@ function App() { .catch(() => {}); }} />`; } else if (route === '/node' && platform.capabilities.nodeAdmin && hasNodeKey) { - page = html`<${NodePage} token=${user.token} username=${user.username} - userId=${user.userId} groups=${groups} />`; + page = html`<${LazyNodePage} groups=${groups} />`; } else if (route.startsWith('/group/')) { const groupId = route.slice(7); const group = groups.find(g => g.id === groupId); 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`<div class="node-service"> + <span class="spinner"></span>${' '}${t('node.service_checking')} + </div>`; + } + + 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` + <div class="node-service"> + <div class="node-service-status"> + <span class="presence presence-${dot}" title="${label}" aria-label="${label}"></span> + <span>${label}</span> + </div> + ${err && html`<div class="error-msg">${err}</div>`} + <div class="node-service-actions"> + <button class="btn btn-small btn-secondary" disabled=${!!busy || running} + onClick=${() => act('start', () => platform.node.start())}> + ${busy === 'start' ? t('node.service_starting') : t('node.service_start')}</button> + ${info.installed && html` + <button class="btn btn-small btn-secondary" disabled=${!!busy || !running} + onClick=${() => act('stop', () => platform.node.service.stop())}> + ${busy === 'stop' ? t('node.service_stopping') : t('node.service_stop')}</button> + <button class="btn btn-small btn-secondary" disabled=${!!busy} + onClick=${() => act('restart', () => platform.node.service.restart())}> + ${busy === 'restart' ? t('node.service_restarting') : t('node.service_restart')}</button> + `} + </div> + </div>`; +} + +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`<div class="page-content"> + <h2>${t('node.title')}</h2> + <${NodeServicePanel} onChanged=${fetchStatus} /> + <p class="page-message"><span class="spinner"></span>${' '}${t('status.connecting')}</p> + </div>`; + } + if (status === 'error') { + return html`<div class="page-content"> + <h2>${t('node.title')}</h2> + <${NodeServicePanel} onChanged=${fetchStatus} /> + <p class="error-msg">${error}</p> + <button class="btn btn-primary" onClick=${fetchStatus}> + ${t('node.retry')}</button> + </div>`; + } + + return html` + <div class="page-content node-page"> + <div class="node-header"> + <h2>${t('node.title')}</h2> + <button class="btn btn-small btn-secondary" disabled=${busy} + onClick=${reloadConfig}> + ${t('node.reload')}</button> + </div> + <${NodeServicePanel} onChanged=${fetchStatus} /> + ${actionMsg && html`<div class="node-message">${actionMsg}</div>`} + ${!operatorPaired && html` + <div class="node-pair-banner"> + <p>${t('node.pair_needed')}</p> + <button class="btn btn-primary btn-small" + disabled=${pairBusy} + onClick=${doPairOperator}> + ${pairBusy ? t('node.settings_saving') : t('node.pair_button')}</button> + ${pairStatus === 'paired' + ? html`<p class="success-msg">${t('node.pair_success')}</p>` + : pairStatus ? html`<p class="error-msg">${pairStatus}</p>` : null} + </div> + `} + ${(() => { + const hubIds = new Set((groups || []).map(g => g.id)); + return nodeGroups.map(g => { + const stale = !hubIds.has(g.id); + return html` + <div class="node-group ${stale ? 'node-group-stale' : ''}" key=${g.id}> + <div class="node-group-header"> + <h3>${g.name}${stale ? html` <span class="node-warn">${t('node.stale')}</span>` : ''}</h3> + <div class="node-group-stats"> + <span>${t('node.files', { n: g.file_count })}</span> + <span>${t('node.peers', { n: g.peers })}</span> + ${!g.has_gek && html` + <span class="node-warn">${t('node.no_gek')}</span>`} + </div> + </div> + <div class="node-roots"> + <div class="node-roots-header"> + <span class="settings-heading">${t('node.roots')}</span> + </div> + ${(g.roots || []).map(r => html` + <div class="node-root ${!r.available ? 'node-root-unavailable' : ''}" + key=${r.name}> + <div class="node-root-info"> + <span class="node-root-name"> + <${Icon} name="folder" /> + ${r.name} + </span> + ${r.upload && html` + <span class="node-root-badge">${t('node.upload_root')}</span>`} + ${!r.available && html` + <span class="node-root-badge node-root-badge-warn"> + ${t('node.unavailable')}</span>`} + </div> + ${(g.roots || []).length > 1 && !r.upload && html` + <button class="btn btn-small btn-danger" + disabled=${busy} onClick=${() => removeRoot(g.id, r.name)}> + ${t('node.remove_root')}</button>`} + </div> + `)} + <button class="btn btn-small btn-secondary" + disabled=${busy} onClick=${() => addRoot(g.id)}> + <${Icon} name="folder-plus" /> ${t('node.add_root')} + </button> + </div> + + <div class="node-section"> + <span class="settings-heading">${t('node.gek')}</span> + ${g.has_gek ? (g.visibility !== 'public' ? html` + <button class="btn btn-small btn-secondary" disabled=${busy} + onClick=${() => rotateGek(g.id)}> + ${t('node.gek_rotate')}</button> + ` : html`<p class="node-hint">${t('node.gek_public_hint')}</p>`) : html` + <p class="node-hint">${t('node.gek_init_hint')}</p> + `} + </div> + + <div class="node-section"> + <div class="node-section-header"> + <span class="settings-heading">${t('node.roster')}</span> + <button class="btn btn-small btn-secondary" disabled=${busy} + onClick=${() => loadRoster(g.id)}> + ${t('node.roster_load')}</button> + </div> + ${roster && rosterGroup === g.id && html` + <div class="node-roster"> + ${(roster.members || []).length === 0 ? html` + <p class="node-hint">${t('node.roster_empty')}</p> + ` : html` + <table class="node-table"> + <thead><tr> + <th>${t('node.roster_user')}</th> + <th>${t('node.roster_role')}</th> + <th>${t('node.roster_status')}</th> + <th>${t('node.roster_via')}</th> + <th></th> + </tr></thead> + <tbody> + ${(roster.members || []).map(m => html` + <tr key=${m.user_id}> + <td>${m.username || m.user_id.slice(0, 12)}</td> + <td>${m.role}</td> + <td>${m.status}</td> + <td>${m.pinned_via || ''}</td> + <td>${m.pk_ed25519 && html` + <button class="btn btn-small btn-danger" + disabled=${busy} + onClick=${() => unpinMember(m.user_id)}> + ${t('node.unpin')}</button> + `}</td> + </tr> + `)} + </tbody> + </table> + `} + ${(roster.invites || []).length > 0 && html` + <p class="node-hint">${t('node.roster_invites', + { n: roster.invites.length })}</p> + `} + </div> + `} + </div> + + ${stale && html` + <div class="node-section"> + <button class="btn btn-small btn-danger" disabled=${busy} + onClick=${() => detachGroup(g.name)}> + ${t('node.detach_group')}</button> + </div> + `} + </div> + `; + }); + })()} + + <div class="node-group"> + <div class="node-section-header"> + <span class="settings-heading">${t('node.denylist')}</span> + <button class="btn btn-small btn-secondary" disabled=${busy} + onClick=${loadDenylist}> + ${t('node.denylist_load')}</button> + </div> + ${showDenylist && denylist && html` + <div class="node-denylist"> + ${denylist.count === 0 ? html` + <p class="node-hint">${t('node.denylist_empty')}</p> + ` : html` + <p class="node-hint">${t('node.denylist_count', + { n: denylist.count })}</p> + ${(denylist.users || []).map(u => html` + <div class="node-deny-entry" key=${'u:' + u}> + <span>user: ${u}</span> + <button class="btn btn-small btn-danger" disabled=${busy} + onClick=${() => clearDenylist(u)}> + ${t('node.denylist_remove')}</button> + </div> + `)} + ${(denylist.groups || []).map(g => html` + <div class="node-deny-entry" key=${'g:' + g}> + <span>group: ${g}</span> + <button class="btn btn-small btn-danger" disabled=${busy} + onClick=${() => clearDenylist(g)}> + ${t('node.denylist_remove')}</button> + </div> + `)} + ${(denylist.jtis || []).map(j => html` + <div class="node-deny-entry" key=${'j:' + j}> + <span>token: ${j.slice(0, 16)}</span> + <button class="btn btn-small btn-danger" disabled=${busy} + onClick=${() => clearDenylist(j)}> + ${t('node.denylist_remove')}</button> + </div> + `)} + <button class="btn btn-small btn-danger" disabled=${busy} + onClick=${() => clearDenylist('')}> + ${t('node.denylist_clear_all')}</button> + `} + </div> + `} + </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> + `} + </div> + `; +} |