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
*.tmp
*.temp
*.temp
# Python cache files
__pycache__/
*.py[cod]

View File

@@ -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)

View File

@@ -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) {

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;
}
#activeServers {
font-size: 14px;
font-weight: 500;
line-height: 1.5;
}
/* Resource Monitoring */
.resource-item {
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 name="viewport" content="width=device-width, initial-scale=1.0">
<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>
<body>
<div class="container">
@@ -30,7 +30,6 @@
<div class="card">
<div class="card-header">
<h2>System Resources</h2>
<span class="card-icon">💻</span>
</div>
<div class="card-content">
<div class="resource-item">
@@ -61,7 +60,6 @@
<div class="card">
<div class="card-header">
<h2>Palladium Node</h2>
<span class="card-icon">⛏️</span>
</div>
<div class="card-content">
<div class="stat-grid">
@@ -97,7 +95,6 @@
<div class="card">
<div class="card-header">
<h2>ElectrumX Server</h2>
<span class="card-icon"></span>
</div>
<div class="card-content">
<div class="electrumx-grid">
@@ -105,10 +102,6 @@
<div class="stat-label">Server Version</div>
<div class="stat-value" id="serverVersion">--</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-label">Database Size</div>
<div class="stat-value" id="dbSize">--</div>
@@ -117,6 +110,10 @@
<div class="stat-label">Uptime</div>
<div class="stat-value" id="uptime">--</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-label">Server IP</div>
<div class="stat-value" id="serverIP">--</div>
@@ -137,7 +134,6 @@
<div class="card">
<div class="card-header">
<h2>Mempool</h2>
<span class="card-icon">📝</span>
</div>
<div class="card-content">
<div class="stat-grid">
@@ -165,7 +161,6 @@
<div class="card full-width">
<div class="card-header">
<h2>Recent Blocks</h2>
<span class="card-icon">🔗</span>
</div>
<div class="card-content">
<div class="table-container">
@@ -195,6 +190,6 @@
</footer>
</div>
<script src="{{ url_for('static', filename='dashboard.js') }}?v=10"></script>
<script src="{{ url_for('static', filename='dashboard.js') }}?v=11"></script>
</body>
</html>

View File

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