aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/node-page.js
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-30 21:07:09 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-30 21:07:18 +0200
commitbf56b60b95c5413d592f0199320d637978792756 (patch)
treed8f4b4dcb386378a7881954418ce11aec745bf79 /packages/meshbay-hub/src/meshbay_hub/static/node-page.js
parentd5fc45ff907e618d884a8d2d91c1f2b45e6898d1 (diff)
downloadmeshbay-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/meshbay-hub/src/meshbay_hub/static/node-page.js')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/node-page.js564
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>
+ `;
+}