aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-01 14:08:32 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-01 14:08:32 +0200
commitcfc91e0a424163869c64d30e55d55a53f18a3dbf (patch)
tree04ed3bbec11690f62f1e2a738bf89f4b763332e5 /packages/meshbay-hub/src
parentba45a3c94806f612fa62812e0b36d08b581a2e47 (diff)
downloadmeshbay-cfc91e0a424163869c64d30e55d55a53f18a3dbf.tar.gz
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MQCaZnde4Bjjdu84dhSuF5
Diffstat (limited to 'packages/meshbay-hub/src')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js46
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js46
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js46
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js46
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js46
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js46
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js46
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js46
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js46
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js46
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/node-page.js476
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/style.css25
12 files changed, 918 insertions, 43 deletions
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`<div class="page-content">
<h2>${t('node.title')}</h2>
@@ -447,7 +642,29 @@ export function NodePage({ groups }) {
: pairStatus ? html`<p class="error-msg">${pairStatus}</p>` : null}
</div>
`}
- ${(() => {
+ <div class="admin-tabs">
+ ${['overview', 'groups', 'roster', 'peers', 'audit', 'settings'].map(k => html`
+ <button key=${k} class="admin-tab ${tab === k ? 'active' : ''}"
+ onClick=${() => setTab(k)}>${t('node.tab_' + k)}</button>
+ `)}
+ </div>
+
+ ${tab === 'overview' && nodeInfo && html`
+ <div class="node-group">
+ <span class="settings-heading">${t('node.overview')}</span>
+ <table class="node-table">
+ <tbody>
+ <tr><td>${t('node.overview_version')}</td><td>${nodeInfo.version || '—'}</td></tr>
+ <tr><td>${t('node.overview_node_id')}</td>
+ <td><code>${nodeInfo.endpoint_hint || nodeInfo.pk_node_ed25519 || '—'}</code></td></tr>
+ <tr><td>${t('node.overview_quic_port')}</td><td>${nodeInfo.quic_port || '—'}</td></tr>
+ <tr><td>${t('node.overview_hub')}</td><td>${nodeInfo.hub_url || '—'}</td></tr>
+ </tbody>
+ </table>
+ </div>
+ `}
+
+ ${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`
+ <div class="node-group">
+ <span class="settings-heading">${t('node.node_roster')}</span>
+ <p class="node-hint">${t('node.node_roster_hint')}</p>
+ ${!nodeRoster ? html`
+ <p class="node-hint">${t('node.loading')}</p>
+ ` : (nodeRoster.members || []).length === 0 ? html`
+ <p class="node-hint">${t('node.roster_empty')}</p>
+ ` : html`
+ <div class="node-table-scroll">
+ <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_scope')}</th>
+ <th>${t('node.roster_via')}</th>
+ <th></th>
+ </tr></thead>
+ <tbody>
+ ${(nodeRoster.members || []).map(m => html`
+ <tr key=${m.user_id + ':' + (m.group_id || '')}>
+ <td>${m.username || m.user_id.slice(0, 12)}</td>
+ <td>${m.role}</td>
+ <td>${m.status}</td>
+ <td>${m.group_id ? m.group_id.slice(0, 8) : t('node.roster_scope_node')}</td>
+ <td>${m.pinned_via || ''}</td>
+ <td>${m.pk_ed25519 && html`
+ <button class="btn btn-small btn-danger" disabled=${busy}
+ onClick=${() => unpinNodeMember(m.user_id)}>
+ ${t('node.unpin')}</button>
+ `}</td>
+ </tr>
+ `)}
+ </tbody>
+ </table>
+ </div>
+ `}
+ </div>
+ `}
+
+ ${tab === 'peers' && html`
+ <div class="node-group">
+ <span class="settings-heading">${t('node.peers_title')}</span>
+ ${!peers ? html`
+ <p class="node-hint">${t('node.loading')}</p>
+ ` : peers.length === 0 ? html`
+ <p class="node-hint">${t('node.peers_empty')}</p>
+ ` : html`
+ <div class="node-table-scroll">
+ <table class="node-table">
+ <thead><tr>
+ <th>${t('node.peers_user')}</th>
+ <th>${t('node.peers_ip')}</th>
+ <th>${t('node.peers_group')}</th>
+ <th>${t('node.peers_state')}</th>
+ </tr></thead>
+ <tbody>
+ ${peers.map(p => html`
+ <tr key=${p.peer_id}>
+ <td>${p.username || (p.user_id || '').slice(0, 12) || '—'}</td>
+ <td>${p.remote_ip || '—'}</td>
+ <td>${p.group_name || (p.group_id ? p.group_id.slice(0, 8) : '—')}</td>
+ <td>${p.state || '—'}</td>
+ </tr>
+ `)}
+ </tbody>
+ </table>
+ </div>
+ `}
+ </div>
+ `}
+
+ ${tab === 'audit' && html`
<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>
+ <span class="settings-heading">${t('node.audit_title')}</span>
+ <button class="btn btn-small btn-secondary"
+ disabled=${auditExporting || busy}
+ onClick=${exportAuditCsv}>
+ ${auditExporting ? t('node.audit_exporting') : t('node.audit_export')}</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 class="stun-add-row">
+ <select value=${auditEvent}
+ onChange=${e => { setAuditEvent(e.target.value); setAuditPage(0); }}>
+ <option value="">${t('node.audit_all_events')}</option>
+ <option value="handshake">handshake</option>
+ <option value="file_download">file_download</option>
+ <option value="file_upload">file_upload</option>
+ <option value="file_delete">file_delete</option>
+ <option value="stream_video">stream_video</option>
+ <option value="chat_message">chat_message</option>
+ <option value="disconnect">disconnect</option>
+ <option value="auth_failed">auth_failed</option>
+ </select>
+ <select value=${String(auditPageSize)}
+ onChange=${e => { setAuditPageSize(parseInt(e.target.value) || 100); setAuditPage(0); }}>
+ <option value="50">50</option>
+ <option value="100">100</option>
+ <option value="200">200</option>
+ <option value="500">500</option>
+ </select>
+ </div>
+ ${!audit ? html`
+ <p class="node-hint">${t('node.loading')}</p>
+ ` : audit.length === 0 ? html`
+ <p class="node-hint">${t('node.audit_empty')}</p>
+ ` : html`
+ <div class="node-table-scroll">
+ <table class="node-table node-table-audit">
+ <thead><tr>
+ <th>${t('node.audit_time')}</th>
+ <th>${t('node.audit_event')}</th>
+ <th>${t('node.audit_user')}</th>
+ <th>${t('node.audit_ip')}</th>
+ <th>${t('node.audit_group')}</th>
+ <th>${t('node.audit_detail')}</th>
+ </tr></thead>
+ <tbody>
+ ${audit.map(e => html`
+ <tr key=${e.id}>
+ <td>${new Date(e.timestamp * 1000).toLocaleString()}</td>
+ <td>${e.event}</td>
+ <td>${e.username || (e.user_id || '').slice(0, 8)}</td>
+ <td>${e.ip || '—'}</td>
+ <td>${e.group_name || (e.group_id ? e.group_id.slice(0, 8) : '—')}</td>
+ <td>${e.detail || ''}</td>
+ </tr>
+ `)}
+ </tbody>
+ </table>
+ </div>
+ `}
+ ${audit && html`
+ <div class="node-audit-pager">
+ <button class="btn btn-small btn-secondary"
+ disabled=${busy || auditPage === 0}
+ onClick=${() => setAuditPage(p => Math.max(0, p - 1))}>
+ ${t('node.audit_prev')}</button>
+ <span class="node-hint">${t('node.audit_page', { n: auditPage + 1 })}</span>
+ <button class="btn btn-small btn-secondary"
+ disabled=${busy || !auditHasMore}
+ onClick=${() => setAuditPage(p => p + 1)}>
+ ${t('node.audit_next')}</button>
</div>
`}
</div>
+ `}
+
+ ${tab === 'overview' && html`
+ <div class="node-group">
+ <span class="settings-heading">${t('node.maintenance')}</span>
+ ${cacheCount === null ? html`
+ <p class="node-hint">${t('node.loading')}</p>
+ ` : html`
+ <p class="node-hint">${t('node.maintenance_cache', { n: cacheCount })}</p>
+ <button class="btn btn-small btn-secondary" disabled=${busy}
+ onClick=${pruneCache}>${t('node.maintenance_prune')}</button>
+ `}
+ </div>
+ `}
- ${editSettings && html`
+ ${tab === 'settings' && editSettings && html`
<div class="node-group">
<span class="settings-heading">${t('node.settings')}</span>
<p class="node-hint">${t('node.settings_edit_hint')}</p>
@@ -659,7 +992,7 @@ export function NodePage({ groups }) {
</div>
`}
- ${editStun && html`
+ ${tab === 'settings' && editStun && html`
<div class="node-group">
<span class="settings-heading">${t('node.stun_servers')}</span>
<p class="node-hint">${t('node.stun_hint')}</p>
@@ -704,7 +1037,7 @@ export function NodePage({ groups }) {
</div>
`}
- ${editIce && html`
+ ${tab === 'settings' && editIce && html`
<div class="node-group">
<span class="settings-heading">${t('node.ice_interfaces')}</span>
<p class="node-hint">${editIce.length === 0
@@ -744,6 +1077,63 @@ export function NodePage({ groups }) {
</div>
</div>
`}
+
+ ${tab === 'settings' && html`
+ <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>
+ `}
+
+ ${tab === 'settings' && nodeInfo && nodeInfo.status !== 'waiting_for_node_key' && html`
+ <div class="node-group node-group-danger">
+ <span class="settings-heading">${t('node.unlink_title')}</span>
+ <p class="node-hint">${t('node.unlink_hint')}</p>
+ <button class="btn btn-small btn-danger" disabled=${unlinkBusy || busy}
+ onClick=${unlinkNode}>${t('node.unlink_button')}</button>
+ </div>
+ `}
</div>
`;
}
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;