diff options
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/node-page.js')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/node-page.js | 564 |
1 files changed, 564 insertions, 0 deletions
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> + `; +} |