aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static
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
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')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js747
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/node-page.js564
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>
+ `;
+}