mirror of
https://github.com/simplex-chat/simplexmq.git
synced 2026-06-06 08:21:36 +00:00
247 lines
9.2 KiB
Python
Executable File
247 lines
9.2 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Sync progress for the Reth + Nimbus stack.
|
|
|
|
Usage:
|
|
./progress.py # continuous (Ctrl-C to exit, auto-exits when synced)
|
|
./progress.py --once # single snapshot
|
|
|
|
Requires Nimbus REST port exposed at 127.0.0.1:5052 (add --rest flag in compose).
|
|
"""
|
|
|
|
import json
|
|
import sys
|
|
import time
|
|
from collections import deque
|
|
from datetime import timedelta
|
|
from urllib.error import URLError
|
|
from urllib.request import Request, urlopen
|
|
|
|
RETH = "http://127.0.0.1:8545"
|
|
NIMBUS = "http://127.0.0.1:5052"
|
|
INTERVAL = 5
|
|
WINDOW = 60
|
|
BAR_W = 40
|
|
|
|
# ANSI helpers
|
|
def c(s, code): return f"\033[{code}m{s}\033[0m"
|
|
GREEN, YELLOW, RED, DIM, BOLD = "32", "33", "31", "2;37", "1"
|
|
|
|
|
|
def rpc(method):
|
|
body = json.dumps({"jsonrpc": "2.0", "method": method, "params": [], "id": 1}).encode()
|
|
req = Request(RETH, data=body, headers={"Content-Type": "application/json"})
|
|
return json.loads(urlopen(req, timeout=5).read())["result"]
|
|
|
|
|
|
def get_reth():
|
|
try:
|
|
r = rpc("eth_syncing")
|
|
try:
|
|
peers = int(rpc("net_peerCount"), 16)
|
|
except Exception:
|
|
peers = -1 # net namespace not exposed
|
|
if r is False:
|
|
head = int(rpc("eth_blockNumber"), 16)
|
|
return {"state": "synced", "current": head, "target": head, "peers": peers,
|
|
"stage": None, "stages": {}, "err": None}
|
|
current = int(r["currentBlock"], 16)
|
|
highest = int(r["highestBlock"], 16)
|
|
# Build stage map (name -> block).
|
|
stages = {s["name"]: int(s["block"], 16) for s in r.get("stages", [])}
|
|
active_stages = {k: v for k, v in stages.items() if v > 0}
|
|
# Headers download phase: nothing has progressed yet.
|
|
if current == 0 and highest == 0 and not active_stages:
|
|
return {"state": "headers", "current": 0, "target": 0, "peers": peers,
|
|
"stage": "Headers", "stages": stages, "err": None}
|
|
# Derive progress from the stages pipeline.
|
|
# Bottleneck (rate-limiting stage) = stage with lowest non-zero block.
|
|
# Target = leading stage block (typically Headers = chain tip).
|
|
# Reth's top-level currentBlock/highestBlock are unreliable during initial
|
|
# sync (often 0 until execution stage runs), so prefer stages-derived values.
|
|
if active_stages:
|
|
bottleneck = min(active_stages, key=active_stages.get)
|
|
stage_current = active_stages[bottleneck]
|
|
stage_target = max(stages.values()) if stages else 0
|
|
# Trust the stages-derived values if highest is unset or stages tip is higher.
|
|
if highest <= 0 or stage_target > highest:
|
|
current = stage_current
|
|
highest = stage_target
|
|
elif current <= 0:
|
|
current = stage_current
|
|
else:
|
|
bottleneck = None
|
|
return {"state": "syncing", "current": current, "target": highest,
|
|
"peers": peers, "stage": bottleneck, "stages": stages, "err": None}
|
|
except URLError as e:
|
|
return {"state": "down", "current": 0, "target": 0, "peers": 0,
|
|
"stage": None, "stages": {}, "err": str(e.reason)}
|
|
except Exception as e:
|
|
return {"state": "error", "current": 0, "target": 0, "peers": 0,
|
|
"stage": None, "stages": {}, "err": str(e)}
|
|
|
|
|
|
def get_nimbus():
|
|
try:
|
|
d = json.loads(urlopen(f"{NIMBUS}/eth/v1/node/syncing", timeout=5).read())["data"]
|
|
peers_d = json.loads(urlopen(f"{NIMBUS}/eth/v1/node/peer_count", timeout=5).read())["data"]
|
|
head = int(d["head_slot"])
|
|
dist = int(d["sync_distance"])
|
|
peers = int(peers_d.get("connected", "0"))
|
|
return {"state": "synced" if not d["is_syncing"] else "syncing",
|
|
"current": head, "target": head + dist, "peers": peers,
|
|
"optimistic": bool(d.get("is_optimistic", False)),
|
|
"el_offline": bool(d.get("el_offline", False)),
|
|
"err": None}
|
|
except URLError as e:
|
|
return {"state": "down", "current": 0, "target": 0, "peers": 0,
|
|
"optimistic": False, "el_offline": False, "err": str(e.reason)}
|
|
except Exception as e:
|
|
return {"state": "error", "current": 0, "target": 0, "peers": 0,
|
|
"optimistic": False, "el_offline": False, "err": str(e)}
|
|
|
|
|
|
def format_num(n): return f"{n:,}"
|
|
|
|
|
|
def format_eta(seconds):
|
|
if seconds is None: return "?"
|
|
if seconds < 0: return "?"
|
|
if seconds < 60: return f"{int(seconds)}s"
|
|
if seconds < 3600:
|
|
return f"{int(seconds // 60)}m {int(seconds % 60)}s"
|
|
if seconds < 86400:
|
|
return f"{int(seconds // 3600)}h {int((seconds % 3600) // 60)}m"
|
|
return f"{int(seconds // 86400)}d {int((seconds % 86400) // 3600)}h"
|
|
|
|
|
|
def rate_per_sec(history):
|
|
if len(history) < 2: return None
|
|
t0, c0 = history[0]
|
|
t1, c1 = history[-1]
|
|
if t1 <= t0: return None
|
|
return (c1 - c0) / (t1 - t0)
|
|
|
|
|
|
def eta_seconds(history, target):
|
|
r = rate_per_sec(history)
|
|
if r is None or r <= 0: return None
|
|
remaining = target - history[-1][1]
|
|
if remaining <= 0: return 0
|
|
return remaining / r
|
|
|
|
|
|
def progress_bar(pct):
|
|
pct = max(0.0, min(100.0, pct))
|
|
filled = int(pct / 100 * BAR_W)
|
|
return c("█" * filled, GREEN) + c("░" * (BAR_W - filled), DIM)
|
|
|
|
|
|
def peers_label(peers):
|
|
if peers < 0:
|
|
return c("· peers unknown (enable net namespace)", DIM)
|
|
return c(f"· {peers} peers", DIM)
|
|
|
|
|
|
def stages_summary(stages):
|
|
"""One-line view: stages that have progressed, with their block numbers."""
|
|
if not stages:
|
|
return ""
|
|
advanced = [(n, b) for n, b in stages.items() if b > 0]
|
|
if not advanced:
|
|
return c(" stages: all 0 (headers downloading)", DIM)
|
|
advanced.sort(key=lambda kv: kv[1], reverse=True)
|
|
parts = [f"{n}={format_num(b)}" for n, b in advanced[:4]]
|
|
return c(" stages: " + ", ".join(parts), DIM)
|
|
|
|
|
|
def render_one(name, x, hist):
|
|
state = x["state"]
|
|
peers = x.get("peers", 0)
|
|
extras = []
|
|
if name == "Nimbus":
|
|
if x.get("optimistic"):
|
|
extras.append(c("(optimistic head — Reth not yet verifying)", YELLOW))
|
|
if x.get("el_offline"):
|
|
extras.append(c("⚠ EL OFFLINE", RED))
|
|
if state == "synced":
|
|
out = [f" {c(name, BOLD):<14s} {c('✓ synced', GREEN)} {c(format_num(x['current']), BOLD)} {peers_label(peers)}"]
|
|
elif state == "headers":
|
|
out = [
|
|
f" {c(name, BOLD):<14s} {c('⧗ headers', YELLOW)} {c('downloading initial chain', DIM)} {peers_label(peers)}",
|
|
f" {c('(per-block progress unavailable until headers validated — see docker logs)', DIM)}",
|
|
]
|
|
elif state == "syncing" and x["target"] <= 0:
|
|
out = [f" {c(name, BOLD):<14s} {c('⧗ syncing', YELLOW)} {c('waiting for fork-choice', DIM)} {peers_label(peers)}"]
|
|
elif state == "syncing":
|
|
pct = x["current"] / x["target"] * 100
|
|
r = rate_per_sec(hist)
|
|
eta = eta_seconds(hist, x["target"])
|
|
rate_s = f"{format_num(int(r))} /s" if r and r > 0 else c("stalled", RED)
|
|
eta_s = format_eta(eta) if eta is not None else "?"
|
|
stage = x.get("stage")
|
|
stage_s = c(f"[{stage}]", DIM) if stage else ""
|
|
out = [
|
|
f" {c(name, BOLD):<14s} {c('⧗ syncing', YELLOW)} {format_num(x['current'])} / {format_num(x['target'])} {stage_s} {peers_label(peers)}",
|
|
f" {progress_bar(pct)} {c(f'{pct:6.2f}%', BOLD)}",
|
|
f" {c(rate_s, DIM)} ETA {c(eta_s, BOLD)}",
|
|
]
|
|
else:
|
|
out = [
|
|
f" {c(name, BOLD):<14s} {c('✗ ' + state, RED)}",
|
|
f" {c(x.get('err') or '', DIM)}",
|
|
]
|
|
# Reth-only: stages summary
|
|
if name == "Reth" and x.get("stages"):
|
|
out.append(f" {stages_summary(x['stages'])}")
|
|
for e in extras:
|
|
out.append(f" {e}")
|
|
return out
|
|
|
|
|
|
def render(reth, nimbus, reth_hist, nim_hist):
|
|
print("\033[2J\033[H", end="")
|
|
width = 64
|
|
title = f"Reth + Nimbus sync"
|
|
ts = time.strftime("%H:%M:%S")
|
|
print()
|
|
print(f" {c(title, BOLD)} {c(ts, DIM)}")
|
|
print(f" {c('─' * width, DIM)}")
|
|
print()
|
|
for line in render_one("Reth", reth, reth_hist):
|
|
print(line)
|
|
print()
|
|
for line in render_one("Nimbus", nimbus, nim_hist):
|
|
print(line)
|
|
print()
|
|
win_s = (len(reth_hist) - 1) * INTERVAL if len(reth_hist) > 1 else 0
|
|
print(f" {c(f'window {win_s}s · refresh {INTERVAL}s · Ctrl-C to exit', DIM)}")
|
|
print()
|
|
|
|
|
|
def main():
|
|
once = "--once" in sys.argv
|
|
reth_hist = deque(maxlen=WINDOW)
|
|
nim_hist = deque(maxlen=WINDOW)
|
|
try:
|
|
while True:
|
|
r = get_reth()
|
|
n = get_nimbus()
|
|
now = time.time()
|
|
if r["target"] > 0 or r["state"] == "syncing":
|
|
reth_hist.append((now, r["current"]))
|
|
if n["target"] > 0 or n["state"] == "syncing":
|
|
nim_hist.append((now, n["current"]))
|
|
render(r, n, reth_hist, nim_hist)
|
|
if once:
|
|
break
|
|
if r["state"] == "synced" and n["state"] == "synced":
|
|
print(f" {c('✓ all synced.', GREEN)}\n")
|
|
break
|
|
time.sleep(INTERVAL)
|
|
except KeyboardInterrupt:
|
|
print()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|