Compare commits
4 Commits
bb441f16e8
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 20740b3704 | |||
| 602592e304 | |||
| 6bca2c62e6 | |||
| 4e27efc3b9 |
@@ -14,11 +14,29 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
./pi-stat -g / --geo # With geolocation (implies -p)
|
./pi-stat -g / --geo # With geolocation (implies -p)
|
||||||
./pi-stat -s / --show-ip # Show IP column (hidden by default)
|
./pi-stat -s / --show-ip # Show IP column (hidden by default)
|
||||||
./pi-stat -x / --extended # Show full RX/TX/RX-rate/TX-rate columns (default: ratio only)
|
./pi-stat -x / --extended # Show full RX/TX/RX-rate/TX-rate columns (default: ratio only)
|
||||||
./pi-stat -i [SEC] / --interactive [SEC] # Interactive auto-refresh (default: 10s)
|
./pi-stat -i [SEC] / --interactive [SEC] # Interactive auto-refresh (default: 5s, configurable)
|
||||||
```
|
```
|
||||||
|
|
||||||
No build step. Requires `pip install docker` and a running Docker daemon. Runs on Linux, WSL2, and Windows.
|
No build step. Requires `pip install docker` and a running Docker daemon. Runs on Linux, WSL2, and Windows.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Optional JSON config file, first match wins: `$PI_STAT_CONFIG` → `~/.config/pi-stat/config.json` → `<script dir>/pi-stat.json` (see `pi-stat.json`, shipped with the repo). Loaded by `load_config()`; missing, unreadable or non-object files fall back to defaults with a warning on stderr.
|
||||||
|
|
||||||
|
| Key | Default | Meaning |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `refresh_interval` | `5` | Interactive auto-refresh interval in seconds (clamped to >= 1) |
|
||||||
|
|
||||||
|
Precedence for the refresh interval: `-i SEC` on the command line > config file > `DEFAULT_REFRESH` (5).
|
||||||
|
|
||||||
|
Standard JSON has no comment syntax; document settings with a `_comment` key instead (any key not read by `config_refresh()` is ignored), e.g.:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"_comment": "refresh_interval in seconds, min. 1 (default: 5)",
|
||||||
|
"refresh_interval": 5
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
Single self-contained script (`pi-stat`). Logic is split into helper functions and a `run_cycle()` function that collects data and renders one full screen, called either once (normal mode) or in a loop (interactive mode).
|
Single self-contained script (`pi-stat`). Logic is split into helper functions and a `run_cycle()` function that collects data and renders one full screen, called either once (normal mode) or in a loop (interactive mode).
|
||||||
@@ -27,10 +45,11 @@ Single self-contained script (`pi-stat`). Logic is split into helper functions a
|
|||||||
|
|
||||||
- **Docker SDK** — container CPU/RAM stats; all commands run inside the auto-detected container (`mainnet` → `testnet2` → `testnet`, first running one) via `exec_run`
|
- **Docker SDK** — container CPU/RAM stats; all commands run inside the auto-detected container (`mainnet` → `testnet2` → `testnet`, first running one) via `exec_run`
|
||||||
- **Stellar Core HTTP API** — `exec_json()` runs `stellar-core http-command <cmd>` inside the container
|
- **Stellar Core HTTP API** — `exec_json()` runs `stellar-core http-command <cmd>` inside the container
|
||||||
- **Horizon API** — `http://localhost:31401`
|
- **Horizon API** — `curl http://127.0.0.1:8000/` run inside the container via `exec_json()`, not via the host port `31401` (host→bridge forwarding can hang until timeout if Docker's host firewall rules are broken)
|
||||||
- **PostgreSQL** — `psql` inside the container; used for peer DB counts and pubkey→IP mapping
|
- **PostgreSQL** — `psql` inside the container; used for peer DB counts and pubkey→IP mapping
|
||||||
- **ZFS CLI** — `zpool`/`zfs` commands on the host for disk health (`z01pool`); if `zpool` is not found (`HAS_ZFS = False`), falls back to `shutil.disk_usage()` — works on Linux without ZFS, WSL2, and Windows
|
- **ZFS CLI** — `zpool`/`zfs` commands on the host for disk health (`z01pool`); if `zpool` is not found (`HAS_ZFS = False`), falls back to `shutil.disk_usage()` — works on Linux without ZFS, WSL2, and Windows
|
||||||
- **ip-api.com** — batch geolocation (Country, City), cached in `_geo_cache`; only fetched with `-g`
|
- **ip-api.com** — batch geolocation (Country, City), cached in `_geo_cache`; only fetched with `-g`
|
||||||
|
- **System `ping` / TCP connect** — per peer from the host (`ping_batch()`, parallel, private IPs skipped): one ICMP ping (1 s timeout); if the peer filters ICMP, falls back to a TCP connect on the peer port from its address (1 s timeout). Shown as `Ping` next to stellar-core's `Latency` in the `-p`/`-g` table; TCP values are marked with a trailing `t` (legend printed below the table), `—` if neither answers
|
||||||
|
|
||||||
### Output sections (in order)
|
### Output sections (in order)
|
||||||
|
|
||||||
@@ -49,11 +68,17 @@ Single self-contained script (`pi-stat`). Logic is split into helper functions a
|
|||||||
|
|
||||||
Outbound peers are numbered in the table. Full node IDs are fetched via `peers?fullkeys=true` for the `droppeer` API call.
|
Outbound peers are numbered in the table. Full node IDs are fetched via `peers?fullkeys=true` for the `droppeer` API call.
|
||||||
|
|
||||||
|
The refresh deadline is anchored to the start of the cycle (`cycle_start`), not its end, so the update's own execution time doesn't stack on top of `refresh_interval`. Execution time is measured and a warning is printed (into the same buffered screen) when a cycle takes >= `REFRESH_WARN_RATIO` (80%) of the interval, or exceeds it outright.
|
||||||
|
|
||||||
### Key patterns
|
### Key patterns
|
||||||
|
|
||||||
- **Graceful degradation** — every external call is wrapped in try-except; missing data renders as `—`
|
- **Graceful degradation** — every external call is wrapped in try-except; missing data renders as `—`
|
||||||
- **Dynamic column widths** — all table columns sized at runtime from actual data length
|
- **Dynamic column widths** — all table columns sized at runtime from actual data length
|
||||||
- **NAT detection** — inbound peers with private IPs are replaced with their DB-stored public IP (`db_peer_addr` from PostgreSQL)
|
- **NAT detection** — inbound peers with private IPs are replaced with their DB-stored public IP (`db_peer_addr` from PostgreSQL)
|
||||||
- **Validator matching** — `load_validators()` parses `stellar-core.cfg` and indexes by IP, name, and public key; matched peers show a truncated key + `(Vn)` label
|
- **Validator matching** — `load_validators()` parses `stellar-core.cfg` and indexes by IP, name, and public key; matched peers show a truncated key + `(Vn)` label
|
||||||
|
- **Ping vs. Latency** — `Latency` is stellar-core's round trip to the peer *process*, `Ping` is ICMP to the peer's IP; a low ping with high latency hints at a VPN exit / relay in front of the actual node. Peers with `Latency > LATENCY_PING_RATIO (3) × Ping` get a `⚡` between the two columns (2-column cell, since `⚡` is double-width in terminals); a legend line is printed below the table only when a `⚡` or `t` marker is present
|
||||||
- **Geo caching** — `_geo_cache` is a module-level dict; `geo_batch()` only fetches IPs not already cached
|
- **Geo caching** — `_geo_cache` is a module-level dict; `geo_batch()` only fetches IPs not already cached
|
||||||
- **Flag parsing** — manual `sys.argv` parsing; `-g` implies `-p`; `-i` accepts an optional next argument as the refresh interval
|
- **Flag parsing** — manual `sys.argv` parsing; `-g` implies `-p`; `-i` accepts an optional next argument as the refresh interval
|
||||||
|
- **Config precedence** — `config_refresh()` reads the config value; an explicit `-i SEC` argument overrides it
|
||||||
|
- **Cycle timing** — `interactive_loop()` measures cycle duration and warns via `warn()` when it nears or exceeds `refresh_interval`, so a slow update doesn't silently push out the actual refresh cadence
|
||||||
|
- **Persistent cbreak** — the tty is switched to cbreak once for the whole session (not reset to canonical between cycles); doing it per-cycle would flush unread input on the mode switch and leave the terminal line-buffered during `run_cycle()`, silently dropping keypresses (e.g. `q`) made while a cycle overruns `refresh_interval`
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import socket
|
|||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from urllib.request import urlopen, Request
|
from urllib.request import urlopen, Request
|
||||||
from urllib.error import URLError
|
from urllib.error import URLError
|
||||||
@@ -38,8 +39,16 @@ except ImportError:
|
|||||||
# --- Configuration ---
|
# --- Configuration ---
|
||||||
HAS_ZFS = shutil.which("zpool") is not None
|
HAS_ZFS = shutil.which("zpool") is not None
|
||||||
CANDIDATE_CONTAINERS = ["mainnet", "testnet2", "testnet"]
|
CANDIDATE_CONTAINERS = ["mainnet", "testnet2", "testnet"]
|
||||||
HORIZON_URL = "http://localhost:31401"
|
HORIZON_URL = "http://127.0.0.1:8000" # queried from inside the container, not via the host port
|
||||||
CFG_PATH = os.path.join(os.path.dirname(os.path.realpath(__file__)), "stellar-core.cfg")
|
CFG_PATH = os.path.join(os.path.dirname(os.path.realpath(__file__)), "stellar-core.cfg")
|
||||||
|
DEFAULT_REFRESH = 5
|
||||||
|
REFRESH_WARN_RATIO = 0.8 # warn if a cycle takes >= 80% of the refresh interval
|
||||||
|
LATENCY_PING_RATIO = 3 # mark peers with ⚡ when core latency > 3× ping/TCP RTT
|
||||||
|
CONFIG_PATHS = [
|
||||||
|
os.environ.get("PI_STAT_CONFIG"),
|
||||||
|
os.path.join(os.path.expanduser("~"), ".config", "pi-stat", "config.json"),
|
||||||
|
os.path.join(os.path.dirname(os.path.realpath(__file__)), "pi-stat.json"),
|
||||||
|
]
|
||||||
|
|
||||||
# --- Colors / Icons ---
|
# --- Colors / Icons ---
|
||||||
GREEN = "\033[0;32m"
|
GREEN = "\033[0;32m"
|
||||||
@@ -144,6 +153,110 @@ def geo_str(ip):
|
|||||||
return ", ".join(p for p in geo if p)
|
return ", ".join(p for p in geo if p)
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
HAS_PING = shutil.which("ping") is not None
|
||||||
|
_PING_RE = re.compile(r"[=<]\s*([\d.,]+)\s*ms", re.IGNORECASE)
|
||||||
|
|
||||||
|
def ping_rtt(ip, timeout_s=1):
|
||||||
|
"""Single ICMP ping via the system 'ping'. Returns RTT in ms, or None (no reply / filtered)."""
|
||||||
|
if os.name == "nt":
|
||||||
|
cmd = ["ping", "-n", "1", "-w", str(timeout_s * 1000), ip]
|
||||||
|
else:
|
||||||
|
cmd = ["ping", "-c", "1", "-W", str(timeout_s), "-n", ip]
|
||||||
|
try:
|
||||||
|
r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout_s + 2,
|
||||||
|
env={**os.environ, "LC_ALL": "C"})
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
if r.returncode != 0:
|
||||||
|
return None
|
||||||
|
# Linux: "time=37.5 ms", Windows: "time=37ms" / "Zeit<1ms"
|
||||||
|
for line in r.stdout.splitlines():
|
||||||
|
if "ttl" in line.lower():
|
||||||
|
mt = _PING_RE.search(line)
|
||||||
|
if mt:
|
||||||
|
try:
|
||||||
|
return float(mt.group(1).replace(",", "."))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
def tcp_rtt(ip, port, timeout_s=1):
|
||||||
|
"""TCP connect time to ip:port in ms (≈ 1 RTT: SYN → SYN/ACK), or None if not connectable."""
|
||||||
|
t = time.perf_counter()
|
||||||
|
try:
|
||||||
|
with socket.create_connection((ip, port), timeout=timeout_s):
|
||||||
|
return (time.perf_counter() - t) * 1000
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def probe_rtt(ip, port):
|
||||||
|
"""ICMP ping first; if the peer filters ICMP, fall back to a TCP connect on its peer port.
|
||||||
|
Returns (rtt_ms, "icmp" | "tcp") or (None, None)."""
|
||||||
|
if HAS_PING:
|
||||||
|
v = ping_rtt(ip)
|
||||||
|
if v is not None:
|
||||||
|
return v, "icmp"
|
||||||
|
if port:
|
||||||
|
v = tcp_rtt(ip, port)
|
||||||
|
if v is not None:
|
||||||
|
return v, "tcp"
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
def ping_batch(addrs):
|
||||||
|
"""Probe all public peer addresses ("ip:port") in parallel.
|
||||||
|
Returns {ip: (rtt_ms, kind)}; private/invalid IPs are skipped."""
|
||||||
|
targets = {}
|
||||||
|
for addr in addrs:
|
||||||
|
ip, _, port = addr.rpartition(":")
|
||||||
|
try:
|
||||||
|
if ipaddress.ip_address(ip).is_private:
|
||||||
|
continue
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
targets[ip] = int(port) if port.isdigit() else None
|
||||||
|
if not targets:
|
||||||
|
return {}
|
||||||
|
with ThreadPoolExecutor(max_workers=min(32, len(targets))) as ex:
|
||||||
|
return dict(zip(targets, ex.map(lambda t: probe_rtt(*t), targets.items())))
|
||||||
|
|
||||||
|
def fmt_ms(v):
|
||||||
|
"""Format a millisecond value: one decimal below 10 ms, integer above."""
|
||||||
|
if v is None:
|
||||||
|
return "—"
|
||||||
|
return f"{v:.1f}ms" if v < 10 else f"{v:.0f}ms"
|
||||||
|
|
||||||
|
def fmt_ping(res):
|
||||||
|
"""Ping cell: ICMP value plain, TCP-connect value marked with a trailing 't'."""
|
||||||
|
v, kind = res if res else (None, None)
|
||||||
|
return f"{fmt_ms(v)} {'t' if kind == 'tcp' else ' '}"
|
||||||
|
|
||||||
|
def load_config():
|
||||||
|
"""Read the first existing config file (JSON object). Returns {} if none is usable."""
|
||||||
|
for path in CONFIG_PATHS:
|
||||||
|
if not path or not os.path.isfile(path):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
with open(path, "r", encoding="utf-8") as fh:
|
||||||
|
data = json.load(fh)
|
||||||
|
if isinstance(data, dict):
|
||||||
|
return data
|
||||||
|
print(f"WARN: Config {path}: kein JSON-Objekt – wird ignoriert", file=sys.stderr)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"WARN: Config {path} nicht lesbar: {e}", file=sys.stderr)
|
||||||
|
return {}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def config_refresh(cfg):
|
||||||
|
"""refresh_interval from config, clamped to >= 1 s; falls back to DEFAULT_REFRESH."""
|
||||||
|
val = cfg.get("refresh_interval", DEFAULT_REFRESH)
|
||||||
|
try:
|
||||||
|
return max(1, int(val))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
print(f"WARN: refresh_interval '{val}' ungültig – benutze {DEFAULT_REFRESH}s", file=sys.stderr)
|
||||||
|
return DEFAULT_REFRESH
|
||||||
|
|
||||||
|
|
||||||
def load_validators(cfg_path):
|
def load_validators(cfg_path):
|
||||||
"""Parse [[VALIDATORS]] sections, returning {ip_or_name: (index, public_key)}."""
|
"""Parse [[VALIDATORS]] sections, returning {ip_or_name: (index, public_key)}."""
|
||||||
if not os.path.exists(cfg_path):
|
if not os.path.exists(cfg_path):
|
||||||
@@ -228,12 +341,12 @@ def run_cycle(client, peers_extended, geo_enabled, show_ip, interactive=False, e
|
|||||||
if raw:
|
if raw:
|
||||||
core_info = raw.get("info")
|
core_info = raw.get("info")
|
||||||
|
|
||||||
|
# Query Horizon via curl inside the container: independent of the host's
|
||||||
|
# port forwarding (31401), which can hang until timeout if Docker's
|
||||||
|
# firewall rules on the host are broken.
|
||||||
hz_data = None
|
hz_data = None
|
||||||
try:
|
if container:
|
||||||
with urlopen(HORIZON_URL + "/", timeout=5) as resp:
|
hz_data = exec_json(container, ["curl", "-s", "-m", "5", HORIZON_URL + "/"])
|
||||||
hz_data = json.loads(resp.read())
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# =============================================================
|
# =============================================================
|
||||||
# 1. CONTAINER STATUS
|
# 1. CONTAINER STATUS
|
||||||
@@ -339,23 +452,36 @@ def run_cycle(client, peers_extended, geo_enabled, show_ip, interactive=False, e
|
|||||||
hdr("🌅 API Status (Horizon)")
|
hdr("🌅 API Status (Horizon)")
|
||||||
|
|
||||||
if hz_data is None:
|
if hz_data is None:
|
||||||
err(f"Horizon API not reachable ({HORIZON_URL})")
|
err(f"Horizon API not reachable ({HORIZON_URL} in container '{container_name}')")
|
||||||
else:
|
else:
|
||||||
core_blk = hz_data.get("core_latest_ledger", 0)
|
core_blk = hz_data.get("core_latest_ledger", 0)
|
||||||
hist_blk = hz_data.get("history_latest_ledger", 0)
|
hist_blk = hz_data.get("history_latest_ledger", 0)
|
||||||
|
elder_blk = hz_data.get("history_elder_ledger", 0)
|
||||||
ings_blk = hz_data.get("ingest_latest_ledger", 0)
|
ings_blk = hz_data.get("ingest_latest_ledger", 0)
|
||||||
lag = core_blk - ings_blk
|
|
||||||
closed_at = hz_data.get("history_latest_ledger_closed_at", "?")
|
closed_at = hz_data.get("history_latest_ledger_closed_at", "?")
|
||||||
|
|
||||||
if lag <= 2:
|
# Reference ledger for the ingest lag: stellar-core's own ledger if we
|
||||||
|
# have it, otherwise what Horizon reports for core. core_latest_ledger
|
||||||
|
# is 0 when Horizon has lost its connection to stellar-core; computing
|
||||||
|
# the lag against 0 would give a negative value and a false "Synced".
|
||||||
|
ref_blk = block if core_info is not None else core_blk
|
||||||
|
lag = (ref_blk - ings_blk) if ref_blk else None
|
||||||
|
|
||||||
|
if not core_blk:
|
||||||
|
err("Status: Horizon has no connection to stellar-core (core_latest_ledger = 0)")
|
||||||
|
if lag is not None and lag > 2:
|
||||||
|
warn(f"Ingest {lag} ledger behind core")
|
||||||
|
elif lag <= 2:
|
||||||
ok(f"Status: Synced (Ingest lag: {lag} ledger)")
|
ok(f"Status: Synced (Ingest lag: {lag} ledger)")
|
||||||
else:
|
else:
|
||||||
warn(f"Status: Lagging (Ingest {lag} ledger behind core)")
|
warn(f"Status: Lagging (Ingest {lag} ledger behind core)")
|
||||||
core_diff = (block - core_blk) if (core_info is not None and core_blk) else None
|
core_diff = (block - core_blk) if (core_info is not None and core_blk) else None
|
||||||
core_diff_str = f" (Δ {core_diff:+d} vs Core)" if core_diff is not None else ""
|
core_diff_str = f" (Δ {core_diff:+d} vs Core)" if core_diff is not None else ""
|
||||||
print(f" Core Latest Ledger: {core_blk}{core_diff_str}")
|
core_blk_str = str(core_blk) if core_blk else "— (not connected)"
|
||||||
|
print(f" Core Latest Ledger: {core_blk_str}{core_diff_str}")
|
||||||
print(f" Ingest Latest Ledger: {ings_blk} (last closed: {closed_at})")
|
print(f" Ingest Latest Ledger: {ings_blk} (last closed: {closed_at})")
|
||||||
print(f" History Oldest: {hist_blk}")
|
print(f" History Latest: {hist_blk}")
|
||||||
|
print(f" History Oldest: {elder_blk}")
|
||||||
|
|
||||||
# =============================================================
|
# =============================================================
|
||||||
# 5. NODE METRICS (stellar-core)
|
# 5. NODE METRICS (stellar-core)
|
||||||
@@ -502,6 +628,12 @@ def run_cycle(client, peers_extended, geo_enabled, show_ip, interactive=False, e
|
|||||||
_all_ips.append(node_ip_str)
|
_all_ips.append(node_ip_str)
|
||||||
geo_batch([ip for ip in _all_ips if ip])
|
geo_batch([ip for ip in _all_ips if ip])
|
||||||
|
|
||||||
|
# --- Ping from this host to each peer's known IP ---
|
||||||
|
# Compared with stellar-core's "Latency", a low ping but high
|
||||||
|
# latency hints at a VPN exit / relay in front of the actual node.
|
||||||
|
# ICMP first, TCP connect to the peer port as fallback (marked 't').
|
||||||
|
ping_ms = ping_batch([p.get("address", "") for p in inbound + outbound])
|
||||||
|
|
||||||
def _is_private(ip):
|
def _is_private(ip):
|
||||||
try:
|
try:
|
||||||
return ipaddress.ip_address(ip).is_private
|
return ipaddress.ip_address(ip).is_private
|
||||||
@@ -596,10 +728,12 @@ def run_cycle(client, peers_extended, geo_enabled, show_ip, interactive=False, e
|
|||||||
ratio_total = max(ratio_total, len("RX/TX"))
|
ratio_total = max(ratio_total, len("RX/TX"))
|
||||||
_traffic_hdr = f" {'RX/TX':>{ratio_total}}"
|
_traffic_hdr = f" {'RX/TX':>{ratio_total}}"
|
||||||
_traffic_node = f" {fmt_ratio_cell(None, ratio_l_w, ratio_r_w):>{ratio_total}}"
|
_traffic_node = f" {fmt_ratio_cell(None, ratio_l_w, ratio_r_w):>{ratio_total}}"
|
||||||
HDR = f" {hdr_num_prefix}{'NodeID':<{col_w}} {'Version':<{ver_w}}{_ip_hdr_part}{_geo_hdr_part} {'Latency':>8} {'Uptime':<8}{_traffic_hdr} Flow"
|
HDR = f" {hdr_num_prefix}{'NodeID':<{col_w}} {'Version':<{ver_w}}{_ip_hdr_part}{_geo_hdr_part} {'Latency':>8} {'':2} {'Ping':>9} {'Uptime':<8}{_traffic_hdr} Flow"
|
||||||
NODE_ROW = f" {node_row_prefix}{node_id_str:<{col_w}} {node_ver_str:<{ver_w}}{_ip_node_part}{_geo_node_part} {'—':>8} {node_up_str:<8}{_traffic_node} {node_flow_str}"
|
NODE_ROW = f" {node_row_prefix}{node_id_str:<{col_w}} {node_ver_str:<{ver_w}}{_ip_node_part}{_geo_node_part} {'—':>8} {'':2} {fmt_ping(None):>9} {node_up_str:<8}{_traffic_node} {node_flow_str}"
|
||||||
SEP = " " + "─" * (len(HDR) - 2)
|
SEP = " " + "─" * (len(HDR) - 2)
|
||||||
|
|
||||||
|
lat_flagged = [] # peers marked ⚡ (for the legend)
|
||||||
|
|
||||||
def show(peers, is_out):
|
def show(peers, is_out):
|
||||||
for p in sorted(peers, key=lambda p: p.get("elapsed", 0), reverse=True):
|
for p in sorted(peers, key=lambda p: p.get("elapsed", 0), reverse=True):
|
||||||
raw_addr = p.get("address", "?").rsplit(":", 1)[0]
|
raw_addr = p.get("address", "?").rsplit(":", 1)[0]
|
||||||
@@ -615,6 +749,16 @@ def run_cycle(client, peers_extended, geo_enabled, show_ip, interactive=False, e
|
|||||||
ver = fmt_version(p.get("ver", ""))
|
ver = fmt_version(p.get("ver", ""))
|
||||||
lat = p.get("latency", "?")
|
lat = p.get("latency", "?")
|
||||||
lat_s = "n/a" if lat == 86400000 else f"{lat}ms"
|
lat_s = "n/a" if lat == 86400000 else f"{lat}ms"
|
||||||
|
ping_res = ping_ms.get(raw_addr)
|
||||||
|
ping_s = fmt_ping(ping_res)
|
||||||
|
# ⚡ when stellar-core's latency exceeds the network RTT by more than
|
||||||
|
# LATENCY_PING_RATIO (possible VPN exit / relay in front of the node).
|
||||||
|
# "⚡" is 2 columns wide in the terminal, so the blank is 2 spaces.
|
||||||
|
rtt = ping_res[0] if ping_res else None
|
||||||
|
lat_flag = ("⚡" if isinstance(lat, (int, float)) and lat != 86400000
|
||||||
|
and rtt and lat > LATENCY_PING_RATIO * rtt else " ")
|
||||||
|
if lat_flag == "⚡":
|
||||||
|
lat_flagged.append(pid)
|
||||||
up = fmt_up(p.get("elapsed", 0))
|
up = fmt_up(p.get("elapsed", 0))
|
||||||
rx = p.get("byte_read", 0) or 0
|
rx = p.get("byte_read", 0) or 0
|
||||||
tx = p.get("byte_write", 0) or 0
|
tx = p.get("byte_write", 0) or 0
|
||||||
@@ -643,7 +787,7 @@ def run_cycle(client, peers_extended, geo_enabled, show_ip, interactive=False, e
|
|||||||
else:
|
else:
|
||||||
_parts = fmt_ratio_parts(rx, tx)
|
_parts = fmt_ratio_parts(rx, tx)
|
||||||
_traffic_row = f" {fmt_ratio_cell(_parts, ratio_l_w, ratio_r_w):>{ratio_total}}"
|
_traffic_row = f" {fmt_ratio_cell(_parts, ratio_l_w, ratio_r_w):>{ratio_total}}"
|
||||||
print(f" {row_prefix}{node_col:<{col_w}} {ver:<{ver_w}}{_ip_row_part}{_geo_row_part} {lat_s:>8} {up:<8}{_traffic_row} {flow}")
|
print(f" {row_prefix}{node_col:<{col_w}} {ver:<{ver_w}}{_ip_row_part}{_geo_row_part} {lat_s:>8} {lat_flag} {ping_s:>9} {up:<8}{_traffic_row} {flow}")
|
||||||
|
|
||||||
print()
|
print()
|
||||||
print(HDR)
|
print(HDR)
|
||||||
@@ -657,6 +801,15 @@ def run_cycle(client, peers_extended, geo_enabled, show_ip, interactive=False, e
|
|||||||
print()
|
print()
|
||||||
print(" Outbound")
|
print(" Outbound")
|
||||||
show(outbound, True)
|
show(outbound, True)
|
||||||
|
legend = []
|
||||||
|
if lat_flagged:
|
||||||
|
legend.append(f"⚡ = Latency > {LATENCY_PING_RATIO}× Ping (possible VPN exit / relay)")
|
||||||
|
if any(kind == "tcp" for _, kind in ping_ms.values()):
|
||||||
|
legend.append("Ping: t = TCP connect to peer port (ICMP filtered)")
|
||||||
|
if legend:
|
||||||
|
print()
|
||||||
|
for line in legend:
|
||||||
|
print(f" {line}")
|
||||||
|
|
||||||
print()
|
print()
|
||||||
return outbound_numbered, container
|
return outbound_numbered, container
|
||||||
@@ -758,56 +911,75 @@ def interactive_loop(client, refresh_interval, peers_extended, geo_enabled, show
|
|||||||
traffic_extended = extended_traffic # local, toggled with 'x'
|
traffic_extended = extended_traffic # local, toggled with 'x'
|
||||||
|
|
||||||
old_settings = termios.tcgetattr(sys.stdin)
|
old_settings = termios.tcgetattr(sys.stdin)
|
||||||
|
# cbreak wird EINMAL für die gesamte Session gesetzt (nicht erst nach jedem
|
||||||
|
# run_cycle()). Andernfalls bleibt das Terminal während der Ausführung im
|
||||||
|
# zeilengepufferten Modus des alten old_settings, und das anschließende
|
||||||
|
# tty.setcbreak(..., TCSAFLUSH) verwirft dabei sogar noch ungelesene
|
||||||
|
# Eingaben – eine während einer langsamen Ausführung gedrückte Taste wie
|
||||||
|
# 'q' würde so nie ankommen.
|
||||||
|
tty.setcbreak(sys.stdin.fileno())
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
|
cycle_start = time.monotonic()
|
||||||
buf = io.StringIO()
|
buf = io.StringIO()
|
||||||
with contextlib.redirect_stdout(buf):
|
with contextlib.redirect_stdout(buf):
|
||||||
outbound_peers, container = run_cycle(
|
outbound_peers, container = run_cycle(
|
||||||
client, peers_extended, geo_enabled, show_ip, interactive=True,
|
client, peers_extended, geo_enabled, show_ip, interactive=True,
|
||||||
extended_traffic=traffic_extended
|
extended_traffic=traffic_extended
|
||||||
)
|
)
|
||||||
|
cycle_duration = time.monotonic() - cycle_start
|
||||||
|
if cycle_duration >= refresh_interval:
|
||||||
|
warn(f"Update dauerte {cycle_duration:.1f}s – "
|
||||||
|
f"länger als das Intervall ({refresh_interval}s). "
|
||||||
|
f"Intervall (refresh_interval) erhöhen.")
|
||||||
|
elif cycle_duration >= refresh_interval * REFRESH_WARN_RATIO:
|
||||||
|
pct = cycle_duration / refresh_interval * 100
|
||||||
|
warn(f"Update dauerte {cycle_duration:.1f}s "
|
||||||
|
f"({pct:.0f}% des {refresh_interval}s-Intervalls) – knapp.")
|
||||||
sys.stdout.write("\033[2J\033[H" + buf.getvalue())
|
sys.stdout.write("\033[2J\033[H" + buf.getvalue())
|
||||||
sys.stdout.flush()
|
sys.stdout.flush()
|
||||||
|
|
||||||
deadline = time.monotonic() + refresh_interval
|
# Timer läuft ab Beginn der Ausführung, nicht erst ab deren Ende –
|
||||||
tty.setcbreak(sys.stdin.fileno())
|
# so verlängert die Update-Laufzeit nicht zusätzlich das Intervall.
|
||||||
try:
|
deadline = cycle_start + refresh_interval
|
||||||
last_secs = -1
|
last_secs = -1
|
||||||
while True:
|
while True:
|
||||||
remaining = max(0.0, deadline - time.monotonic())
|
remaining = max(0.0, deadline - time.monotonic())
|
||||||
secs = int(remaining) + (1 if remaining % 1 > 0 else 0)
|
secs = int(remaining) + (1 if remaining % 1 > 0 else 0)
|
||||||
if secs != last_secs:
|
if secs != last_secs:
|
||||||
x_label = "RX/TX" if traffic_extended else "detail"
|
x_label = "RX/TX" if traffic_extended else "detail"
|
||||||
text = f" [d] Drop peer [a] Drop all [x] {x_label} [q] Beenden Refresh in {secs}s "
|
text = (f" [d] Drop peer [a] Drop all [x] {x_label} [q] Beenden "
|
||||||
sys.stdout.write("\r" + text)
|
f"Refresh in {secs}s (Update: {cycle_duration:.1f}s) ")
|
||||||
sys.stdout.flush()
|
sys.stdout.write("\r" + text)
|
||||||
last_secs = secs
|
sys.stdout.flush()
|
||||||
|
last_secs = secs
|
||||||
|
|
||||||
if remaining <= 0:
|
# Immer mindestens einmal (nicht-blockierend, wenn remaining
|
||||||
break
|
# bereits 0 ist) auf Eingabe prüfen – auch wenn die Deadline
|
||||||
|
# schon verstrichen ist, weil die Ausführung selbst länger als
|
||||||
|
# das Intervall dauerte. Sonst wird 'q' & Co. in diesem Fall
|
||||||
|
# nie abgefragt, da sofort in den nächsten Zyklus gesprungen würde.
|
||||||
|
timeout = min(remaining, 0.25) if remaining > 0 else 0
|
||||||
|
rlist, _, _ = select.select([sys.stdin], [], [], timeout)
|
||||||
|
if rlist:
|
||||||
|
ch = sys.stdin.read(1)
|
||||||
|
if ch in ('q', 'Q', '\x03', '\x04'):
|
||||||
|
print()
|
||||||
|
return
|
||||||
|
elif ch in ('d', 'D') and outbound_peers and container:
|
||||||
|
print()
|
||||||
|
do_drop_workflow(outbound_peers, container)
|
||||||
|
break # trigger immediate refresh
|
||||||
|
elif ch in ('a', 'A') and outbound_peers and container:
|
||||||
|
print()
|
||||||
|
drop_all_workflow(outbound_peers, container)
|
||||||
|
break # trigger immediate refresh
|
||||||
|
elif ch in ('x', 'X'):
|
||||||
|
traffic_extended = not traffic_extended
|
||||||
|
break # trigger immediate refresh
|
||||||
|
|
||||||
rlist, _, _ = select.select([sys.stdin], [], [], min(remaining, 0.25))
|
if remaining <= 0:
|
||||||
if rlist:
|
break
|
||||||
ch = sys.stdin.read(1)
|
|
||||||
if ch in ('q', 'Q', '\x03', '\x04'):
|
|
||||||
termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_settings)
|
|
||||||
print()
|
|
||||||
return
|
|
||||||
elif ch in ('d', 'D') and outbound_peers and container:
|
|
||||||
termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_settings)
|
|
||||||
print()
|
|
||||||
do_drop_workflow(outbound_peers, container)
|
|
||||||
break # trigger immediate refresh
|
|
||||||
elif ch in ('a', 'A') and outbound_peers and container:
|
|
||||||
termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_settings)
|
|
||||||
print()
|
|
||||||
drop_all_workflow(outbound_peers, container)
|
|
||||||
break # trigger immediate refresh
|
|
||||||
elif ch in ('x', 'X'):
|
|
||||||
traffic_extended = not traffic_extended
|
|
||||||
break # trigger immediate refresh
|
|
||||||
finally:
|
|
||||||
termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_settings)
|
|
||||||
|
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
pass
|
pass
|
||||||
@@ -829,9 +1001,17 @@ Options:
|
|||||||
-s, --show-ip Show IP column (hidden by default)
|
-s, --show-ip Show IP column (hidden by default)
|
||||||
-x, --extended Full RX/TX/RX-rate/TX-rate columns (default: ratio only)
|
-x, --extended Full RX/TX/RX-rate/TX-rate columns (default: ratio only)
|
||||||
-i [SEC], --interactive [SEC]
|
-i [SEC], --interactive [SEC]
|
||||||
Auto-refresh mode (default interval: 10 s)
|
Auto-refresh mode (overrides the configured interval)
|
||||||
-?, --help Show this help and exit
|
-?, --help Show this help and exit
|
||||||
|
|
||||||
|
Config file (first match wins, JSON):
|
||||||
|
$PI_STAT_CONFIG
|
||||||
|
~/.config/pi-stat/config.json
|
||||||
|
<script dir>/pi-stat.json
|
||||||
|
|
||||||
|
Keys:
|
||||||
|
refresh_interval Auto-refresh interval in seconds (default: 5)
|
||||||
|
|
||||||
Interactive keys:
|
Interactive keys:
|
||||||
d Drop one outbound peer
|
d Drop one outbound peer
|
||||||
a Drop all outbound peers
|
a Drop all outbound peers
|
||||||
@@ -853,7 +1033,7 @@ def main():
|
|||||||
interactive = "-i" in args or "--interactive" in args
|
interactive = "-i" in args or "--interactive" in args
|
||||||
extended_traffic = "-x" in args or "--extended" in args
|
extended_traffic = "-x" in args or "--extended" in args
|
||||||
|
|
||||||
refresh_interval = 10
|
refresh_interval = config_refresh(load_config())
|
||||||
if interactive:
|
if interactive:
|
||||||
for flag in ("-i", "--interactive"):
|
for flag in ("-i", "--interactive"):
|
||||||
if flag in args:
|
if flag in args:
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"_comment": "refresh_interval in Sekunden, min. 1 (Default: 5)",
|
||||||
|
"refresh_interval": 10
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user