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 --- packages/meshbay-hub/src/meshbay_hub/static/app.js | 747 +-------------------- .../src/meshbay_hub/static/node-page.js | 564 ++++++++++++++++ 2 files changed, 574 insertions(+), 737 deletions(-) create mode 100644 packages/meshbay-hub/src/meshbay_hub/static/node-page.js 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(''); - } - }, [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` - - - `} -
-
`; -} - -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; + if (!_NodePage) { + import('./node-page.js').then(m => { _NodePage = m.NodePage; setLoaded(true); }); } - 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`
-

${t('node.title')}

- <${NodeServicePanel} onChanged=${connectAndFetch} /> -

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

-
`; - } - if (status === 'no_groups') { - return html`
-

${t('node.title')}

- <${NodeServicePanel} onChanged=${connectAndFetch} /> -

${t('node.no_groups')}

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

${t('node.title')}

- <${NodeServicePanel} onChanged=${connectAndFetch} /> -

${error}

- -
`; - } - - return html` -
-
-

${t('node.title')}

- -
- <${NodeServicePanel} onChanged=${connectAndFetch} /> - ${actionMsg && html`
${actionMsg}
`} - ${!operatorPaired && html` -
-

${t('node.pair_needed')}

-
- setPairCode(e.target.value)} - disabled=${pairing} /> - -
- ${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 && m.user_id !== userId && html` - - `}
- `} - ${(roster.invites || []).length > 0 && html` -

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

- `} -
- `} -
- - ${/* 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` -
- -
- `} -
- `; - }); - })()} - -
-
- ${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')}

-
- - - - - -
-
- -
-
- `} - - ${/* 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` -
- ${t('node.attach_group')} -
- - - -
- setAttachDir(e.target.value)} /> - ${platform.rootPicker && html` - - `} -
- -
- setAttachUpload(e.target.value)} /> - ${platform.rootPicker && html` - - `} -
- -
-

${t('node.restart_needed')}

-
- `; - })()} -
- `; + if (!loaded) return html`
+

`; + 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`
+ ${' '}${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