#!/usr/bin/env python3 """ Web Dashboard API for Palladium Node and ElectrumX Server Statistics """ from flask import Flask, jsonify, render_template, request from flask_cors import CORS import requests import json import os import time import copy import threading import ssl import ipaddress import base64 import hmac from datetime import datetime import psutil import socket app = Flask(__name__) CORS(app) TRUSTED_CLIENT_NETWORKS = ( ipaddress.ip_network('127.0.0.0/8'), ipaddress.ip_network('10.0.0.0/8'), ipaddress.ip_network('172.16.0.0/12'), ipaddress.ip_network('192.168.0.0/16'), ipaddress.ip_network('::1/128'), ) def is_trusted_client_ip(ip_text): """Allow direct access for localhost and private RFC1918 LAN clients.""" if not ip_text: return False try: ip_obj = ipaddress.ip_address(ip_text.strip()) if getattr(ip_obj, 'ipv4_mapped', None): ip_obj = ip_obj.ipv4_mapped return any(ip_obj in network for network in TRUSTED_CLIENT_NETWORKS) except ValueError: return False def unauthorized_response(): response = jsonify({'error': 'Authentication required'}) response.status_code = 401 response.headers['WWW-Authenticate'] = 'Basic realm="Palladium Dashboard"' return response @app.before_request def enforce_external_auth(): """ Localhost and LAN clients can access directly. External clients must authenticate via Basic Auth credentials from env vars. """ client_ip = request.remote_addr or '' if is_trusted_client_ip(client_ip): return None expected_user = os.getenv('DASHBOARD_AUTH_USERNAME', '').strip() expected_pass = os.getenv('DASHBOARD_AUTH_PASSWORD', '').strip() if not expected_user or not expected_pass: return jsonify({'error': 'Dashboard auth is not configured'}), 503 auth_header = request.headers.get('Authorization', '') if not auth_header.startswith('Basic '): return unauthorized_response() try: decoded = base64.b64decode(auth_header.split(' ', 1)[1]).decode('utf-8') username, password = decoded.split(':', 1) except Exception: return unauthorized_response() user_ok = hmac.compare_digest(username, expected_user) pass_ok = hmac.compare_digest(password, expected_pass) if not (user_ok and pass_ok): return unauthorized_response() return None @app.after_request def disable_api_cache(response): """Prevent stale API payloads from browser/proxy caches.""" if request.path.startswith('/api/'): response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0' response.headers['Pragma'] = 'no-cache' response.headers['Expires'] = '0' return response # Configuration 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 parse_services_ports(services): """Extract TCP/SSL ports from SERVICES string (e.g. tcp://0.0.0.0:50001,ssl://0.0.0.0:50002).""" tcp_port = None ssl_port = None for item in services.split(','): item = item.strip() if item.startswith('tcp://') and ':' in item: try: tcp_port = int(item.rsplit(':', 1)[1]) except ValueError: pass if item.startswith('ssl://') and ':' in item: try: ssl_port = int(item.rsplit(':', 1)[1]) except ValueError: pass return tcp_port, ssl_port def get_electrumx_service_ports(): """ Resolve ElectrumX service ports dynamically. Priority: 1) SERVICES env from electrumx container 2) local SERVICES env (if provided) """ try: import subprocess result = subprocess.run( ['docker', 'exec', 'electrumx-server', 'sh', '-c', 'printf "%s" "${SERVICES:-}"'], capture_output=True, text=True, timeout=2 ) if result.returncode == 0 and result.stdout.strip(): return parse_services_ports(result.stdout.strip()) except Exception: pass return parse_services_ports(os.getenv('SERVICES', '')) def probe_electrum_server(host, port, timeout=2.0): """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.sendall((json.dumps(request) + '\n').encode()) data = b"" for _ in range(6): chunk = sock.recv(4096) if not chunk: break data += chunk candidate = data.decode(errors='ignore') candidate = candidate.split("\n", 1)[0].strip() if candidate: try: payload = json.loads(candidate) sock.close() return 'result' in payload except Exception: pass sock.close() line = data.split(b"\n", 1)[0].decode(errors='ignore').strip() if data else "" payload = json.loads(line) if line else {} if 'result' in payload: return True except Exception: return False return False def probe_electrum_server_ssl(host, port, timeout=2.0): """Check if an Electrum SSL server is reachable on host:port (self-signed allowed).""" try: raw_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) raw_sock.settimeout(timeout) raw_sock.connect((host, port)) context = ssl.create_default_context() context.check_hostname = False context.verify_mode = ssl.CERT_NONE ssl_sock = context.wrap_socket(raw_sock, server_hostname=host) ssl_sock.settimeout(timeout) request = { "jsonrpc": "2.0", "id": 101, "method": "server.version", "params": ["palladium-dashboard", "1.4"] } ssl_sock.sendall((json.dumps(request) + '\n').encode()) data = b"" for _ in range(6): chunk = ssl_sock.recv(4096) if not chunk: break data += chunk candidate = data.decode(errors='ignore') candidate = candidate.split("\n", 1)[0].strip() if candidate: try: payload = json.loads(candidate) ssl_sock.close() return 'result' in payload except Exception: pass ssl_sock.close() line = data.split(b"\n", 1)[0].decode(errors='ignore').strip() if data else "" payload = json.loads(line) if line else {} if 'result' in payload: return True except Exception: return False return False def get_electrum_server_genesis(host, tcp_port=None, ssl_port=None, timeout=2.0): """Return peer genesis_hash via server.features, trying TCP first then SSL.""" request = { "jsonrpc": "2.0", "id": 102, "method": "server.features", "params": [] } if tcp_port: try: sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.settimeout(timeout) sock.connect((host, tcp_port)) sock.sendall((json.dumps(request) + '\n').encode()) data = b"" for _ in range(6): chunk = sock.recv(4096) if not chunk: break data += chunk candidate = data.decode(errors='ignore').split("\n", 1)[0].strip() if candidate: payload = json.loads(candidate) genesis = (payload.get('result') or {}).get('genesis_hash') sock.close() return genesis sock.close() except Exception: pass if ssl_port: try: raw_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) raw_sock.settimeout(timeout) raw_sock.connect((host, ssl_port)) context = ssl.create_default_context() context.check_hostname = False context.verify_mode = ssl.CERT_NONE ssl_sock = context.wrap_socket(raw_sock, server_hostname=host) ssl_sock.settimeout(timeout) ssl_sock.sendall((json.dumps(request) + '\n').encode()) data = b"" for _ in range(6): chunk = ssl_sock.recv(4096) if not chunk: break data += chunk candidate = data.decode(errors='ignore').split("\n", 1)[0].strip() if candidate: payload = json.loads(candidate) genesis = (payload.get('result') or {}).get('genesis_hash') ssl_sock.close() return genesis ssl_sock.close() except Exception: pass return None def is_electrumx_reachable(timeout=1.0): """Fast ElectrumX liveness check used by /api/health""" tcp_port, _ = get_electrumx_service_ports() if not tcp_port: return False try: sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.settimeout(timeout) sock.connect((ELECTRUMX_RPC_HOST, tcp_port)) 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(): """Read RPC credentials from palladium.conf""" try: conf_path = '/palladium-config/palladium.conf' rpc_user = None rpc_password = None with open(conf_path, 'r') as f: for line in f: line = line.strip() if line.startswith('rpcuser='): rpc_user = line.split('=', 1)[1] elif line.startswith('rpcpassword='): rpc_password = line.split('=', 1)[1] return rpc_user, rpc_password except Exception as e: print(f"Error reading RPC credentials: {e}") return None, None def palladium_rpc_call(method, params=None): """Make RPC call to Palladium node""" if params is None: params = [] rpc_user, rpc_password = get_rpc_credentials() if not rpc_user or not rpc_password: return None url = f"http://{PALLADIUM_RPC_HOST}:{PALLADIUM_RPC_PORT}" headers = {'content-type': 'application/json'} payload = { "jsonrpc": "2.0", "id": "dashboard", "method": method, "params": params } try: response = requests.post( url, auth=(rpc_user, rpc_password), data=json.dumps(payload), headers=headers, timeout=10 ) if response.status_code == 200: result = response.json() return result.get('result') return None except Exception as e: print(f"RPC call error ({method}): {e}") return None def get_electrumx_stats(include_addnode_probes=False): """Get ElectrumX statistics via Electrum protocol and system info""" try: import socket import json import subprocess from datetime import datetime local_tcp_port, local_ssl_port = get_electrumx_service_ports() stats = { 'server_version': 'Unknown', 'protocol_min': '', 'protocol_max': '', 'genesis_hash': '', 'genesis_hash_full': '', 'hash_function': '', 'pruning': None, 'sessions': 0, 'peer_discovery': 'unknown', 'peer_announce': 'unknown', 'active_servers': [], 'active_servers_count': 0, 'requests': 0, 'subs': 0, 'uptime': 0, 'db_size': 0, 'tcp_port': str(local_tcp_port) if local_tcp_port else None, 'ssl_port': str(local_ssl_port) if local_ssl_port else None, 'server_ip': 'Unknown' } # Get server IP address try: # Try to get public IP from external service 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: # Fallback to local IP s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) s.connect(("8.8.8.8", 80)) stats['server_ip'] = s.getsockname()[0] s.close() except Exception as e: print(f"IP detection error: {e}") # Last fallback: get hostname IP try: stats['server_ip'] = socket.gethostbyname(socket.gethostname()) except: stats['server_ip'] = 'Unknown' # Get server features via Electrum protocol try: if not local_tcp_port: raise RuntimeError("SERVICES tcp port not configured") sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.settimeout(5) sock.connect((ELECTRUMX_RPC_HOST, local_tcp_port)) request = { "jsonrpc": "2.0", "id": 1, "method": "server.features", "params": [] } sock.send((json.dumps(request) + '\n').encode()) response = sock.recv(4096).decode() sock.close() data = json.loads(response) if 'result' in data: result = data['result'] full_genesis = result.get('genesis_hash', '') stats['server_version'] = result.get('server_version', 'Unknown') stats['protocol_min'] = result.get('protocol_min', '') stats['protocol_max'] = result.get('protocol_max', '') stats['genesis_hash_full'] = full_genesis stats['genesis_hash'] = (full_genesis[:16] + '...') if full_genesis else '' stats['hash_function'] = result.get('hash_function', '') stats['pruning'] = result.get('pruning') except Exception as e: print(f"ElectrumX protocol error: {e}") # Fallback: derive expected network genesis from local Palladium node # if Electrum server.features is temporarily unavailable. if not stats.get('genesis_hash_full'): try: local_genesis = palladium_rpc_call('getblockhash', [0]) if isinstance(local_genesis, str) and local_genesis: stats['genesis_hash_full'] = local_genesis stats['genesis_hash'] = local_genesis[:16] + '...' except Exception as e: print(f"Local genesis fallback error: {e}") # Get peers discovered by ElectrumX try: if not local_tcp_port: raise RuntimeError("SERVICES tcp port not configured") sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.settimeout(5) sock.connect((ELECTRUMX_RPC_HOST, local_tcp_port)) 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, 'tcp_reachable': None, 'ssl_reachable': None }) 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_by_host = {} 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')) if peer.get('tcp_port') else None ssl_port = str(peer.get('ssl_port')) if peer.get('ssl_port') else None if not host: continue if self_host and host == self_host: continue existing = merged_by_host.get(host) if not existing: merged_by_host[host] = { 'host': host, 'tcp_port': tcp_port, 'ssl_port': ssl_port, 'tcp_reachable': peer.get('tcp_reachable'), 'ssl_reachable': peer.get('ssl_reachable') } else: if not existing.get('tcp_port') and tcp_port: existing['tcp_port'] = tcp_port if not existing.get('ssl_port') and ssl_port: existing['ssl_port'] = ssl_port if existing.get('tcp_reachable') is None and peer.get('tcp_reachable') is not None: existing['tcp_reachable'] = peer.get('tcp_reachable') if existing.get('ssl_reachable') is None and peer.get('ssl_reachable') is not None: existing['ssl_reachable'] = peer.get('ssl_reachable') merged = list(merged_by_host.values()) 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: tcp_ok = probe_electrum_server(host, local_tcp_port, timeout=2.0) if local_tcp_port else False ssl_ok = probe_electrum_server_ssl(host, local_ssl_port, timeout=2.0) if local_ssl_port else False if tcp_ok or ssl_ok: extra_servers.append({ 'host': host, 'tcp_port': str(local_tcp_port) if tcp_ok and local_tcp_port else None, 'ssl_port': str(local_ssl_port) if ssl_ok and local_ssl_port else None, 'tcp_reachable': tcp_ok, 'ssl_reachable': ssl_ok }) merged_by_host = {} 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')) if peer.get('tcp_port') else None ssl_port = str(peer.get('ssl_port')) if peer.get('ssl_port') else None if not host: continue if self_host and host == self_host: continue existing = merged_by_host.get(host) if not existing: merged_by_host[host] = { 'host': host, 'tcp_port': tcp_port, 'ssl_port': ssl_port, 'tcp_reachable': peer.get('tcp_reachable'), 'ssl_reachable': peer.get('ssl_reachable') } else: if not existing.get('tcp_port') and tcp_port: existing['tcp_port'] = tcp_port if not existing.get('ssl_port') and ssl_port: existing['ssl_port'] = ssl_port if existing.get('tcp_reachable') is None and peer.get('tcp_reachable') is not None: existing['tcp_reachable'] = peer.get('tcp_reachable') if existing.get('ssl_reachable') is None and peer.get('ssl_reachable') is not None: existing['ssl_reachable'] = peer.get('ssl_reachable') merged = list(merged_by_host.values()) # Probe merged list so summary can report both TCP and SSL reachability for peer in merged: host = peer.get('host') if not host: continue peer_tcp_port = int(peer.get('tcp_port')) if str(peer.get('tcp_port', '')).isdigit() else local_tcp_port peer_ssl_port = int(peer.get('ssl_port')) if str(peer.get('ssl_port', '')).isdigit() else local_ssl_port if peer.get('tcp_reachable') is None and peer_tcp_port: peer['tcp_reachable'] = probe_electrum_server(host, peer_tcp_port, timeout=2.0) if peer.get('ssl_reachable') is None and peer_ssl_port: peer['ssl_reachable'] = probe_electrum_server_ssl(host, peer_ssl_port, timeout=2.0) if peer.get('tcp_reachable') is True and not peer.get('tcp_port') and peer_tcp_port: peer['tcp_port'] = str(peer_tcp_port) if peer.get('ssl_reachable') is True and not peer.get('ssl_port') and peer_ssl_port: peer['ssl_port'] = str(peer_ssl_port) # Keep only peers matching local Electrum network (same genesis hash). expected_genesis = (stats.get('genesis_hash_full') or '').strip().lower() if expected_genesis: filtered = [] for peer in merged: host = peer.get('host') if not host: continue if peer.get('tcp_reachable') is not True and peer.get('ssl_reachable') is not True: continue peer_tcp_port = int(peer.get('tcp_port')) if str(peer.get('tcp_port', '')).isdigit() else None peer_ssl_port = int(peer.get('ssl_port')) if str(peer.get('ssl_port', '')).isdigit() else None peer_genesis = get_electrum_server_genesis( host, tcp_port=peer_tcp_port, ssl_port=peer_ssl_port, timeout=2.0 ) if peer_genesis and peer_genesis.strip().lower() == expected_genesis: filtered.append(peer) merged = filtered 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 result = subprocess.run( ['docker', 'inspect', 'electrumx-server', '--format', '{{.State.StartedAt}}'], capture_output=True, text=True, timeout=2 ) if result.returncode == 0: started_at = result.stdout.strip() # Parse and calculate uptime from dateutil import parser start_time = parser.parse(started_at) uptime_seconds = int((datetime.now(start_time.tzinfo) - start_time).total_seconds()) stats['uptime'] = uptime_seconds except Exception as e: print(f"Docker uptime error: {e}") # Try to estimate DB size from data directory try: result = subprocess.run( ['docker', 'exec', 'electrumx-server', 'du', '-sb', '/data'], capture_output=True, text=True, timeout=5 ) if result.returncode == 0: db_size = int(result.stdout.split()[0]) stats['db_size'] = db_size except Exception as e: print(f"DB size error: {e}") # Count active connections (TCP sessions) try: if not local_tcp_port: raise RuntimeError("SERVICES tcp port not configured") result = subprocess.run( ['docker', 'exec', 'electrumx-server', 'sh', '-c', f'netstat -an 2>/dev/null | grep ":{local_tcp_port}.*ESTABLISHED" | wc -l'], capture_output=True, text=True, timeout=2 ) if result.returncode == 0: sessions = int(result.stdout.strip()) stats['sessions'] = sessions except Exception as e: print(f"Sessions count error: {e}") return stats except Exception as e: print(f"ElectrumX stats error: {e}") return None @app.route('/') def index(): """Serve main dashboard page""" return render_template('index.html') @app.route('/peers') 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""" try: blockchain_info = palladium_rpc_call('getblockchaininfo') network_info = palladium_rpc_call('getnetworkinfo') mining_info = palladium_rpc_call('getmininginfo') peer_info = palladium_rpc_call('getpeerinfo') mempool_info = palladium_rpc_call('getmempoolinfo') data = { 'blockchain': blockchain_info or {}, 'network': network_info or {}, 'mining': mining_info or {}, 'peers': len(peer_info) if peer_info else 0, 'mempool': mempool_info or {}, 'timestamp': datetime.now().isoformat() } return jsonify(data) except Exception as e: return jsonify({'error': str(e)}), 500 @app.route('/api/palladium/peers') def palladium_peers(): """Get detailed peer information""" try: peer_info = palladium_rpc_call('getpeerinfo') if not peer_info: return jsonify({'peers': []}) peers_data = [] for peer in peer_info: peers_data.append({ 'addr': peer.get('addr', 'Unknown'), 'inbound': peer.get('inbound', False), 'version': peer.get('subver', 'Unknown'), 'conntime': peer.get('conntime', 0), 'bytessent': peer.get('bytessent', 0), 'bytesrecv': peer.get('bytesrecv', 0) }) return jsonify({ 'peers': peers_data, 'total': len(peers_data), 'timestamp': datetime.now().isoformat() }) except Exception as e: return jsonify({'error': str(e)}), 500 @app.route('/api/palladium/blocks/recent') def recent_blocks(): """Get recent blocks information""" try: blockchain_info = palladium_rpc_call('getblockchaininfo') if not blockchain_info: return jsonify({'error': 'Cannot get blockchain info'}), 500 current_height = blockchain_info.get('blocks', 0) blocks = [] # Get last 10 blocks for i in range(min(10, current_height)): height = current_height - i block_hash = palladium_rpc_call('getblockhash', [height]) if block_hash: block = palladium_rpc_call('getblock', [block_hash]) if block: blocks.append({ 'height': height, 'hash': block_hash, 'time': block.get('time'), 'size': block.get('size'), 'tx_count': len(block.get('tx', [])) }) return jsonify({'blocks': blocks}) except Exception as e: return jsonify({'error': str(e)}), 500 @app.route('/api/electrumx/stats') def electrumx_stats(): """Get ElectrumX server statistics""" try: stats = get_electrumx_stats_cached(include_addnode_probes=False) if stats: # Keep dashboard card aligned with /api/electrumx/servers: # always prefer the full discovery view for active servers. heavy_stats = get_electrumx_stats_cached(include_addnode_probes=True) if heavy_stats: 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 import subprocess result = subprocess.run( ['docker', 'exec', 'electrumx-server', 'sh', '-c', 'ps aux | grep electrumx_server | grep -v grep'], capture_output=True, text=True, timeout=2 ) if result.returncode == 0: # Process is running, add placeholder stats stats['status'] = 'running' stats['requests'] = 0 stats['subs'] = 0 except: pass return jsonify({ 'stats': stats, 'timestamp': datetime.now().isoformat() }) return jsonify({'error': 'Cannot connect to ElectrumX'}), 500 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 [] 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""" try: cpu_percent = psutil.cpu_percent(interval=1) memory = psutil.virtual_memory() disk = psutil.disk_usage('/') data = { 'cpu': { 'percent': cpu_percent, 'count': psutil.cpu_count() }, 'memory': { 'total': memory.total, 'used': memory.used, 'percent': memory.percent }, 'disk': { 'total': disk.total, 'used': disk.used, 'percent': disk.percent }, 'timestamp': datetime.now().isoformat() } return jsonify(data) except Exception as e: return jsonify({'error': str(e)}), 500 @app.route('/api/health') def health(): """Health check endpoint""" palladium_ok = palladium_rpc_call('getblockchaininfo') 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', 'services': { 'palladium': 'up' if palladium_ok else 'down', 'electrumx': 'up' if electrumx_ok else 'down' }, 'timestamp': datetime.now().isoformat() }) if __name__ == '__main__': warm_electrumx_caches_async() app.run(host='0.0.0.0', port=8080, debug=False)