Compare commits

...

3 Commits

8 changed files with 447 additions and 23 deletions

6
.gitignore vendored
View File

@@ -26,4 +26,8 @@ Thumbs.db
# Temporary files # Temporary files
*.tmp *.tmp
*.temp *.temp
# Python cache files
__pycache__/
*.py[cod]

View File

@@ -9,6 +9,8 @@ import requests
import json import json
import os import os
import time import time
import copy
import threading
from datetime import datetime from datetime import datetime
import psutil import psutil
import socket 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')) PALLADIUM_RPC_PORT = int(os.getenv('PALLADIUM_RPC_PORT', '2332'))
ELECTRUMX_RPC_HOST = os.getenv('ELECTRUMX_RPC_HOST', 'electrumx') ELECTRUMX_RPC_HOST = os.getenv('ELECTRUMX_RPC_HOST', 'electrumx')
ELECTRUMX_RPC_PORT = int(os.getenv('ELECTRUMX_RPC_PORT', '8000')) 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 # Read RPC credentials from palladium.conf
def get_rpc_credentials(): def get_rpc_credentials():
@@ -78,7 +201,7 @@ def palladium_rpc_call(method, params=None):
print(f"RPC call error ({method}): {e}") print(f"RPC call error ({method}): {e}")
return None return None
def get_electrumx_stats(): def get_electrumx_stats(include_addnode_probes=False):
"""Get ElectrumX statistics via Electrum protocol and system info""" """Get ElectrumX statistics via Electrum protocol and system info"""
try: try:
import socket import socket
@@ -94,6 +217,10 @@ def get_electrumx_stats():
'hash_function': '', 'hash_function': '',
'pruning': None, 'pruning': None,
'sessions': 0, 'sessions': 0,
'peer_discovery': 'unknown',
'peer_announce': 'unknown',
'active_servers': [],
'active_servers_count': 0,
'requests': 0, 'requests': 0,
'subs': 0, 'subs': 0,
'uptime': 0, 'uptime': 0,
@@ -106,7 +233,7 @@ def get_electrumx_stats():
# Get server IP address # Get server IP address
try: try:
# Try to get public IP from external service # 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: if response.status_code == 200:
stats['server_ip'] = response.json().get('ip', 'Unknown') stats['server_ip'] = response.json().get('ip', 'Unknown')
else: else:
@@ -152,6 +279,131 @@ def get_electrumx_stats():
except Exception as e: except Exception as e:
print(f"ElectrumX protocol error: {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 to get container stats via Docker
try: try:
# Get container uptime # Get container uptime
@@ -215,6 +467,11 @@ def peers():
"""Serve peers page""" """Serve peers page"""
return render_template('peers.html') 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') @app.route('/api/palladium/info')
def palladium_info(): def palladium_info():
"""Get Palladium node blockchain info""" """Get Palladium node blockchain info"""
@@ -299,8 +556,15 @@ def recent_blocks():
def electrumx_stats(): def electrumx_stats():
"""Get ElectrumX server statistics""" """Get ElectrumX server statistics"""
try: try:
stats = get_electrumx_stats() stats = get_electrumx_stats_cached(include_addnode_probes=False)
if stats: 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 # Get additional info from logs if available
try: try:
# Try to get container stats # Try to get container stats
@@ -315,7 +579,6 @@ def electrumx_stats():
if result.returncode == 0: if result.returncode == 0:
# Process is running, add placeholder stats # Process is running, add placeholder stats
stats['status'] = 'running' stats['status'] = 'running'
stats['sessions'] = 0 # Will show 0 for now
stats['requests'] = 0 stats['requests'] = 0
stats['subs'] = 0 stats['subs'] = 0
except: except:
@@ -329,6 +592,28 @@ def electrumx_stats():
except Exception as e: except Exception as e:
return jsonify({'error': str(e)}), 500 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') @app.route('/api/system/resources')
def system_resources(): def system_resources():
"""Get system resource usage""" """Get system resource usage"""
@@ -363,7 +648,10 @@ def system_resources():
def health(): def health():
"""Health check endpoint""" """Health check endpoint"""
palladium_ok = palladium_rpc_call('getblockchaininfo') is not None 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({ return jsonify({
'status': 'healthy' if (palladium_ok and electrumx_ok) else 'degraded', 'status': 'healthy' if (palladium_ok and electrumx_ok) else 'degraded',
@@ -375,4 +663,5 @@ def health():
}) })
if __name__ == '__main__': if __name__ == '__main__':
warm_electrumx_caches_async()
app.run(host='0.0.0.0', port=8080, debug=False) app.run(host='0.0.0.0', port=8080, debug=False)

View File

@@ -205,10 +205,6 @@ async function updateElectrumXStats() {
} }
document.getElementById('serverVersion').textContent = serverVersion; document.getElementById('serverVersion').textContent = serverVersion;
// Active sessions
const sessions = typeof data.stats.sessions === 'number' ? data.stats.sessions : '--';
document.getElementById('activeSessions').textContent = sessions;
// Database size // Database size
const dbSize = data.stats.db_size > 0 ? formatBytes(data.stats.db_size) : '--'; const dbSize = data.stats.db_size > 0 ? formatBytes(data.stats.db_size) : '--';
document.getElementById('dbSize').textContent = dbSize; document.getElementById('dbSize').textContent = dbSize;
@@ -225,6 +221,10 @@ async function updateElectrumXStats() {
// SSL Port // SSL Port
document.getElementById('sslPort').textContent = data.stats.ssl_port || 50002; 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) { } catch (error) {

View File

@@ -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 = '<tr><td colspan="3" class="loading">No active servers found</td></tr>';
document.getElementById('totalServers').textContent = '0';
document.getElementById('tcpReachable').textContent = '0';
return;
}
servers.forEach(server => {
const row = document.createElement('tr');
row.innerHTML = `
<td class="peer-addr">${server.host || '--'}</td>
<td>${server.tcp_port || '--'}</td>
<td>${server.ssl_port || '--'}</td>
`;
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 =
'<tr><td colspan="3" class="loading">Error loading servers</td></tr>';
}
}
async function updateAll() {
updateLastUpdateTime();
await updateElectrumServers();
}
document.addEventListener('DOMContentLoaded', async () => {
await updateAll();
setInterval(updateAll, 10000);
});

View File

@@ -242,6 +242,12 @@ body {
word-break: break-word; word-break: break-word;
} }
#activeServers {
font-size: 14px;
font-weight: 500;
line-height: 1.5;
}
/* Resource Monitoring */ /* Resource Monitoring */
.resource-item { .resource-item {
display: grid; display: grid;

View File

@@ -0,0 +1,75 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Electrum Active Servers - Palladium Dashboard</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}?v=11">
</head>
<body>
<div class="container">
<header class="header">
<div class="header-content">
<h1>
<svg class="logo-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/>
</svg>
Electrum Active Servers
</h1>
<a href="/" class="back-button">
<span>← Back to Dashboard</span>
</a>
</div>
</header>
<div class="dashboard-grid">
<div class="card">
<div class="card-header">
<h2>Discovery Summary</h2>
</div>
<div class="card-content">
<div class="stat-grid">
<div class="stat-item">
<div class="stat-label">Total Active Servers</div>
<div class="stat-value" id="totalServers">--</div>
</div>
<div class="stat-item">
<div class="stat-label">TCP 50001 Reachable</div>
<div class="stat-value" id="tcpReachable">--</div>
</div>
</div>
</div>
</div>
</div>
<div class="card full-width">
<div class="card-header">
<h2>Other Active Servers</h2>
</div>
<div class="card-content">
<div class="table-container">
<table class="blocks-table peers-table">
<thead>
<tr>
<th>Host</th>
<th>TCP Port</th>
<th>SSL Port</th>
</tr>
</thead>
<tbody id="electrumServersTable">
<tr><td colspan="3" class="loading">Loading servers...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<footer class="footer">
<p>Last updated: <span id="lastUpdate">--</span></p>
<p>Auto-refresh every 10 seconds</p>
</footer>
</div>
<script src="{{ url_for('static', filename='electrum_servers.js') }}?v=1"></script>
</body>
</html>

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Palladium & ElectrumX Dashboard</title> <title>Palladium & ElectrumX Dashboard</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}?v=10"> <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}?v=11">
</head> </head>
<body> <body>
<div class="container"> <div class="container">
@@ -30,7 +30,6 @@
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<h2>System Resources</h2> <h2>System Resources</h2>
<span class="card-icon">💻</span>
</div> </div>
<div class="card-content"> <div class="card-content">
<div class="resource-item"> <div class="resource-item">
@@ -61,7 +60,6 @@
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<h2>Palladium Node</h2> <h2>Palladium Node</h2>
<span class="card-icon">⛏️</span>
</div> </div>
<div class="card-content"> <div class="card-content">
<div class="stat-grid"> <div class="stat-grid">
@@ -97,7 +95,6 @@
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<h2>ElectrumX Server</h2> <h2>ElectrumX Server</h2>
<span class="card-icon"></span>
</div> </div>
<div class="card-content"> <div class="card-content">
<div class="electrumx-grid"> <div class="electrumx-grid">
@@ -105,10 +102,6 @@
<div class="stat-label">Server Version</div> <div class="stat-label">Server Version</div>
<div class="stat-value" id="serverVersion">--</div> <div class="stat-value" id="serverVersion">--</div>
</div> </div>
<div class="stat-item">
<div class="stat-label">Active Sessions</div>
<div class="stat-value" id="activeSessions">--</div>
</div>
<div class="stat-item"> <div class="stat-item">
<div class="stat-label">Database Size</div> <div class="stat-label">Database Size</div>
<div class="stat-value" id="dbSize">--</div> <div class="stat-value" id="dbSize">--</div>
@@ -117,6 +110,10 @@
<div class="stat-label">Uptime</div> <div class="stat-label">Uptime</div>
<div class="stat-value" id="uptime">--</div> <div class="stat-value" id="uptime">--</div>
</div> </div>
<a href="/electrum-servers" class="stat-item stat-link">
<div class="stat-label">Active Servers</div>
<div class="stat-value" id="activeServersCount">--</div>
</a>
<div class="stat-item full-width"> <div class="stat-item full-width">
<div class="stat-label">Server IP</div> <div class="stat-label">Server IP</div>
<div class="stat-value" id="serverIP">--</div> <div class="stat-value" id="serverIP">--</div>
@@ -137,7 +134,6 @@
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<h2>Mempool</h2> <h2>Mempool</h2>
<span class="card-icon">📝</span>
</div> </div>
<div class="card-content"> <div class="card-content">
<div class="stat-grid"> <div class="stat-grid">
@@ -165,7 +161,6 @@
<div class="card full-width"> <div class="card full-width">
<div class="card-header"> <div class="card-header">
<h2>Recent Blocks</h2> <h2>Recent Blocks</h2>
<span class="card-icon">🔗</span>
</div> </div>
<div class="card-content"> <div class="card-content">
<div class="table-container"> <div class="table-container">
@@ -195,6 +190,6 @@
</footer> </footer>
</div> </div>
<script src="{{ url_for('static', filename='dashboard.js') }}?v=10"></script> <script src="{{ url_for('static', filename='dashboard.js') }}?v=11"></script>
</body> </body>
</html> </html>

View File

@@ -28,7 +28,6 @@
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<h2>Connection Statistics</h2> <h2>Connection Statistics</h2>
<span class="card-icon">📊</span>
</div> </div>
<div class="card-content"> <div class="card-content">
<div class="stat-grid"> <div class="stat-grid">
@@ -57,7 +56,6 @@
<div class="card full-width"> <div class="card full-width">
<div class="card-header"> <div class="card-header">
<h2>Connected Peers</h2> <h2>Connected Peers</h2>
<span class="card-icon">🌐</span>
</div> </div>
<div class="card-content"> <div class="card-content">
<div class="table-container"> <div class="table-container">