diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-01 14:08:32 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-01 14:08:32 +0200 |
| commit | cfc91e0a424163869c64d30e55d55a53f18a3dbf (patch) | |
| tree | 04ed3bbec11690f62f1e2a738bf89f4b763332e5 /packages/meshbay-hub | |
| parent | ba45a3c94806f612fa62812e0b36d08b581a2e47 (diff) | |
| download | meshbay-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')
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; |