Compare commits

..

4 Commits

Author SHA1 Message Date
olieske 20740b3704 feat: TCP connect fallback for ping, flag high latency vs. ping
- If a peer filters ICMP, measure the TCP connect time to its peer port
  instead; such values are marked with a trailing "t"
- Show ⚡ between Latency and Ping when stellar-core's latency exceeds
  the ping/TCP RTT by more than LATENCY_PING_RATIO (3), hinting at a
  VPN exit / relay in front of the actual node
- Legend below the peer table, only for markers actually shown

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 08:40:31 +00:00
olieske 602592e304 feat: add ICMP ping column to peer table
- ping_batch() pings each peer's public IP in parallel from the host
  (system ping, 1 packet, 1 s timeout, private IPs skipped)
- New "Ping" column next to stellar-core's "Latency" in the -p/-g table;
  "—" when the peer filters ICMP
- Low ping with high latency hints at a VPN exit / relay in front of
  the actual node

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 08:21:36 +00:00
olieske 6bca2c62e6 fix: query Horizon inside the container and flag lost core connection
- Fetch Horizon via curl inside the container (127.0.0.1:8000) instead of
  the host port 31401; the host->bridge forwarding hung until the 5 s
  timeout and made each cycle take ~6 s instead of ~1 s
- Report an error when Horizon's core_latest_ledger is 0 (no connection
  to stellar-core) instead of a false "Synced" with a negative lag
- Compute the ingest lag against stellar-core's own ledger
- "History Oldest" now shows history_elder_ledger; the latest ledger is
  listed separately as "History Latest"

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 08:05:10 +00:00
olieske 4e27efc3b9 feat: config file for refresh interval, cycle timing warning, persistent cbreak
- Optional JSON config ($PI_STAT_CONFIG, ~/.config/pi-stat/config.json,
  <script dir>/pi-stat.json) with refresh_interval; -i SEC overrides it
- Refresh deadline anchored to cycle start; warn when a cycle takes
  >= 80% of the refresh interval or exceeds it
- Switch the tty to cbreak once per session so keypresses made during a
  long cycle are not dropped
- Ship pi-stat.json with the repo

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 08:05:04 +00:00
3 changed files with 264 additions and 55 deletions
+27 -2
View File
@@ -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`
+233 -53
View File
@@ -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:
+4
View File
@@ -0,0 +1,4 @@
{
"_comment": "refresh_interval in Sekunden, min. 1 (Default: 5)",
"refresh_interval": 10
}