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/locales/de.js | 46 ++ .../src/meshbay_hub/static/locales/en.js | 46 ++ .../src/meshbay_hub/static/locales/es.js | 46 ++ .../src/meshbay_hub/static/locales/fr.js | 46 ++ .../src/meshbay_hub/static/locales/it.js | 46 ++ .../src/meshbay_hub/static/locales/ja.js | 46 ++ .../src/meshbay_hub/static/locales/nl.js | 46 ++ .../src/meshbay_hub/static/locales/pl.js | 46 ++ .../src/meshbay_hub/static/locales/pt-BR.js | 46 ++ .../src/meshbay_hub/static/locales/zh-CN.js | 46 ++ .../src/meshbay_hub/static/node-page.js | 476 +++++++++++++++++++-- .../meshbay-hub/src/meshbay_hub/static/style.css | 25 ++ 12 files changed, 918 insertions(+), 43 deletions(-) (limited to 'packages/meshbay-hub/src') diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js index 3253372..6c03726 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js @@ -847,4 +847,50 @@ export default { 'node.ice_saved': 'ICE interfaces saved.', 'node.ice_reset_auto': 'Switch to auto', 'node.ice_duplicate': 'This interface is already in the list.', + 'node.overview': 'Overview', + 'node.overview_version': 'Version', + 'node.overview_node_id': 'Node ID', + 'node.overview_quic_port': 'QUIC port', + 'node.overview_hub': 'Hub', + 'node.node_roster': 'Node roster', + 'node.node_roster_hint': 'Every identity this node recognises, across all groups.', + 'node.roster_scope': 'Scope', + 'node.roster_scope_node': 'node-wide', + 'node.peers_title': 'Connected peers', + 'node.peers_empty': 'No peers connected.', + 'node.peers_user': 'User', + 'node.peers_ip': 'IP', + 'node.peers_group': 'Group', + 'node.peers_state': 'State', + 'node.audit_title': 'Audit log', + 'node.audit_all_events': 'All events', + 'node.audit_empty': 'No audit entries.', + 'node.audit_time': 'Time', + 'node.audit_event': 'Event', + 'node.audit_user': 'User', + 'node.audit_ip': 'IP', + 'node.audit_group': 'Group', + 'node.audit_detail': 'Detail', + 'node.maintenance': 'Maintenance', + 'node.maintenance_cache': 'Index cache: {n} path(s) remembered.', + 'node.maintenance_prune': 'Prune stale entries', + 'node.maintenance_pruned': 'Pruned {n} stale cache entries.', + 'node.loading': 'Loading…', + 'node.audit_export': 'Export CSV', + 'node.audit_exporting': 'Exporting…', + 'node.audit_prev': 'Previous', + 'node.audit_next': 'Next', + 'node.audit_page': 'Page {n}', + 'node.audit_exported': 'Exported {n} entries.', + 'node.unlink_title': 'Unlink from hub', + 'node.unlink_hint': 'Stops the hub from brokering connections to this node and listing it for group members. Your groups, keys, roster and files are untouched — the node keeps serving anyone who reaches it directly. Re-link any time from the group wizard or your profile.', + 'node.unlink_button': 'Unlink this node', + 'node.unlink_confirm': 'Unlink this node from the hub? The hub will stop routing members to it. Group data on disk is not affected.', + 'node.unlink_done': 'Node unlinked from the hub. It may take a moment for the hub connection to drop.', + 'node.tab_overview': 'Overview', + 'node.tab_groups': 'Groups', + 'node.tab_roster': 'Roster', + 'node.tab_peers': 'Peers', + 'node.tab_audit': 'Audit', + 'node.tab_settings': 'Settings', }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js index aae9031..88dca15 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js @@ -826,4 +826,50 @@ export default { 'node.ice_saved': 'ICE interfaces saved.', 'node.ice_reset_auto': 'Switch to auto', 'node.ice_duplicate': 'This interface is already in the list.', + 'node.overview': 'Overview', + 'node.overview_version': 'Version', + 'node.overview_node_id': 'Node ID', + 'node.overview_quic_port': 'QUIC port', + 'node.overview_hub': 'Hub', + 'node.node_roster': 'Node roster', + 'node.node_roster_hint': 'Every identity this node recognises, across all groups.', + 'node.roster_scope': 'Scope', + 'node.roster_scope_node': 'node-wide', + 'node.peers_title': 'Connected peers', + 'node.peers_empty': 'No peers connected.', + 'node.peers_user': 'User', + 'node.peers_ip': 'IP', + 'node.peers_group': 'Group', + 'node.peers_state': 'State', + 'node.audit_title': 'Audit log', + 'node.audit_all_events': 'All events', + 'node.audit_empty': 'No audit entries.', + 'node.audit_time': 'Time', + 'node.audit_event': 'Event', + 'node.audit_user': 'User', + 'node.audit_ip': 'IP', + 'node.audit_group': 'Group', + 'node.audit_detail': 'Detail', + 'node.maintenance': 'Maintenance', + 'node.maintenance_cache': 'Index cache: {n} path(s) remembered.', + 'node.maintenance_prune': 'Prune stale entries', + 'node.maintenance_pruned': 'Pruned {n} stale cache entries.', + 'node.loading': 'Loading…', + 'node.audit_export': 'Export CSV', + 'node.audit_exporting': 'Exporting…', + 'node.audit_prev': 'Previous', + 'node.audit_next': 'Next', + 'node.audit_page': 'Page {n}', + 'node.audit_exported': 'Exported {n} entries.', + 'node.unlink_title': 'Unlink from hub', + 'node.unlink_hint': 'Stops the hub from brokering connections to this node and listing it for group members. Your groups, keys, roster and files are untouched — the node keeps serving anyone who reaches it directly. Re-link any time from the group wizard or your profile.', + 'node.unlink_button': 'Unlink this node', + 'node.unlink_confirm': 'Unlink this node from the hub? The hub will stop routing members to it. Group data on disk is not affected.', + 'node.unlink_done': 'Node unlinked from the hub. It may take a moment for the hub connection to drop.', + 'node.tab_overview': 'Overview', + 'node.tab_groups': 'Groups', + 'node.tab_roster': 'Roster', + 'node.tab_peers': 'Peers', + 'node.tab_audit': 'Audit', + 'node.tab_settings': 'Settings', }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js index 4f80fe9..cf0d6d4 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js @@ -843,4 +843,50 @@ export default { 'node.ice_saved': 'ICE interfaces saved.', 'node.ice_reset_auto': 'Switch to auto', 'node.ice_duplicate': 'This interface is already in the list.', + 'node.overview': 'Overview', + 'node.overview_version': 'Version', + 'node.overview_node_id': 'Node ID', + 'node.overview_quic_port': 'QUIC port', + 'node.overview_hub': 'Hub', + 'node.node_roster': 'Node roster', + 'node.node_roster_hint': 'Every identity this node recognises, across all groups.', + 'node.roster_scope': 'Scope', + 'node.roster_scope_node': 'node-wide', + 'node.peers_title': 'Connected peers', + 'node.peers_empty': 'No peers connected.', + 'node.peers_user': 'User', + 'node.peers_ip': 'IP', + 'node.peers_group': 'Group', + 'node.peers_state': 'State', + 'node.audit_title': 'Audit log', + 'node.audit_all_events': 'All events', + 'node.audit_empty': 'No audit entries.', + 'node.audit_time': 'Time', + 'node.audit_event': 'Event', + 'node.audit_user': 'User', + 'node.audit_ip': 'IP', + 'node.audit_group': 'Group', + 'node.audit_detail': 'Detail', + 'node.maintenance': 'Maintenance', + 'node.maintenance_cache': 'Index cache: {n} path(s) remembered.', + 'node.maintenance_prune': 'Prune stale entries', + 'node.maintenance_pruned': 'Pruned {n} stale cache entries.', + 'node.loading': 'Loading…', + 'node.audit_export': 'Export CSV', + 'node.audit_exporting': 'Exporting…', + 'node.audit_prev': 'Previous', + 'node.audit_next': 'Next', + 'node.audit_page': 'Page {n}', + 'node.audit_exported': 'Exported {n} entries.', + 'node.unlink_title': 'Unlink from hub', + 'node.unlink_hint': 'Stops the hub from brokering connections to this node and listing it for group members. Your groups, keys, roster and files are untouched — the node keeps serving anyone who reaches it directly. Re-link any time from the group wizard or your profile.', + 'node.unlink_button': 'Unlink this node', + 'node.unlink_confirm': 'Unlink this node from the hub? The hub will stop routing members to it. Group data on disk is not affected.', + 'node.unlink_done': 'Node unlinked from the hub. It may take a moment for the hub connection to drop.', + 'node.tab_overview': 'Overview', + 'node.tab_groups': 'Groups', + 'node.tab_roster': 'Roster', + 'node.tab_peers': 'Peers', + 'node.tab_audit': 'Audit', + 'node.tab_settings': 'Settings', }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js index 9b9c5b2..138c2cc 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js @@ -858,4 +858,50 @@ export default { 'node.ice_saved': 'ICE interfaces saved.', 'node.ice_reset_auto': 'Switch to auto', 'node.ice_duplicate': 'This interface is already in the list.', + 'node.overview': 'Overview', + 'node.overview_version': 'Version', + 'node.overview_node_id': 'Node ID', + 'node.overview_quic_port': 'QUIC port', + 'node.overview_hub': 'Hub', + 'node.node_roster': 'Node roster', + 'node.node_roster_hint': 'Every identity this node recognises, across all groups.', + 'node.roster_scope': 'Scope', + 'node.roster_scope_node': 'node-wide', + 'node.peers_title': 'Connected peers', + 'node.peers_empty': 'No peers connected.', + 'node.peers_user': 'User', + 'node.peers_ip': 'IP', + 'node.peers_group': 'Group', + 'node.peers_state': 'State', + 'node.audit_title': 'Audit log', + 'node.audit_all_events': 'All events', + 'node.audit_empty': 'No audit entries.', + 'node.audit_time': 'Time', + 'node.audit_event': 'Event', + 'node.audit_user': 'User', + 'node.audit_ip': 'IP', + 'node.audit_group': 'Group', + 'node.audit_detail': 'Detail', + 'node.maintenance': 'Maintenance', + 'node.maintenance_cache': 'Index cache: {n} path(s) remembered.', + 'node.maintenance_prune': 'Prune stale entries', + 'node.maintenance_pruned': 'Pruned {n} stale cache entries.', + 'node.loading': 'Loading…', + 'node.audit_export': 'Export CSV', + 'node.audit_exporting': 'Exporting…', + 'node.audit_prev': 'Previous', + 'node.audit_next': 'Next', + 'node.audit_page': 'Page {n}', + 'node.audit_exported': 'Exported {n} entries.', + 'node.unlink_title': 'Unlink from hub', + 'node.unlink_hint': 'Stops the hub from brokering connections to this node and listing it for group members. Your groups, keys, roster and files are untouched — the node keeps serving anyone who reaches it directly. Re-link any time from the group wizard or your profile.', + 'node.unlink_button': 'Unlink this node', + 'node.unlink_confirm': 'Unlink this node from the hub? The hub will stop routing members to it. Group data on disk is not affected.', + 'node.unlink_done': 'Node unlinked from the hub. It may take a moment for the hub connection to drop.', + 'node.tab_overview': 'Overview', + 'node.tab_groups': 'Groups', + 'node.tab_roster': 'Roster', + 'node.tab_peers': 'Peers', + 'node.tab_audit': 'Audit', + 'node.tab_settings': 'Settings', }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js index 785c510..21a7fca 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js @@ -857,4 +857,50 @@ export default { 'node.ice_saved': 'ICE interfaces saved.', 'node.ice_reset_auto': 'Switch to auto', 'node.ice_duplicate': 'This interface is already in the list.', + 'node.overview': 'Overview', + 'node.overview_version': 'Version', + 'node.overview_node_id': 'Node ID', + 'node.overview_quic_port': 'QUIC port', + 'node.overview_hub': 'Hub', + 'node.node_roster': 'Node roster', + 'node.node_roster_hint': 'Every identity this node recognises, across all groups.', + 'node.roster_scope': 'Scope', + 'node.roster_scope_node': 'node-wide', + 'node.peers_title': 'Connected peers', + 'node.peers_empty': 'No peers connected.', + 'node.peers_user': 'User', + 'node.peers_ip': 'IP', + 'node.peers_group': 'Group', + 'node.peers_state': 'State', + 'node.audit_title': 'Audit log', + 'node.audit_all_events': 'All events', + 'node.audit_empty': 'No audit entries.', + 'node.audit_time': 'Time', + 'node.audit_event': 'Event', + 'node.audit_user': 'User', + 'node.audit_ip': 'IP', + 'node.audit_group': 'Group', + 'node.audit_detail': 'Detail', + 'node.maintenance': 'Maintenance', + 'node.maintenance_cache': 'Index cache: {n} path(s) remembered.', + 'node.maintenance_prune': 'Prune stale entries', + 'node.maintenance_pruned': 'Pruned {n} stale cache entries.', + 'node.loading': 'Loading…', + 'node.audit_export': 'Export CSV', + 'node.audit_exporting': 'Exporting…', + 'node.audit_prev': 'Previous', + 'node.audit_next': 'Next', + 'node.audit_page': 'Page {n}', + 'node.audit_exported': 'Exported {n} entries.', + 'node.unlink_title': 'Unlink from hub', + 'node.unlink_hint': 'Stops the hub from brokering connections to this node and listing it for group members. Your groups, keys, roster and files are untouched — the node keeps serving anyone who reaches it directly. Re-link any time from the group wizard or your profile.', + 'node.unlink_button': 'Unlink this node', + 'node.unlink_confirm': 'Unlink this node from the hub? The hub will stop routing members to it. Group data on disk is not affected.', + 'node.unlink_done': 'Node unlinked from the hub. It may take a moment for the hub connection to drop.', + 'node.tab_overview': 'Overview', + 'node.tab_groups': 'Groups', + 'node.tab_roster': 'Roster', + 'node.tab_peers': 'Peers', + 'node.tab_audit': 'Audit', + 'node.tab_settings': 'Settings', }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js index 22792ce..e74750f 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js @@ -841,4 +841,50 @@ export default { 'node.ice_saved': 'ICE interfaces saved.', 'node.ice_reset_auto': 'Switch to auto', 'node.ice_duplicate': 'This interface is already in the list.', + 'node.overview': 'Overview', + 'node.overview_version': 'Version', + 'node.overview_node_id': 'Node ID', + 'node.overview_quic_port': 'QUIC port', + 'node.overview_hub': 'Hub', + 'node.node_roster': 'Node roster', + 'node.node_roster_hint': 'Every identity this node recognises, across all groups.', + 'node.roster_scope': 'Scope', + 'node.roster_scope_node': 'node-wide', + 'node.peers_title': 'Connected peers', + 'node.peers_empty': 'No peers connected.', + 'node.peers_user': 'User', + 'node.peers_ip': 'IP', + 'node.peers_group': 'Group', + 'node.peers_state': 'State', + 'node.audit_title': 'Audit log', + 'node.audit_all_events': 'All events', + 'node.audit_empty': 'No audit entries.', + 'node.audit_time': 'Time', + 'node.audit_event': 'Event', + 'node.audit_user': 'User', + 'node.audit_ip': 'IP', + 'node.audit_group': 'Group', + 'node.audit_detail': 'Detail', + 'node.maintenance': 'Maintenance', + 'node.maintenance_cache': 'Index cache: {n} path(s) remembered.', + 'node.maintenance_prune': 'Prune stale entries', + 'node.maintenance_pruned': 'Pruned {n} stale cache entries.', + 'node.loading': 'Loading…', + 'node.audit_export': 'Export CSV', + 'node.audit_exporting': 'Exporting…', + 'node.audit_prev': 'Previous', + 'node.audit_next': 'Next', + 'node.audit_page': 'Page {n}', + 'node.audit_exported': 'Exported {n} entries.', + 'node.unlink_title': 'Unlink from hub', + 'node.unlink_hint': 'Stops the hub from brokering connections to this node and listing it for group members. Your groups, keys, roster and files are untouched — the node keeps serving anyone who reaches it directly. Re-link any time from the group wizard or your profile.', + 'node.unlink_button': 'Unlink this node', + 'node.unlink_confirm': 'Unlink this node from the hub? The hub will stop routing members to it. Group data on disk is not affected.', + 'node.unlink_done': 'Node unlinked from the hub. It may take a moment for the hub connection to drop.', + 'node.tab_overview': 'Overview', + 'node.tab_groups': 'Groups', + 'node.tab_roster': 'Roster', + 'node.tab_peers': 'Peers', + 'node.tab_audit': 'Audit', + 'node.tab_settings': 'Settings', }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js index 01663c3..9f15053 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js @@ -859,4 +859,50 @@ export default { 'node.ice_saved': 'ICE interfaces saved.', 'node.ice_reset_auto': 'Switch to auto', 'node.ice_duplicate': 'This interface is already in the list.', + 'node.overview': 'Overview', + 'node.overview_version': 'Version', + 'node.overview_node_id': 'Node ID', + 'node.overview_quic_port': 'QUIC port', + 'node.overview_hub': 'Hub', + 'node.node_roster': 'Node roster', + 'node.node_roster_hint': 'Every identity this node recognises, across all groups.', + 'node.roster_scope': 'Scope', + 'node.roster_scope_node': 'node-wide', + 'node.peers_title': 'Connected peers', + 'node.peers_empty': 'No peers connected.', + 'node.peers_user': 'User', + 'node.peers_ip': 'IP', + 'node.peers_group': 'Group', + 'node.peers_state': 'State', + 'node.audit_title': 'Audit log', + 'node.audit_all_events': 'All events', + 'node.audit_empty': 'No audit entries.', + 'node.audit_time': 'Time', + 'node.audit_event': 'Event', + 'node.audit_user': 'User', + 'node.audit_ip': 'IP', + 'node.audit_group': 'Group', + 'node.audit_detail': 'Detail', + 'node.maintenance': 'Maintenance', + 'node.maintenance_cache': 'Index cache: {n} path(s) remembered.', + 'node.maintenance_prune': 'Prune stale entries', + 'node.maintenance_pruned': 'Pruned {n} stale cache entries.', + 'node.loading': 'Loading…', + 'node.audit_export': 'Export CSV', + 'node.audit_exporting': 'Exporting…', + 'node.audit_prev': 'Previous', + 'node.audit_next': 'Next', + 'node.audit_page': 'Page {n}', + 'node.audit_exported': 'Exported {n} entries.', + 'node.unlink_title': 'Unlink from hub', + 'node.unlink_hint': 'Stops the hub from brokering connections to this node and listing it for group members. Your groups, keys, roster and files are untouched — the node keeps serving anyone who reaches it directly. Re-link any time from the group wizard or your profile.', + 'node.unlink_button': 'Unlink this node', + 'node.unlink_confirm': 'Unlink this node from the hub? The hub will stop routing members to it. Group data on disk is not affected.', + 'node.unlink_done': 'Node unlinked from the hub. It may take a moment for the hub connection to drop.', + 'node.tab_overview': 'Overview', + 'node.tab_groups': 'Groups', + 'node.tab_roster': 'Roster', + 'node.tab_peers': 'Peers', + 'node.tab_audit': 'Audit', + 'node.tab_settings': 'Settings', }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js index 2bc29ee..069d642 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js @@ -885,4 +885,50 @@ export default { 'node.ice_saved': 'ICE interfaces saved.', 'node.ice_reset_auto': 'Switch to auto', 'node.ice_duplicate': 'This interface is already in the list.', + 'node.overview': 'Overview', + 'node.overview_version': 'Version', + 'node.overview_node_id': 'Node ID', + 'node.overview_quic_port': 'QUIC port', + 'node.overview_hub': 'Hub', + 'node.node_roster': 'Node roster', + 'node.node_roster_hint': 'Every identity this node recognises, across all groups.', + 'node.roster_scope': 'Scope', + 'node.roster_scope_node': 'node-wide', + 'node.peers_title': 'Connected peers', + 'node.peers_empty': 'No peers connected.', + 'node.peers_user': 'User', + 'node.peers_ip': 'IP', + 'node.peers_group': 'Group', + 'node.peers_state': 'State', + 'node.audit_title': 'Audit log', + 'node.audit_all_events': 'All events', + 'node.audit_empty': 'No audit entries.', + 'node.audit_time': 'Time', + 'node.audit_event': 'Event', + 'node.audit_user': 'User', + 'node.audit_ip': 'IP', + 'node.audit_group': 'Group', + 'node.audit_detail': 'Detail', + 'node.maintenance': 'Maintenance', + 'node.maintenance_cache': 'Index cache: {n} path(s) remembered.', + 'node.maintenance_prune': 'Prune stale entries', + 'node.maintenance_pruned': 'Pruned {n} stale cache entries.', + 'node.loading': 'Loading…', + 'node.audit_export': 'Export CSV', + 'node.audit_exporting': 'Exporting…', + 'node.audit_prev': 'Previous', + 'node.audit_next': 'Next', + 'node.audit_page': 'Page {n}', + 'node.audit_exported': 'Exported {n} entries.', + 'node.unlink_title': 'Unlink from hub', + 'node.unlink_hint': 'Stops the hub from brokering connections to this node and listing it for group members. Your groups, keys, roster and files are untouched — the node keeps serving anyone who reaches it directly. Re-link any time from the group wizard or your profile.', + 'node.unlink_button': 'Unlink this node', + 'node.unlink_confirm': 'Unlink this node from the hub? The hub will stop routing members to it. Group data on disk is not affected.', + 'node.unlink_done': 'Node unlinked from the hub. It may take a moment for the hub connection to drop.', + 'node.tab_overview': 'Overview', + 'node.tab_groups': 'Groups', + 'node.tab_roster': 'Roster', + 'node.tab_peers': 'Peers', + 'node.tab_audit': 'Audit', + 'node.tab_settings': 'Settings', }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js index 2f2aa43..a1a1352 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js @@ -844,4 +844,50 @@ export default { 'node.ice_saved': 'ICE interfaces saved.', 'node.ice_reset_auto': 'Switch to auto', 'node.ice_duplicate': 'This interface is already in the list.', + 'node.overview': 'Overview', + 'node.overview_version': 'Version', + 'node.overview_node_id': 'Node ID', + 'node.overview_quic_port': 'QUIC port', + 'node.overview_hub': 'Hub', + 'node.node_roster': 'Node roster', + 'node.node_roster_hint': 'Every identity this node recognises, across all groups.', + 'node.roster_scope': 'Scope', + 'node.roster_scope_node': 'node-wide', + 'node.peers_title': 'Connected peers', + 'node.peers_empty': 'No peers connected.', + 'node.peers_user': 'User', + 'node.peers_ip': 'IP', + 'node.peers_group': 'Group', + 'node.peers_state': 'State', + 'node.audit_title': 'Audit log', + 'node.audit_all_events': 'All events', + 'node.audit_empty': 'No audit entries.', + 'node.audit_time': 'Time', + 'node.audit_event': 'Event', + 'node.audit_user': 'User', + 'node.audit_ip': 'IP', + 'node.audit_group': 'Group', + 'node.audit_detail': 'Detail', + 'node.maintenance': 'Maintenance', + 'node.maintenance_cache': 'Index cache: {n} path(s) remembered.', + 'node.maintenance_prune': 'Prune stale entries', + 'node.maintenance_pruned': 'Pruned {n} stale cache entries.', + 'node.loading': 'Loading…', + 'node.audit_export': 'Export CSV', + 'node.audit_exporting': 'Exporting…', + 'node.audit_prev': 'Previous', + 'node.audit_next': 'Next', + 'node.audit_page': 'Page {n}', + 'node.audit_exported': 'Exported {n} entries.', + 'node.unlink_title': 'Unlink from hub', + 'node.unlink_hint': 'Stops the hub from brokering connections to this node and listing it for group members. Your groups, keys, roster and files are untouched — the node keeps serving anyone who reaches it directly. Re-link any time from the group wizard or your profile.', + 'node.unlink_button': 'Unlink this node', + 'node.unlink_confirm': 'Unlink this node from the hub? The hub will stop routing members to it. Group data on disk is not affected.', + 'node.unlink_done': 'Node unlinked from the hub. It may take a moment for the hub connection to drop.', + 'node.tab_overview': 'Overview', + 'node.tab_groups': 'Groups', + 'node.tab_roster': 'Roster', + 'node.tab_peers': 'Peers', + 'node.tab_audit': 'Audit', + 'node.tab_settings': 'Settings', }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js index 141cb6f..b0c88ff 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js @@ -829,4 +829,50 @@ export default { 'node.ice_saved': 'ICE interfaces saved.', 'node.ice_reset_auto': 'Switch to auto', 'node.ice_duplicate': 'This interface is already in the list.', + 'node.overview': 'Overview', + 'node.overview_version': 'Version', + 'node.overview_node_id': 'Node ID', + 'node.overview_quic_port': 'QUIC port', + 'node.overview_hub': 'Hub', + 'node.node_roster': 'Node roster', + 'node.node_roster_hint': 'Every identity this node recognises, across all groups.', + 'node.roster_scope': 'Scope', + 'node.roster_scope_node': 'node-wide', + 'node.peers_title': 'Connected peers', + 'node.peers_empty': 'No peers connected.', + 'node.peers_user': 'User', + 'node.peers_ip': 'IP', + 'node.peers_group': 'Group', + 'node.peers_state': 'State', + 'node.audit_title': 'Audit log', + 'node.audit_all_events': 'All events', + 'node.audit_empty': 'No audit entries.', + 'node.audit_time': 'Time', + 'node.audit_event': 'Event', + 'node.audit_user': 'User', + 'node.audit_ip': 'IP', + 'node.audit_group': 'Group', + 'node.audit_detail': 'Detail', + 'node.maintenance': 'Maintenance', + 'node.maintenance_cache': 'Index cache: {n} path(s) remembered.', + 'node.maintenance_prune': 'Prune stale entries', + 'node.maintenance_pruned': 'Pruned {n} stale cache entries.', + 'node.loading': 'Loading…', + 'node.audit_export': 'Export CSV', + 'node.audit_exporting': 'Exporting…', + 'node.audit_prev': 'Previous', + 'node.audit_next': 'Next', + 'node.audit_page': 'Page {n}', + 'node.audit_exported': 'Exported {n} entries.', + 'node.unlink_title': 'Unlink from hub', + 'node.unlink_hint': 'Stops the hub from brokering connections to this node and listing it for group members. Your groups, keys, roster and files are untouched — the node keeps serving anyone who reaches it directly. Re-link any time from the group wizard or your profile.', + 'node.unlink_button': 'Unlink this node', + 'node.unlink_confirm': 'Unlink this node from the hub? The hub will stop routing members to it. Group data on disk is not affected.', + 'node.unlink_done': 'Node unlinked from the hub. It may take a moment for the hub connection to drop.', + 'node.tab_overview': 'Overview', + 'node.tab_groups': 'Groups', + 'node.tab_roster': 'Roster', + 'node.tab_peers': 'Peers', + 'node.tab_audit': 'Audit', + 'node.tab_settings': 'Settings', }; 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')}

+ +
+ `} `; } diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css index e217cc8..fe01cd6 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/style.css +++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css @@ -2480,6 +2480,9 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; } opacity: 0.65; border-color: var(--warn, #d97706); } +.node-group-danger { + border-color: var(--error, #dc2626); +} .node-group-header { display: flex; align-items: baseline; @@ -2616,6 +2619,28 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; } padding: 4px 8px; border-bottom: 1px solid var(--border); } +.node-table-scroll { + overflow-x: auto; + margin-top: 4px; +} +/* Audit rows: keep the fixed-shape columns on one line (the IP must never be + clipped); only the free-text detail column wraps. */ +.node-table-audit td, +.node-table-audit th { white-space: nowrap; } +.node-table-audit td:last-child, +.node-table-audit th:last-child { + white-space: normal; + word-break: break-word; + overflow-wrap: anywhere; + min-width: 16ch; + max-width: 48ch; +} +.node-audit-pager { + display: flex; + align-items: center; + gap: 10px; + margin-top: 10px; +} .node-deny-entry { display: flex; -- cgit v1.2.3