From cfc91e0a424163869c64d30e55d55a53f18a3dbf Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Tue, 1 Sep 2026 14:08:32 +0200 Subject: refactor(node): JSON-only control API, Node page absorbs the admin dashboard Remove the node daemon's server-rendered admin UI (GET / and /audit, the _render_* helpers and inline templates) and the `meshbay-node ui` CLI verb. The loopback control API stays; it is now JSON only, ruff-clean, and 453 lines (was 1074). Also drop three never-wired endpoints (/api/config, /api/chat/history, /ws/chat, plus broadcast_chat) and the pointless 18000/tcp firewall profiles. The desktop client's Node page (static/node-page.js) takes over what the dashboard showed, reorganised into six tabs (Overview, Groups, Roster, Peers, Audit, Settings): - Overview: version, node id, QUIC port, hub, index-cache maintenance - Roster: node-wide view with unpin - Peers and Audit: auto-load on open, no Load button - Audit: real usernames and group names (resolved from the roster and node.toml), Previous/Next pagination newest-first, Export CSV of every matching row - Settings: node settings, STUN, ICE, denylist, then Unlink from hub Backend: audit.get_entries gains `offset`; /api/audit and /api/peers resolve ids to names via a new _display_names helper; CSP tightened to default-src 'none' now that no HTML is served. draft-v6 sections 2.11 and 2.12 corrected -- the Node page uses the loopback API, not MNP. One capability is intentionally dropped: browser-based admin on a headless server. The CLI covers every operation there. See docs/refactor-node-ui.md. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01MQCaZnde4Bjjdu84dhSuF5 --- .../src/meshbay_hub/static/node-page.js | 476 +++++++++++++++++++-- 1 file changed, 433 insertions(+), 43 deletions(-) (limited to 'packages/meshbay-hub/src/meshbay_hub/static/node-page.js') diff --git a/packages/meshbay-hub/src/meshbay_hub/static/node-page.js b/packages/meshbay-hub/src/meshbay_hub/static/node-page.js index 82fc56e..8df4c9d 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/node-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/node-page.js @@ -89,6 +89,30 @@ async function nodeCall(method, path, body) { return platform.node.call(method, path, body); } +// Hand a generated file to the user: native Save As on the desktop, a blob +// download link everywhere else. Small text only (CSV export) — not the +// large-file download path in file-utils.js. +async function saveCsv(filename, text) { + const bytes = new TextEncoder().encode(text); + try { + const target = await platform.nativeSave(filename, { auto: false }); + if (target && target.writable) { + if (target.open) await target.open(); + await target.writable.write(bytes); + await target.writable.close(); + return; + } + } catch { /* fall through to the blob path */ } + const url = URL.createObjectURL(new Blob([bytes], { type: 'text/csv' })); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); +} + export function NodePage({ groups }) { const [status, setStatus] = useState('idle'); const [error, setError] = useState(''); @@ -111,6 +135,18 @@ export function NodePage({ groups }) { const [operatorPaired, setOperatorPaired] = useState(false); const [pairBusy, setPairBusy] = useState(false); const [pairStatus, setPairStatus] = useState(''); + const [nodeInfo, setNodeInfo] = useState(null); + const [peers, setPeers] = useState(null); + const [audit, setAudit] = useState(null); + const [auditEvent, setAuditEvent] = useState(''); + const [auditPageSize, setAuditPageSize] = useState(100); + const [auditPage, setAuditPage] = useState(0); + const [auditHasMore, setAuditHasMore] = useState(false); + const [auditExporting, setAuditExporting] = useState(false); + const [cacheCount, setCacheCount] = useState(null); + const [nodeRoster, setNodeRoster] = useState(null); + const [unlinkBusy, setUnlinkBusy] = useState(false); + const [tab, setTab] = useState('overview'); const fetchStatus = useCallback(async () => { setStatus('connecting'); @@ -126,6 +162,7 @@ export function NodePage({ groups }) { setNodeGroups(result.groups || []); setOperatorPaired(!!result.operator_paired); setNodeSettings(result.settings || null); + try { setNodeInfo(await nodeCall('GET', '/api/status')); } catch {} setStatus('connected'); } catch (err) { setError(platform.bridgeMessage(err)); @@ -147,6 +184,7 @@ export function NodePage({ groups }) { setNodeGroups(result.groups || []); setOperatorPaired(!!result.operator_paired); setNodeSettings(result.settings || null); + try { setNodeInfo(await nodeCall('GET', '/api/status')); } catch {} } catch {} }, []); @@ -408,6 +446,163 @@ export function NodePage({ groups }) { } }, [refresh]); + const loadPeers = useCallback(async () => { + setBusy(true); + try { + const r = await nodeCall('GET', '/api/peers'); + setPeers(r.peers || []); + } catch (err) { + setActionMsg(platform.bridgeMessage(err)); + } finally { + setBusy(false); + } + }, []); + + const loadAudit = useCallback(async () => { + setBusy(true); + try { + const offset = auditPage * auditPageSize; + let path = `/api/audit?limit=${auditPageSize}&offset=${offset}`; + if (auditEvent) path += `&event=${encodeURIComponent(auditEvent)}`; + const r = await nodeCall('GET', path); + setAudit(r.entries || []); + setAuditHasMore(!!r.has_more); + } catch (err) { + setActionMsg(platform.bridgeMessage(err)); + } finally { + setBusy(false); + } + }, [auditEvent, auditPage, auditPageSize]); + + const exportAuditCsv = useCallback(async () => { + setAuditExporting(true); + setActionMsg(''); + try { + // Every entry that matches the current event filter, not just one page. + // Walk the log newest-first in blocks; de-dupe by id so an event written + // mid-export (which shifts rows to a higher offset) cannot duplicate one. + const PAGE = 1000; + const MAX_PAGES = 1000; // 1M-row stop, so a bug cannot spin forever + const evq = auditEvent ? `&event=${encodeURIComponent(auditEvent)}` : ''; + const seen = new Set(); + const rows = []; + for (let page = 0; page < MAX_PAGES; page++) { + const r = await nodeCall( + 'GET', `/api/audit?limit=${PAGE}&offset=${page * PAGE}${evq}`); + const batch = r.entries || []; + for (const e of batch) { + if (!seen.has(e.id)) { seen.add(e.id); rows.push(e); } + } + if (!r.has_more || batch.length === 0) break; + } + + const cols = ['timestamp', 'event', 'user', 'user_id', 'ip', + 'group', 'group_id', 'detail']; + const esc = (v) => { + const s = v == null ? '' : String(v); + return /[",\n\r]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s; + }; + const lines = [cols.join(',')]; + for (const e of rows) { + lines.push([ + new Date(e.timestamp * 1000).toISOString(), + e.event, e.username || '', e.user_id || '', e.ip || '', + e.group_name || '', e.group_id || '', e.detail || '', + ].map(esc).join(',')); + } + const csv = lines.join('\r\n') + '\r\n'; + const stamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-'); + await saveCsv(`node-audit-${stamp}.csv`, csv); + setActionMsg(t('node.audit_exported', { n: rows.length })); + } catch (err) { + setActionMsg(platform.bridgeMessage(err)); + } finally { + setAuditExporting(false); + } + }, [auditEvent]); + + const loadCache = useCallback(async () => { + setBusy(true); + try { + const r = await nodeCall('GET', '/api/index-cache'); + setCacheCount(r.count ?? 0); + } catch (err) { + setActionMsg(platform.bridgeMessage(err)); + } finally { + setBusy(false); + } + }, []); + + const pruneCache = useCallback(async () => { + setBusy(true); + setActionMsg(''); + try { + const r = await nodeCall('POST', '/api/index-cache/prune'); + setCacheCount(r.kept ?? null); + setActionMsg(t('node.maintenance_pruned', { n: r.removed ?? 0 })); + } catch (err) { + setActionMsg(platform.bridgeMessage(err)); + } finally { + setBusy(false); + } + }, []); + + const loadNodeRoster = useCallback(async () => { + setBusy(true); + try { + const r = await nodeCall('GET', '/api/roster'); + setNodeRoster(r); + } catch (err) { + setActionMsg(platform.bridgeMessage(err)); + } finally { + setBusy(false); + } + }, []); + + const unpinNodeMember = 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')); + await loadNodeRoster(); + } catch (err) { + setActionMsg(platform.bridgeMessage(err)); + } finally { + setBusy(false); + } + }, [loadNodeRoster]); + + const unlinkNode = useCallback(async () => { + if (!confirm(t('node.unlink_confirm'))) return; + setUnlinkBusy(true); + setActionMsg(''); + try { + await nodeCall('DELETE', '/api/unlink'); + setActionMsg(t('node.unlink_done')); + await refresh(); + } catch (err) { + setActionMsg(platform.bridgeMessage(err)); + } finally { + setUnlinkBusy(false); + } + }, [refresh]); + + // Each read-only tab loads itself on open — no Load button. + useEffect(() => { + if (status !== 'connected') return; + if (tab === 'overview') loadCache(); + else if (tab === 'roster') loadNodeRoster(); + else if (tab === 'peers') loadPeers(); + }, [tab, status, loadCache, loadNodeRoster, loadPeers]); + + // Audit reloads on tab open and whenever the filter or page changes + // (loadAudit's identity tracks auditEvent / auditPage / auditPageSize). + useEffect(() => { + if (status === 'connected' && tab === 'audit') loadAudit(); + }, [tab, status, loadAudit]); + if (status === 'idle' || status === 'connecting') { return html`

${t('node.title')}

@@ -447,7 +642,29 @@ export function NodePage({ groups }) { : pairStatus ? html`

${pairStatus}

` : null}
`} - ${(() => { +
+ ${['overview', 'groups', 'roster', 'peers', 'audit', 'settings'].map(k => html` + + `)} +
+ + ${tab === 'overview' && nodeInfo && html` +
+ ${t('node.overview')} + + + + + + + + +
${t('node.overview_version')}${nodeInfo.version || '—'}
${t('node.overview_node_id')}${nodeInfo.endpoint_hint || nodeInfo.pk_node_ed25519 || '—'}
${t('node.overview_quic_port')}${nodeInfo.quic_port || '—'}
${t('node.overview_hub')}${nodeInfo.hub_url || '—'}
+
+ `} + + ${tab === 'groups' && (() => { const hubIds = new Set((groups || []).map(g => g.id)); return nodeGroups.map(g => { const stale = !hubIds.has(g.id); @@ -561,53 +778,169 @@ export function NodePage({ groups }) { }); })()} + ${tab === 'roster' && html` +
+ ${t('node.node_roster')} +

${t('node.node_roster_hint')}

+ ${!nodeRoster ? html` +

${t('node.loading')}

+ ` : (nodeRoster.members || []).length === 0 ? html` +

${t('node.roster_empty')}

+ ` : html` +
+ + + + + + + + + + + ${(nodeRoster.members || []).map(m => html` + + + + + + + + + `)} + +
${t('node.roster_user')}${t('node.roster_role')}${t('node.roster_status')}${t('node.roster_scope')}${t('node.roster_via')}
${m.username || m.user_id.slice(0, 12)}${m.role}${m.status}${m.group_id ? m.group_id.slice(0, 8) : t('node.roster_scope_node')}${m.pinned_via || ''}${m.pk_ed25519 && html` + + `}
+
+ `} +
+ `} + + ${tab === 'peers' && html` +
+ ${t('node.peers_title')} + ${!peers ? html` +

${t('node.loading')}

+ ` : peers.length === 0 ? html` +

${t('node.peers_empty')}

+ ` : html` +
+ + + + + + + + + ${peers.map(p => html` + + + + + + + `)} + +
${t('node.peers_user')}${t('node.peers_ip')}${t('node.peers_group')}${t('node.peers_state')}
${p.username || (p.user_id || '').slice(0, 12) || '—'}${p.remote_ip || '—'}${p.group_name || (p.group_id ? p.group_id.slice(0, 8) : '—')}${p.state || '—'}
+
+ `} +
+ `} + + ${tab === 'audit' && html`
- ${t('node.denylist')} - + ${t('node.audit_title')} +
- ${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)} - -
- `)} - - `} +
+ + +
+ ${!audit ? html` +

${t('node.loading')}

+ ` : audit.length === 0 ? html` +

${t('node.audit_empty')}

+ ` : html` +
+ + + + + + + + + + + ${audit.map(e => html` + + + + + + + + + `)} + +
${t('node.audit_time')}${t('node.audit_event')}${t('node.audit_user')}${t('node.audit_ip')}${t('node.audit_group')}${t('node.audit_detail')}
${new Date(e.timestamp * 1000).toLocaleString()}${e.event}${e.username || (e.user_id || '').slice(0, 8)}${e.ip || '—'}${e.group_name || (e.group_id ? e.group_id.slice(0, 8) : '—')}${e.detail || ''}
+
+ `} + ${audit && html` +
+ + ${t('node.audit_page', { n: auditPage + 1 })} +
`}
+ `} + + ${tab === 'overview' && html` +
+ ${t('node.maintenance')} + ${cacheCount === null ? html` +

${t('node.loading')}

+ ` : html` +

${t('node.maintenance_cache', { n: cacheCount })}

+ + `} +
+ `} - ${editSettings && html` + ${tab === 'settings' && editSettings && html`
${t('node.settings')}

${t('node.settings_edit_hint')}

@@ -659,7 +992,7 @@ export function NodePage({ groups }) {
`} - ${editStun && html` + ${tab === 'settings' && editStun && html`
${t('node.stun_servers')}

${t('node.stun_hint')}

@@ -704,7 +1037,7 @@ export function NodePage({ groups }) {
`} - ${editIce && html` + ${tab === 'settings' && editIce && html`
${t('node.ice_interfaces')}

${editIce.length === 0 @@ -744,6 +1077,63 @@ export function NodePage({ groups }) {

`} + + ${tab === 'settings' && 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)} + +
+ `)} + + `} +
+ `} +
+ `} + + ${tab === 'settings' && nodeInfo && nodeInfo.status !== 'waiting_for_node_key' && html` +
+ ${t('node.unlink_title')} +

${t('node.unlink_hint')}

+ +
+ `} `; } -- cgit v1.2.3