From 10ad8dc6804c1071672878f80ea38996d0820fd5 Mon Sep 17 00:00:00 2001 From: davide3011 Date: Fri, 13 Feb 2026 11:12:29 +0100 Subject: [PATCH] feat(dashboard): add Electrum active servers page and optimize ElectrumX card/perf --- web-dashboard/app.py | 299 +++++++++++++++++- web-dashboard/static/dashboard.js | 8 +- web-dashboard/static/electrum_servers.js | 57 ++++ web-dashboard/static/style.css | 6 + web-dashboard/templates/electrum_servers.html | 77 +++++ web-dashboard/templates/index.html | 12 +- 6 files changed, 444 insertions(+), 15 deletions(-) create mode 100644 web-dashboard/static/electrum_servers.js create mode 100644 web-dashboard/templates/electrum_servers.html diff --git a/web-dashboard/app.py b/web-dashboard/app.py index 9b3d5a3..942524d 100644 --- a/web-dashboard/app.py +++ b/web-dashboard/app.py @@ -9,6 +9,8 @@ import requests import json import os import time +import copy +import threading from datetime import datetime import psutil import socket @@ -21,6 +23,127 @@ PALLADIUM_RPC_HOST = os.getenv('PALLADIUM_RPC_HOST', 'palladiumd') PALLADIUM_RPC_PORT = int(os.getenv('PALLADIUM_RPC_PORT', '2332')) ELECTRUMX_RPC_HOST = os.getenv('ELECTRUMX_RPC_HOST', 'electrumx') ELECTRUMX_RPC_PORT = int(os.getenv('ELECTRUMX_RPC_PORT', '8000')) +ELECTRUMX_STATS_TTL = int(os.getenv('ELECTRUMX_STATS_TTL', '60')) +ELECTRUMX_SERVERS_TTL = int(os.getenv('ELECTRUMX_SERVERS_TTL', '120')) +ELECTRUMX_EMPTY_SERVERS_TTL = int(os.getenv('ELECTRUMX_EMPTY_SERVERS_TTL', '15')) + +# In-memory caches for fast card stats and heavier server probing stats +_electrumx_stats_cache = {'timestamp': 0.0, 'stats': None} +_electrumx_servers_cache = {'timestamp': 0.0, 'stats': None} + + +def warm_electrumx_caches_async(): + """Pre-warm caches in background to reduce first-load latency.""" + def _worker(): + try: + get_electrumx_stats_cached(force_refresh=True, include_addnode_probes=False) + get_electrumx_stats_cached(force_refresh=True, include_addnode_probes=True) + except Exception as e: + print(f"ElectrumX cache warmup error: {e}") + + threading.Thread(target=_worker, daemon=True).start() + + +def parse_addnode_hosts(conf_path='/palladium-config/palladium.conf'): + """Extract addnode hosts from palladium.conf""" + hosts = [] + try: + with open(conf_path, 'r') as f: + for raw_line in f: + line = raw_line.strip() + if not line or line.startswith('#') or not line.startswith('addnode='): + continue + value = line.split('=', 1)[1].strip() + if not value: + continue + host = value.rsplit(':', 1)[0] if ':' in value else value + if host and host not in hosts: + hosts.append(host) + except Exception as e: + print(f"Error parsing addnode hosts: {e}") + return hosts + + +def probe_electrum_server(host, port=50001, timeout=1.2): + """Check if an Electrum server is reachable and speaking protocol on host:port""" + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(timeout) + sock.connect((host, port)) + request = { + "jsonrpc": "2.0", + "id": 100, + "method": "server.version", + "params": ["palladium-dashboard", "1.4"] + } + sock.send((json.dumps(request) + '\n').encode()) + response = sock.recv(4096).decode() + sock.close() + data = json.loads(response) + if 'result' in data: + return True + except Exception: + return False + return False + + +def is_electrumx_reachable(timeout=1.0): + """Fast ElectrumX liveness check used by /api/health""" + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(timeout) + sock.connect((ELECTRUMX_RPC_HOST, 50001)) + request = { + "jsonrpc": "2.0", + "id": 999, + "method": "server.version", + "params": ["palladium-health", "1.4"] + } + sock.send((json.dumps(request) + '\n').encode()) + response = sock.recv(4096).decode() + sock.close() + data = json.loads(response) + return 'result' in data + except Exception: + return False + + +def is_electrumx_reachable_retry(): + """Retry-aware liveness check to avoid transient false negatives.""" + if is_electrumx_reachable(timeout=1.2): + return True + time.sleep(0.15) + return is_electrumx_reachable(timeout=2.0) + + +def get_electrumx_stats_cached(force_refresh=False, include_addnode_probes=False): + """Return cached ElectrumX stats unless cache is stale.""" + cache = _electrumx_servers_cache if include_addnode_probes else _electrumx_stats_cache + ttl = ELECTRUMX_SERVERS_TTL if include_addnode_probes else ELECTRUMX_STATS_TTL + now = time.time() + cached = cache.get('stats') + cached_ts = cache.get('timestamp', 0.0) + + if ( + include_addnode_probes + and cached is not None + and (cached.get('active_servers_count', 0) == 0) + ): + ttl = ELECTRUMX_EMPTY_SERVERS_TTL + + if not force_refresh and cached is not None and (now - cached_ts) < ttl: + return copy.deepcopy(cached) + + fresh = get_electrumx_stats(include_addnode_probes=include_addnode_probes) + if fresh is not None: + cache['timestamp'] = now + cache['stats'] = fresh + return copy.deepcopy(fresh) + + # Fallback to stale cache if fresh fetch fails + if cached is not None: + return copy.deepcopy(cached) + return None # Read RPC credentials from palladium.conf def get_rpc_credentials(): @@ -78,7 +201,7 @@ def palladium_rpc_call(method, params=None): print(f"RPC call error ({method}): {e}") return None -def get_electrumx_stats(): +def get_electrumx_stats(include_addnode_probes=False): """Get ElectrumX statistics via Electrum protocol and system info""" try: import socket @@ -94,6 +217,10 @@ def get_electrumx_stats(): 'hash_function': '', 'pruning': None, 'sessions': 0, + 'peer_discovery': 'unknown', + 'peer_announce': 'unknown', + 'active_servers': [], + 'active_servers_count': 0, 'requests': 0, 'subs': 0, 'uptime': 0, @@ -106,7 +233,7 @@ def get_electrumx_stats(): # Get server IP address try: # Try to get public IP from external service - response = requests.get('https://api.ipify.org?format=json', timeout=3) + response = requests.get('https://api.ipify.org?format=json', timeout=0.4) if response.status_code == 200: stats['server_ip'] = response.json().get('ip', 'Unknown') else: @@ -152,6 +279,131 @@ def get_electrumx_stats(): except Exception as e: print(f"ElectrumX protocol error: {e}") + # Get peers discovered by ElectrumX + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(5) + sock.connect((ELECTRUMX_RPC_HOST, 50001)) + + request = { + "jsonrpc": "2.0", + "id": 2, + "method": "server.peers.subscribe", + "params": [] + } + + sock.send((json.dumps(request) + '\n').encode()) + response = sock.recv(65535).decode() + sock.close() + + data = json.loads(response) + if 'result' in data and isinstance(data['result'], list): + peers = [] + for peer in data['result']: + if not isinstance(peer, list) or len(peer) < 3: + continue + host = peer[1] + features = peer[2] if isinstance(peer[2], list) else [] + tcp_port = None + ssl_port = None + for feat in features: + if isinstance(feat, str) and feat.startswith('t') and feat[1:].isdigit(): + tcp_port = feat[1:] + if isinstance(feat, str) and feat.startswith('s') and feat[1:].isdigit(): + ssl_port = feat[1:] + if host: + peers.append({ + 'host': host, + 'tcp_port': tcp_port, + 'ssl_port': ssl_port + }) + + stats['active_servers'] = peers + stats['active_servers_count'] = len(peers) + except Exception as e: + print(f"ElectrumX peers error: {e}") + + # Keep peers list without self for dashboard card count + try: + merged = [] + seen = set() + self_host = (stats.get('server_ip') or '').strip() + for peer in (stats.get('active_servers') or []): + host = (peer.get('host') or '').strip() + tcp_port = str(peer.get('tcp_port') or '50001') + if not host: + continue + if self_host and host == self_host: + continue + key = f"{host}:{tcp_port}" + if key in seen: + continue + seen.add(key) + merged.append({ + 'host': host, + 'tcp_port': tcp_port, + 'ssl_port': peer.get('ssl_port') + }) + stats['active_servers'] = merged + stats['active_servers_count'] = len(merged) + except Exception as e: + print(f"Electrum peers normalization error: {e}") + + # Optional full probing for dedicated servers page + if include_addnode_probes: + try: + addnode_hosts = parse_addnode_hosts() + extra_servers = [] + for host in addnode_hosts: + if probe_electrum_server(host, 50001, timeout=0.5): + extra_servers.append({ + 'host': host, + 'tcp_port': '50001', + 'ssl_port': None + }) + + merged = [] + seen = set() + self_host = (stats.get('server_ip') or '').strip() + for peer in (stats.get('active_servers') or []) + extra_servers: + host = (peer.get('host') or '').strip() + tcp_port = str(peer.get('tcp_port') or '50001') + if not host: + continue + if self_host and host == self_host: + continue + key = f"{host}:{tcp_port}" + if key in seen: + continue + seen.add(key) + merged.append({ + 'host': host, + 'tcp_port': tcp_port, + 'ssl_port': peer.get('ssl_port') + }) + + stats['active_servers'] = merged + stats['active_servers_count'] = len(merged) + except Exception as e: + print(f"Supplemental Electrum discovery error: {e}") + + # Read peer discovery/announce settings from electrumx container env + try: + result = subprocess.run( + ['docker', 'exec', 'electrumx-server', 'sh', '-c', + 'printf "%s|%s" "${PEER_DISCOVERY:-unknown}" "${PEER_ANNOUNCE:-unknown}"'], + capture_output=True, + text=True, + timeout=2 + ) + if result.returncode == 0: + values = (result.stdout or '').strip().split('|', 1) + if len(values) == 2: + stats['peer_discovery'] = values[0] or 'unknown' + stats['peer_announce'] = values[1] or 'unknown' + except Exception as e: + print(f"Peer discovery env read error: {e}") + # Try to get container stats via Docker try: # Get container uptime @@ -215,6 +467,11 @@ def peers(): """Serve peers page""" return render_template('peers.html') +@app.route('/electrum-servers') +def electrum_servers(): + """Serve Electrum active servers page""" + return render_template('electrum_servers.html') + @app.route('/api/palladium/info') def palladium_info(): """Get Palladium node blockchain info""" @@ -299,8 +556,15 @@ def recent_blocks(): def electrumx_stats(): """Get ElectrumX server statistics""" try: - stats = get_electrumx_stats() + stats = get_electrumx_stats_cached(include_addnode_probes=False) if stats: + # If fast path reports no servers, reuse full servers cache if available. + if (stats.get('active_servers_count') or 0) == 0: + heavy_stats = get_electrumx_stats_cached(include_addnode_probes=True) + if heavy_stats and (heavy_stats.get('active_servers_count') or 0) > 0: + stats['active_servers'] = heavy_stats.get('active_servers', []) + stats['active_servers_count'] = heavy_stats.get('active_servers_count', 0) + # Get additional info from logs if available try: # Try to get container stats @@ -315,7 +579,6 @@ def electrumx_stats(): if result.returncode == 0: # Process is running, add placeholder stats stats['status'] = 'running' - stats['sessions'] = 0 # Will show 0 for now stats['requests'] = 0 stats['subs'] = 0 except: @@ -329,6 +592,28 @@ def electrumx_stats(): except Exception as e: return jsonify({'error': str(e)}), 500 +@app.route('/api/electrumx/servers') +def electrumx_servers(): + """Get active Electrum servers discovered by this node""" + try: + stats = get_electrumx_stats_cached(include_addnode_probes=True) + if not stats: + return jsonify({'error': 'Cannot connect to ElectrumX'}), 500 + + servers = stats.get('active_servers') or [] + if len(servers) == 0: + # Fallback to fast discovery results if full probing is temporarily empty. + fast_stats = get_electrumx_stats_cached(include_addnode_probes=False) + if fast_stats: + servers = fast_stats.get('active_servers') or [] + return jsonify({ + 'servers': servers, + 'total': len(servers), + 'timestamp': datetime.now().isoformat() + }) + except Exception as e: + return jsonify({'error': str(e)}), 500 + @app.route('/api/system/resources') def system_resources(): """Get system resource usage""" @@ -363,7 +648,10 @@ def system_resources(): def health(): """Health check endpoint""" palladium_ok = palladium_rpc_call('getblockchaininfo') is not None - electrumx_ok = get_electrumx_stats() is not None + stats = get_electrumx_stats_cached(include_addnode_probes=False) + if not stats or stats.get('server_version') in (None, '', 'Unknown'): + stats = get_electrumx_stats_cached(force_refresh=True, include_addnode_probes=False) + electrumx_ok = bool(stats and (stats.get('server_version') not in (None, '', 'Unknown'))) return jsonify({ 'status': 'healthy' if (palladium_ok and electrumx_ok) else 'degraded', @@ -375,4 +663,5 @@ def health(): }) if __name__ == '__main__': + warm_electrumx_caches_async() app.run(host='0.0.0.0', port=8080, debug=False) diff --git a/web-dashboard/static/dashboard.js b/web-dashboard/static/dashboard.js index 1036831..cd12f2e 100644 --- a/web-dashboard/static/dashboard.js +++ b/web-dashboard/static/dashboard.js @@ -205,10 +205,6 @@ async function updateElectrumXStats() { } document.getElementById('serverVersion').textContent = serverVersion; - // Active sessions - const sessions = typeof data.stats.sessions === 'number' ? data.stats.sessions : '--'; - document.getElementById('activeSessions').textContent = sessions; - // Database size const dbSize = data.stats.db_size > 0 ? formatBytes(data.stats.db_size) : '--'; document.getElementById('dbSize').textContent = dbSize; @@ -225,6 +221,10 @@ async function updateElectrumXStats() { // SSL Port document.getElementById('sslPort').textContent = data.stats.ssl_port || 50002; + + // Active servers from peer discovery + const activeServers = Array.isArray(data.stats.active_servers) ? data.stats.active_servers : []; + document.getElementById('activeServersCount').textContent = data.stats.active_servers_count ?? activeServers.length; } } catch (error) { diff --git a/web-dashboard/static/electrum_servers.js b/web-dashboard/static/electrum_servers.js new file mode 100644 index 0000000..63b8848 --- /dev/null +++ b/web-dashboard/static/electrum_servers.js @@ -0,0 +1,57 @@ +// Electrum Active Servers Page JavaScript + +function updateLastUpdateTime() { + const now = new Date().toLocaleString(); + document.getElementById('lastUpdate').textContent = now; +} + +async function updateElectrumServers() { + try { + const response = await fetch('/api/electrumx/servers'); + const data = await response.json(); + + if (data.error) { + console.error('Electrum servers error:', data.error); + return; + } + + const servers = Array.isArray(data.servers) ? data.servers : []; + const tbody = document.getElementById('electrumServersTable'); + tbody.innerHTML = ''; + + if (servers.length === 0) { + tbody.innerHTML = 'No active servers found'; + document.getElementById('totalServers').textContent = '0'; + document.getElementById('tcpReachable').textContent = '0'; + return; + } + + servers.forEach(server => { + const row = document.createElement('tr'); + row.innerHTML = ` + ${server.host || '--'} + ${server.tcp_port || '--'} + ${server.ssl_port || '--'} + `; + tbody.appendChild(row); + }); + + document.getElementById('totalServers').textContent = String(servers.length); + const tcpCount = servers.filter(s => !!s.tcp_port).length; + document.getElementById('tcpReachable').textContent = String(tcpCount); + } catch (error) { + console.error('Error fetching Electrum servers:', error); + document.getElementById('electrumServersTable').innerHTML = + 'Error loading servers'; + } +} + +async function updateAll() { + updateLastUpdateTime(); + await updateElectrumServers(); +} + +document.addEventListener('DOMContentLoaded', async () => { + await updateAll(); + setInterval(updateAll, 10000); +}); diff --git a/web-dashboard/static/style.css b/web-dashboard/static/style.css index 7fe7100..77aa543 100644 --- a/web-dashboard/static/style.css +++ b/web-dashboard/static/style.css @@ -242,6 +242,12 @@ body { word-break: break-word; } +#activeServers { + font-size: 14px; + font-weight: 500; + line-height: 1.5; +} + /* Resource Monitoring */ .resource-item { display: grid; diff --git a/web-dashboard/templates/electrum_servers.html b/web-dashboard/templates/electrum_servers.html new file mode 100644 index 0000000..b277d27 --- /dev/null +++ b/web-dashboard/templates/electrum_servers.html @@ -0,0 +1,77 @@ + + + + + + Electrum Active Servers - Palladium Dashboard + + + +
+
+
+

+ + + + Electrum Active Servers +

+ + ← Back to Dashboard + +
+
+ +
+
+
+

Discovery Summary

+ 🌐 +
+
+
+
+
Total Active Servers
+
--
+
+
+
TCP 50001 Reachable
+
--
+
+
+
+
+
+ +
+
+

Other Active Servers

+ +
+
+
+ + + + + + + + + + + +
HostTCP PortSSL Port
Loading servers...
+
+
+
+ +
+

Last updated: --

+

Auto-refresh every 10 seconds

+
+
+ + + + diff --git a/web-dashboard/templates/index.html b/web-dashboard/templates/index.html index 183b5d8..bb64398 100644 --- a/web-dashboard/templates/index.html +++ b/web-dashboard/templates/index.html @@ -4,7 +4,7 @@ Palladium & ElectrumX Dashboard - +
@@ -105,10 +105,6 @@
Server Version
--
-
-
Active Sessions
-
--
-
Database Size
--
@@ -117,6 +113,10 @@
Uptime
--
+ +
Active Servers
+
--
+
Server IP
--
@@ -195,6 +195,6 @@
- +