netpath 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- netpath/__init__.py +1 -0
- netpath/__main__.py +3 -0
- netpath/asn.py +108 -0
- netpath/cli.py +526 -0
- netpath/country.py +134 -0
- netpath/diagnosis.py +93 -0
- netpath/display.py +473 -0
- netpath/globe.py +205 -0
- netpath/iperf.py +59 -0
- netpath/mtr.py +197 -0
- netpath/rum.py +55 -0
- netpath/servers.py +69 -0
- netpath/speedtest.py +112 -0
- netpath-0.1.0.dist-info/METADATA +135 -0
- netpath-0.1.0.dist-info/RECORD +18 -0
- netpath-0.1.0.dist-info/WHEEL +4 -0
- netpath-0.1.0.dist-info/entry_points.txt +2 -0
- netpath-0.1.0.dist-info/licenses/LICENSE +21 -0
netpath/country.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import ipaddress
|
|
2
|
+
import random
|
|
3
|
+
import requests
|
|
4
|
+
from collections import defaultdict
|
|
5
|
+
|
|
6
|
+
from .asn import cymru_bulk_lookup_rich
|
|
7
|
+
|
|
8
|
+
RIPE_COUNTRY_RESOURCES = "https://stat.ripe.net/data/country-resource-list/data.json"
|
|
9
|
+
RIPE_PREFIXES = "https://stat.ripe.net/data/announced-prefixes/data.json"
|
|
10
|
+
|
|
11
|
+
# Global CDN / cloud ASNs that appear in every country's allocation —
|
|
12
|
+
# exclude so they don't crowd out actual domestic ISPs.
|
|
13
|
+
_GLOBAL_NETWORK_FRAGMENTS = {
|
|
14
|
+
# Global CDN / hyperscaler / transit — appear in every country's allocation
|
|
15
|
+
"AKAMAI", "CLOUDFLARE", "FASTLY", "AMAZON", "GOOGLE", "MICROSOFT",
|
|
16
|
+
"LIMELIGHT", "STACKPATH", "INCAPSULA", "IMPERVA", "COGENT", "LUMEN",
|
|
17
|
+
"LEVEL3", "TELIA", "NTT ", "SEABONE", "HURRICANE", "ZAYO",
|
|
18
|
+
# Academic / research / government — not consumer ISPs
|
|
19
|
+
"UNIVERSITY", "ACADEMIC", "RESEARCH", "EDUCATION", "COMPUTATION CENTER",
|
|
20
|
+
"INTERUNIVERSITY", "IUCC",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _is_global_cdn(name: str) -> bool:
|
|
25
|
+
upper = name.upper()
|
|
26
|
+
return any(frag in upper for frag in _GLOBAL_NETWORK_FRAGMENTS)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def get_top_asns(country_code: str, top_n: int = 4) -> list[dict]:
|
|
30
|
+
"""
|
|
31
|
+
Rank ASNs in a country by total allocated IPv4 address space.
|
|
32
|
+
|
|
33
|
+
Method:
|
|
34
|
+
1. RIPE Stat country-resource-list → all IPv4 prefixes allocated to
|
|
35
|
+
entities registered in this country (single API call).
|
|
36
|
+
2. Sample one IP per prefix.
|
|
37
|
+
3. Team Cymru bulk whois → ASN + org name for every sample
|
|
38
|
+
(single TCP connection).
|
|
39
|
+
4. Aggregate address space per ASN, exclude global CDNs, return top N.
|
|
40
|
+
"""
|
|
41
|
+
code = country_code.upper()
|
|
42
|
+
|
|
43
|
+
resp = requests.get(RIPE_COUNTRY_RESOURCES, params={"resource": code}, timeout=20)
|
|
44
|
+
resp.raise_for_status()
|
|
45
|
+
ipv4_prefixes = resp.json().get("data", {}).get("resources", {}).get("ipv4", [])
|
|
46
|
+
|
|
47
|
+
if not ipv4_prefixes:
|
|
48
|
+
raise RuntimeError(f"RIPE Stat returned no IPv4 resources for '{code}'")
|
|
49
|
+
|
|
50
|
+
# Sample one usable IP per prefix; cap at 2000 so Cymru query stays fast
|
|
51
|
+
sample_ips: list[str] = []
|
|
52
|
+
ip_to_size: dict[str, int] = {}
|
|
53
|
+
|
|
54
|
+
entries = ipv4_prefixes
|
|
55
|
+
if len(entries) > 2000:
|
|
56
|
+
entries = random.sample(entries, 2000)
|
|
57
|
+
|
|
58
|
+
for prefix_str in entries:
|
|
59
|
+
try:
|
|
60
|
+
net = ipaddress.IPv4Network(prefix_str, strict=False)
|
|
61
|
+
if net.is_private:
|
|
62
|
+
continue
|
|
63
|
+
hosts = list(net.hosts())
|
|
64
|
+
if not hosts:
|
|
65
|
+
continue
|
|
66
|
+
ip = str(hosts[0])
|
|
67
|
+
sample_ips.append(ip)
|
|
68
|
+
ip_to_size[ip] = net.num_addresses
|
|
69
|
+
except ValueError:
|
|
70
|
+
continue
|
|
71
|
+
|
|
72
|
+
rich = cymru_bulk_lookup_rich(sample_ips)
|
|
73
|
+
|
|
74
|
+
# Aggregate: {asn_str: {addresses, prefix_count, name}}
|
|
75
|
+
totals: dict[str, dict] = defaultdict(lambda: {"addresses": 0, "prefix_count": 0, "name": ""})
|
|
76
|
+
for ip, info in rich.items():
|
|
77
|
+
asn = info["asn"]
|
|
78
|
+
name = info["name"]
|
|
79
|
+
size = ip_to_size.get(ip, 0)
|
|
80
|
+
totals[asn]["addresses"] += size
|
|
81
|
+
totals[asn]["prefix_count"] += 1
|
|
82
|
+
if name and not totals[asn]["name"]:
|
|
83
|
+
totals[asn]["name"] = name
|
|
84
|
+
|
|
85
|
+
# Filter out unrouted / global CDNs
|
|
86
|
+
filtered = [
|
|
87
|
+
(asn, d) for asn, d in totals.items()
|
|
88
|
+
if asn != "AS???" and not _is_global_cdn(d["name"])
|
|
89
|
+
]
|
|
90
|
+
filtered.sort(key=lambda x: x[1]["addresses"], reverse=True)
|
|
91
|
+
|
|
92
|
+
if not filtered:
|
|
93
|
+
raise RuntimeError(
|
|
94
|
+
f"Could not determine top ASNs for '{code}' — "
|
|
95
|
+
"RIPE data or Cymru lookup may have failed."
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
return [
|
|
99
|
+
{
|
|
100
|
+
"asn": asn,
|
|
101
|
+
"name": d["name"] or "Unknown",
|
|
102
|
+
"addresses": d["addresses"],
|
|
103
|
+
"prefix_count": d["prefix_count"],
|
|
104
|
+
}
|
|
105
|
+
for asn, d in filtered[:top_n]
|
|
106
|
+
]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def get_test_ip_for_asn(asn: str) -> str | None:
|
|
110
|
+
"""Return a routable IPv4 from the ASN's announced prefixes via RIPE Stat."""
|
|
111
|
+
asn_num = asn.lstrip("ASas")
|
|
112
|
+
try:
|
|
113
|
+
resp = requests.get(
|
|
114
|
+
RIPE_PREFIXES, params={"resource": f"AS{asn_num}"}, timeout=15
|
|
115
|
+
)
|
|
116
|
+
resp.raise_for_status()
|
|
117
|
+
prefixes = resp.json().get("data", {}).get("prefixes", [])
|
|
118
|
+
except Exception:
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
for entry in prefixes:
|
|
122
|
+
prefix = entry.get("prefix", "")
|
|
123
|
+
if ":" in prefix:
|
|
124
|
+
continue
|
|
125
|
+
try:
|
|
126
|
+
net = ipaddress.IPv4Network(prefix, strict=False)
|
|
127
|
+
if net.is_private or net.prefixlen < 8:
|
|
128
|
+
continue
|
|
129
|
+
hosts = list(net.hosts())
|
|
130
|
+
if len(hosts) >= 2:
|
|
131
|
+
return str(hosts[1])
|
|
132
|
+
except ValueError:
|
|
133
|
+
continue
|
|
134
|
+
return None
|
netpath/diagnosis.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
def diagnose(result: dict) -> dict:
|
|
2
|
+
"""Classify collected measurements into a plain-language verdict.
|
|
3
|
+
|
|
4
|
+
Pure function — no I/O, no imports from netpath modules.
|
|
5
|
+
Returns a dict with keys: verdict, severity, detail, signals.
|
|
6
|
+
Never raises.
|
|
7
|
+
"""
|
|
8
|
+
default: dict = {
|
|
9
|
+
"verdict": "Healthy",
|
|
10
|
+
"severity": "ok",
|
|
11
|
+
"detail": "No anomalies detected on the measured path.",
|
|
12
|
+
"signals": [],
|
|
13
|
+
}
|
|
14
|
+
try:
|
|
15
|
+
hubs = result.get("hubs") or []
|
|
16
|
+
bufferbloat = result.get("bufferbloat_ms")
|
|
17
|
+
rum = result.get("rum")
|
|
18
|
+
download_mbps = result.get("download_mbps")
|
|
19
|
+
|
|
20
|
+
# (1) Severe bufferbloat
|
|
21
|
+
if bufferbloat is not None and bufferbloat > 30:
|
|
22
|
+
return {
|
|
23
|
+
"verdict": "Severe Bufferbloat",
|
|
24
|
+
"severity": "critical",
|
|
25
|
+
"detail": (
|
|
26
|
+
f"Latency rose {bufferbloat:.0f} ms under load, "
|
|
27
|
+
"indicating severe queuing congestion on the path."
|
|
28
|
+
),
|
|
29
|
+
"signals": [f"bufferbloat_ms={bufferbloat:.1f}"],
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
# (2) Mid-path packet loss — requires at least 2 hops
|
|
33
|
+
if len(hubs) > 1:
|
|
34
|
+
last_resp_idx = -1
|
|
35
|
+
for i, h in enumerate(hubs):
|
|
36
|
+
if h.get("host") not in ("???", None, ""):
|
|
37
|
+
last_resp_idx = i
|
|
38
|
+
for i, h in enumerate(hubs):
|
|
39
|
+
if i == 0 or i >= last_resp_idx:
|
|
40
|
+
continue
|
|
41
|
+
if h.get("host") in ("???", None, ""):
|
|
42
|
+
continue
|
|
43
|
+
loss = float(h.get("Loss%", 0.0) or 0.0)
|
|
44
|
+
if loss > 1.0:
|
|
45
|
+
hop_id = h.get("host", f"hop {i + 1}")
|
|
46
|
+
return {
|
|
47
|
+
"verdict": "Mid-path Packet Loss",
|
|
48
|
+
"severity": "warning",
|
|
49
|
+
"detail": (
|
|
50
|
+
f"Packet loss of {loss:.1f}% detected at {hop_id}, "
|
|
51
|
+
"suggesting a congested or faulty intermediate hop."
|
|
52
|
+
),
|
|
53
|
+
"signals": [f"hop {h.get('count', i + 1)} ({hop_id}) Loss%={loss:.1f}"],
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
# (3) Last-mile congestion — first hop loss combined with bufferbloat
|
|
57
|
+
if hubs:
|
|
58
|
+
first_loss = float(hubs[0].get("Loss%", 0.0) or 0.0)
|
|
59
|
+
if first_loss > 0 and bufferbloat is not None and bufferbloat > 5:
|
|
60
|
+
return {
|
|
61
|
+
"verdict": "Last-mile Congestion",
|
|
62
|
+
"severity": "warning",
|
|
63
|
+
"detail": (
|
|
64
|
+
f"First-hop loss of {first_loss:.1f}% combined with "
|
|
65
|
+
f"{bufferbloat:.0f} ms bufferbloat indicates last-mile congestion."
|
|
66
|
+
),
|
|
67
|
+
"signals": [
|
|
68
|
+
f"first-hop Loss%={first_loss:.1f}",
|
|
69
|
+
f"bufferbloat_ms={bufferbloat:.1f}",
|
|
70
|
+
],
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
# (4) Throughput cap — measured download significantly below RUM baseline
|
|
74
|
+
if rum is not None and download_mbps is not None:
|
|
75
|
+
rum_dl = rum.get("dl_mbps")
|
|
76
|
+
if rum_dl and download_mbps < rum_dl * 0.7:
|
|
77
|
+
return {
|
|
78
|
+
"verdict": "Throughput Cap",
|
|
79
|
+
"severity": "warning",
|
|
80
|
+
"detail": (
|
|
81
|
+
f"Measured download ({download_mbps:.0f} Mbps) is more than 30% below "
|
|
82
|
+
f"the Cloudflare RUM baseline for this ASN ({rum_dl:.0f} Mbps)."
|
|
83
|
+
),
|
|
84
|
+
"signals": [
|
|
85
|
+
f"download_mbps={download_mbps:.0f}",
|
|
86
|
+
f"rum_dl_mbps={rum_dl:.0f}",
|
|
87
|
+
],
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return default
|
|
91
|
+
|
|
92
|
+
except Exception:
|
|
93
|
+
return default
|
netpath/display.py
ADDED
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
import re
|
|
2
|
+
|
|
3
|
+
from rich import box
|
|
4
|
+
from rich.columns import Columns
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
from rich.panel import Panel
|
|
7
|
+
from rich.rule import Rule
|
|
8
|
+
from rich.table import Table
|
|
9
|
+
from rich.text import Text
|
|
10
|
+
|
|
11
|
+
from netpath.asn import cymru_bulk_lookup_rich, normalize_asn
|
|
12
|
+
|
|
13
|
+
_IP_PAT = re.compile(r'^\d{1,3}(?:\.\d{1,3}){3}$')
|
|
14
|
+
|
|
15
|
+
LATENCY_GREEN_MS = 20
|
|
16
|
+
LATENCY_YELLOW_MS = 80
|
|
17
|
+
|
|
18
|
+
console = Console()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# ── name helpers ─────────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
def clean_asn_name(name: str) -> str:
|
|
24
|
+
"""Strip Cymru short-name prefix: 'PARTNER-AS - Partner Comms' → 'Partner Comms'"""
|
|
25
|
+
if ' - ' not in name:
|
|
26
|
+
return name
|
|
27
|
+
prefix, _, rest = name.partition(' - ')
|
|
28
|
+
prefix = prefix.strip()
|
|
29
|
+
# Short code: no spaces, ≤25 chars (handles PARTNER-AS, NV-ASN, Internet_Binat, Tehila-AS…)
|
|
30
|
+
if ' ' not in prefix and 1 <= len(prefix) <= 25:
|
|
31
|
+
return rest.strip().replace('_', ' ').strip()
|
|
32
|
+
return name
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ── formatting helpers ────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
def fmt_country_latency(ms: float) -> Text:
|
|
38
|
+
s = f"{ms:.1f} ms"
|
|
39
|
+
if ms < 120:
|
|
40
|
+
return Text(s, style="bold green")
|
|
41
|
+
if ms < 200:
|
|
42
|
+
return Text(s, style="yellow")
|
|
43
|
+
return Text(s, style="bold red")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def fmt_latency(ms: float) -> Text:
|
|
47
|
+
s = f"{ms:.1f} ms"
|
|
48
|
+
if ms <= 0:
|
|
49
|
+
return Text("—", style="dim")
|
|
50
|
+
if ms < LATENCY_GREEN_MS:
|
|
51
|
+
return Text(s, style="bold green")
|
|
52
|
+
if ms < LATENCY_YELLOW_MS:
|
|
53
|
+
return Text(s, style="yellow")
|
|
54
|
+
return Text(s, style="bold red")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def fmt_loss(pct: float) -> Text:
|
|
58
|
+
s = f"{pct:.1f}%"
|
|
59
|
+
if pct == 0:
|
|
60
|
+
return Text(s, style="green")
|
|
61
|
+
if pct < 5:
|
|
62
|
+
return Text(s, style="yellow")
|
|
63
|
+
return Text(s, style="bold red")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def fmt_bps(bps: float) -> str:
|
|
67
|
+
if bps >= 1e9:
|
|
68
|
+
return f"{bps / 1e9:.2f} Gbps"
|
|
69
|
+
if bps >= 1e6:
|
|
70
|
+
return f"{bps / 1e6:.0f} Mbps"
|
|
71
|
+
return f"{bps / 1e3:.0f} Kbps"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# ── sections ─────────────────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
def header(version: str = "0.1.0"):
|
|
77
|
+
console.print()
|
|
78
|
+
console.print(
|
|
79
|
+
Panel(
|
|
80
|
+
"[bold cyan]netpath[/bold cyan] "
|
|
81
|
+
"[dim]network path analyzer — throughput · latency · packet loss · AS hops[/dim]",
|
|
82
|
+
border_style="cyan",
|
|
83
|
+
expand=False,
|
|
84
|
+
padding=(0, 2),
|
|
85
|
+
)
|
|
86
|
+
)
|
|
87
|
+
console.print()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def server_heading(server: dict):
|
|
91
|
+
host = server.get("HOST", "?")
|
|
92
|
+
site = server.get("SITE", "")
|
|
93
|
+
country = server.get("COUNTRY", "")
|
|
94
|
+
asn = server.get("asn", "")
|
|
95
|
+
port = server.get("port", 5201)
|
|
96
|
+
meta = " ".join(filter(None, [asn, site, country, f":{port}"]))
|
|
97
|
+
console.print(
|
|
98
|
+
Panel(
|
|
99
|
+
f"[bold]{host}[/bold] [dim]{meta}[/dim]",
|
|
100
|
+
border_style="blue",
|
|
101
|
+
expand=False,
|
|
102
|
+
padding=(0, 1),
|
|
103
|
+
)
|
|
104
|
+
)
|
|
105
|
+
console.print()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def isp_section(rank: int, asn: str, name: str, addresses: int = 0, prefix_count: int = 0):
|
|
109
|
+
"""Full-width section rule for each ISP in country mode."""
|
|
110
|
+
name_clean = clean_asn_name(name)
|
|
111
|
+
addrs = f" [dim]{addresses:,} IPs[/dim]" if addresses else ""
|
|
112
|
+
console.print()
|
|
113
|
+
console.rule(
|
|
114
|
+
f" [bold cyan]#{rank}[/bold cyan] [bold]{asn}[/bold] {name_clean}{addrs} ",
|
|
115
|
+
style="cyan",
|
|
116
|
+
align="left",
|
|
117
|
+
)
|
|
118
|
+
console.print()
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _all_stars(hubs: list[dict]) -> bool:
|
|
122
|
+
return bool(hubs) and all(h.get("host") in ("???", None) for h in hubs)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def path_table(hubs: list[dict], target_asn: str):
|
|
126
|
+
if _all_stars(hubs):
|
|
127
|
+
console.print(
|
|
128
|
+
f" [yellow]⚠[/yellow] [dim]Path filtered — all {len(hubs)} hops dropped ICMP probes "
|
|
129
|
+
f"(destination may still be reachable)[/dim]\n"
|
|
130
|
+
)
|
|
131
|
+
return
|
|
132
|
+
|
|
133
|
+
# Trim trailing unreachable hops — after the last responsive hop they're just noise
|
|
134
|
+
last_real = max(
|
|
135
|
+
(i for i, h in enumerate(hubs) if h.get("host") not in ("???", None, "")),
|
|
136
|
+
default=-1,
|
|
137
|
+
)
|
|
138
|
+
trailing = 0
|
|
139
|
+
if last_real >= 0 and last_real < len(hubs) - 1:
|
|
140
|
+
trailing = len(hubs) - last_real - 1
|
|
141
|
+
hubs = hubs[:last_real + 1]
|
|
142
|
+
|
|
143
|
+
show_p95 = console.width >= 90
|
|
144
|
+
|
|
145
|
+
table = Table(
|
|
146
|
+
box=box.SIMPLE_HEAD,
|
|
147
|
+
show_header=True,
|
|
148
|
+
header_style="bold cyan",
|
|
149
|
+
expand=False,
|
|
150
|
+
padding=(0, 1),
|
|
151
|
+
)
|
|
152
|
+
table.add_column("#", style="dim", width=3, justify="right")
|
|
153
|
+
table.add_column("Host", min_width=18)
|
|
154
|
+
table.add_column("ASN", min_width=9)
|
|
155
|
+
table.add_column("Loss", justify="right", width=7)
|
|
156
|
+
table.add_column("Avg", justify="right", width=9)
|
|
157
|
+
table.add_column("Best", justify="right", width=9)
|
|
158
|
+
table.add_column("Worst", justify="right", width=9)
|
|
159
|
+
if show_p95:
|
|
160
|
+
table.add_column("p95", justify="right", width=9)
|
|
161
|
+
|
|
162
|
+
prev_asn = None
|
|
163
|
+
for hub in hubs:
|
|
164
|
+
hop = str(hub.get("count", "?"))
|
|
165
|
+
host = hub.get("host", "???")
|
|
166
|
+
asn = hub.get("ASN", "AS???")
|
|
167
|
+
loss = hub.get("Loss%", 0.0)
|
|
168
|
+
avg = hub.get("Avg", 0.0)
|
|
169
|
+
best = hub.get("Best", 0.0)
|
|
170
|
+
worst = hub.get("Wrst", 0.0)
|
|
171
|
+
|
|
172
|
+
if host in ("???", "", None):
|
|
173
|
+
row = [hop, Text("* * *", style="dim"), Text("—", style="dim"),
|
|
174
|
+
Text("—", style="dim"), Text("—", style="dim"),
|
|
175
|
+
Text("—", style="dim"), Text("—", style="dim")]
|
|
176
|
+
if show_p95:
|
|
177
|
+
row.append(Text("—", style="dim"))
|
|
178
|
+
table.add_row(*row)
|
|
179
|
+
prev_asn = asn
|
|
180
|
+
continue
|
|
181
|
+
|
|
182
|
+
# AS boundary: highlight the hop where we enter a new AS
|
|
183
|
+
asn_text = Text(asn if asn != "AS???" else "—")
|
|
184
|
+
if asn != "AS???" and asn != prev_asn and prev_asn is not None:
|
|
185
|
+
asn_text.stylize("bold yellow")
|
|
186
|
+
if asn == target_asn:
|
|
187
|
+
asn_text.stylize("bold green")
|
|
188
|
+
elif asn == target_asn:
|
|
189
|
+
asn_text.stylize("green")
|
|
190
|
+
|
|
191
|
+
row = [
|
|
192
|
+
hop, host, asn_text,
|
|
193
|
+
fmt_loss(loss), fmt_latency(avg), fmt_latency(best), fmt_latency(worst),
|
|
194
|
+
]
|
|
195
|
+
if show_p95:
|
|
196
|
+
p95 = hub.get("p95")
|
|
197
|
+
row.append(fmt_latency(p95) if p95 is not None else Text("—", style="dim"))
|
|
198
|
+
table.add_row(*row)
|
|
199
|
+
prev_asn = asn
|
|
200
|
+
|
|
201
|
+
console.print(table)
|
|
202
|
+
if trailing:
|
|
203
|
+
console.print(
|
|
204
|
+
f" [dim] + {trailing} hop{'s' if trailing != 1 else ''} beyond "
|
|
205
|
+
f"— ICMP TTL-exceeded filtered[/dim]"
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def as_path_summary(hubs: list[dict]):
|
|
210
|
+
asns = []
|
|
211
|
+
for hub in hubs:
|
|
212
|
+
asn = hub.get("ASN", "")
|
|
213
|
+
if asn and asn != "AS???" and (not asns or asns[-1] != asn):
|
|
214
|
+
asns.append(asn)
|
|
215
|
+
|
|
216
|
+
if not asns:
|
|
217
|
+
return
|
|
218
|
+
|
|
219
|
+
parts = []
|
|
220
|
+
for i, asn in enumerate(asns):
|
|
221
|
+
if i == 0:
|
|
222
|
+
parts.append(f"[dim]{asn}[/dim]")
|
|
223
|
+
elif i == len(asns) - 1:
|
|
224
|
+
parts.append(f"[bold green]{asn}[/bold green]")
|
|
225
|
+
else:
|
|
226
|
+
parts.append(f"[yellow]{asn}[/yellow]")
|
|
227
|
+
|
|
228
|
+
console.print(" [dim]AS path:[/dim] " + " [dim]→[/dim] ".join(parts))
|
|
229
|
+
console.print()
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _fmt_opt(val: float | None, unit: str) -> str:
|
|
233
|
+
return f"{val:.0f} {unit}" if val is not None else "—"
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def throughput_and_rum(upload: dict, download: dict, rum: dict | None = None,
|
|
237
|
+
server: str = "speed.cloudflare.com"):
|
|
238
|
+
up = fmt_bps(upload.get("bps", 0))
|
|
239
|
+
dn = fmt_bps(download.get("recv_bps", download.get("bps", 0)))
|
|
240
|
+
retx = upload.get("retransmits") or 0
|
|
241
|
+
ttfb = download.get("ttfb_ms")
|
|
242
|
+
|
|
243
|
+
synth_lines = [
|
|
244
|
+
f" [bold green]↑ Upload:[/bold green] {up}",
|
|
245
|
+
f" [bold cyan]↓ Download:[/bold cyan] {dn}",
|
|
246
|
+
]
|
|
247
|
+
if ttfb is not None:
|
|
248
|
+
synth_lines.append(f" [dim]TTFB: {ttfb:.0f} ms[/dim]")
|
|
249
|
+
if retx:
|
|
250
|
+
synth_lines.append(f" [dim]retransmits: {retx}[/dim]")
|
|
251
|
+
|
|
252
|
+
synth_panel = Panel(
|
|
253
|
+
"\n".join(synth_lines),
|
|
254
|
+
title=f"[bold]Throughput · {server}[/bold]",
|
|
255
|
+
border_style="blue",
|
|
256
|
+
expand=False,
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
if rum:
|
|
260
|
+
dr = rum.get("date_range", "7d")
|
|
261
|
+
rum_lines = [
|
|
262
|
+
f" [bold cyan]↓[/bold cyan] {_fmt_opt(rum.get('dl_mbps'), 'Mbps')}",
|
|
263
|
+
f" [bold green]↑[/bold green] {_fmt_opt(rum.get('ul_mbps'), 'Mbps')}",
|
|
264
|
+
f" [dim]Latency idle: {_fmt_opt(rum.get('latency_idle'), 'ms')}[/dim]",
|
|
265
|
+
f" [dim]Latency loaded: {_fmt_opt(rum.get('latency_loaded'), 'ms')}[/dim]",
|
|
266
|
+
f" [dim]Jitter: {_fmt_opt(rum.get('jitter'), 'ms')}[/dim]",
|
|
267
|
+
]
|
|
268
|
+
if rum.get("packet_loss") is not None:
|
|
269
|
+
rum_lines.append(f" [dim]Packet loss: {rum['packet_loss']:.2f}%[/dim]")
|
|
270
|
+
|
|
271
|
+
rum_panel = Panel(
|
|
272
|
+
"\n".join(rum_lines),
|
|
273
|
+
title=f"[bold]RUM · Cloudflare Radar ({dr})[/bold]",
|
|
274
|
+
border_style="magenta",
|
|
275
|
+
expand=False,
|
|
276
|
+
)
|
|
277
|
+
console.print(Columns([synth_panel, rum_panel], equal=False, expand=False))
|
|
278
|
+
else:
|
|
279
|
+
console.print(synth_panel)
|
|
280
|
+
|
|
281
|
+
console.print()
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def rum_only_panel(rum: dict, asn: str):
|
|
285
|
+
"""Show compact RUM panel (3 lines) when throughput test was skipped."""
|
|
286
|
+
dr = rum.get("date_range", "7d")
|
|
287
|
+
dl = _fmt_opt(rum.get("dl_mbps"), "Mbps")
|
|
288
|
+
ul = _fmt_opt(rum.get("ul_mbps"), "Mbps")
|
|
289
|
+
idle = _fmt_opt(rum.get("latency_idle"), "ms")
|
|
290
|
+
loaded = _fmt_opt(rum.get("latency_loaded"), "ms")
|
|
291
|
+
|
|
292
|
+
lines = [
|
|
293
|
+
f" [bold cyan]↓[/bold cyan] {dl} [bold green]↑[/bold green] {ul}",
|
|
294
|
+
f" [dim]idle {idle} loaded {loaded}[/dim]",
|
|
295
|
+
]
|
|
296
|
+
if rum.get("packet_loss") is not None:
|
|
297
|
+
lines.append(f" [dim]loss {rum['packet_loss']:.2f}%[/dim]")
|
|
298
|
+
|
|
299
|
+
console.print(
|
|
300
|
+
Panel("\n".join(lines),
|
|
301
|
+
title=f"[bold]Cloudflare Radar · {asn} ({dr})[/bold]",
|
|
302
|
+
border_style="magenta", expand=False)
|
|
303
|
+
)
|
|
304
|
+
console.print()
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def baseline_panel(upload: dict, download: dict):
|
|
308
|
+
"""Your own connection baseline — shown once before per-ISP RUM comparisons."""
|
|
309
|
+
up = fmt_bps(upload.get("bps", 0))
|
|
310
|
+
dn = fmt_bps(download.get("recv_bps", download.get("bps", 0)))
|
|
311
|
+
ttfb = download.get("ttfb_ms")
|
|
312
|
+
lines = [
|
|
313
|
+
f" [bold green]↑ Upload:[/bold green] {up}",
|
|
314
|
+
f" [bold cyan]↓ Download:[/bold cyan] {dn}",
|
|
315
|
+
]
|
|
316
|
+
if ttfb is not None:
|
|
317
|
+
lines.append(f" [dim]TTFB to Cloudflare: {ttfb:.0f} ms[/dim]")
|
|
318
|
+
console.print(
|
|
319
|
+
Panel("\n".join(lines),
|
|
320
|
+
title="[bold]Your baseline · speed.cloudflare.com[/bold]",
|
|
321
|
+
border_style="blue", expand=False)
|
|
322
|
+
)
|
|
323
|
+
console.print()
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def country_summary(code: str, results: list[dict]):
|
|
327
|
+
"""Tree summary grouped by transit entry point with color-coded latency."""
|
|
328
|
+
if not results:
|
|
329
|
+
return
|
|
330
|
+
|
|
331
|
+
complete = [r for r in results if r.get("path_complete") and r.get("verified_rtt_ms") is not None]
|
|
332
|
+
incomplete = [r for r in results if not r.get("path_complete") or r.get("verified_rtt_ms") is None]
|
|
333
|
+
|
|
334
|
+
star_asn: str | None = None
|
|
335
|
+
if complete:
|
|
336
|
+
star_asn = min(complete, key=lambda r: r["verified_rtt_ms"])["asn"]
|
|
337
|
+
|
|
338
|
+
# Group complete rows by entry_transit_asn; sort each group by RTT
|
|
339
|
+
groups: dict[str | None, list[dict]] = {}
|
|
340
|
+
for r in complete:
|
|
341
|
+
groups.setdefault(r.get("entry_transit_asn"), []).append(r)
|
|
342
|
+
sorted_keys = sorted(groups, key=lambda k: min(r["verified_rtt_ms"] for r in groups[k]))
|
|
343
|
+
for key in sorted_keys:
|
|
344
|
+
groups[key].sort(key=lambda r: r["verified_rtt_ms"])
|
|
345
|
+
|
|
346
|
+
# Collect one IP per transit ASN from hub lists for Cymru name lookup
|
|
347
|
+
transit_ips: dict[str, str] = {}
|
|
348
|
+
for key in sorted_keys:
|
|
349
|
+
if key is None:
|
|
350
|
+
continue
|
|
351
|
+
for r in groups[key]:
|
|
352
|
+
for hub in r.get("hubs", []):
|
|
353
|
+
raw_asn = hub.get("ASN", "")
|
|
354
|
+
if raw_asn and normalize_asn(raw_asn) == key:
|
|
355
|
+
host = hub.get("host", "")
|
|
356
|
+
if host and host not in ("???", None, "") and _IP_PAT.match(host):
|
|
357
|
+
transit_ips[key] = host
|
|
358
|
+
break
|
|
359
|
+
if key in transit_ips:
|
|
360
|
+
break
|
|
361
|
+
|
|
362
|
+
# Batch Cymru lookup for transit org names (one TCP connection)
|
|
363
|
+
transit_names: dict[str, str] = {}
|
|
364
|
+
if transit_ips:
|
|
365
|
+
try:
|
|
366
|
+
lookup = cymru_bulk_lookup_rich(list(transit_ips.values()))
|
|
367
|
+
for asn_key, ip in transit_ips.items():
|
|
368
|
+
if ip in lookup:
|
|
369
|
+
n = lookup[ip].get("name", "")
|
|
370
|
+
if n:
|
|
371
|
+
transit_names[asn_key] = clean_asn_name(n)
|
|
372
|
+
except Exception:
|
|
373
|
+
pass
|
|
374
|
+
|
|
375
|
+
def _transit_label(key: str | None) -> str:
|
|
376
|
+
if key is None:
|
|
377
|
+
return "direct"
|
|
378
|
+
name = transit_names.get(key)
|
|
379
|
+
return f"{key} · {name}" if name else key
|
|
380
|
+
|
|
381
|
+
def _trim(name: str, width: int = 26) -> str:
|
|
382
|
+
return name[:width - 1] + "…" if len(name) > width else name
|
|
383
|
+
|
|
384
|
+
console.print()
|
|
385
|
+
console.rule(f" {code} summary ", style="bold cyan")
|
|
386
|
+
console.print()
|
|
387
|
+
|
|
388
|
+
for key in sorted_keys:
|
|
389
|
+
rows = groups[key]
|
|
390
|
+
console.print(f"[bold]{_transit_label(key)}[/bold]")
|
|
391
|
+
for ri, r in enumerate(rows):
|
|
392
|
+
connector = "└─" if ri == len(rows) - 1 else "├─"
|
|
393
|
+
star = "★ " if r["asn"] == star_asn else " "
|
|
394
|
+
line = Text()
|
|
395
|
+
line.append(f" {connector} {star}{r['asn']:<10} {_trim(r['name']):<26} ")
|
|
396
|
+
line.append_text(fmt_country_latency(r["verified_rtt_ms"]))
|
|
397
|
+
console.print(line)
|
|
398
|
+
console.print()
|
|
399
|
+
|
|
400
|
+
if incomplete:
|
|
401
|
+
console.print("[dim]incomplete paths[/dim]")
|
|
402
|
+
for ri, r in enumerate(incomplete):
|
|
403
|
+
connector = "└─" if ri == len(incomplete) - 1 else "├─"
|
|
404
|
+
line = Text()
|
|
405
|
+
line.append(f" {connector} {r['asn']:<10} {_trim(r['name']):<26} ", style="dim")
|
|
406
|
+
line.append("⚠ ", style="yellow")
|
|
407
|
+
line.append("incomplete", style="dim")
|
|
408
|
+
console.print(line)
|
|
409
|
+
console.print()
|
|
410
|
+
|
|
411
|
+
if sorted_keys:
|
|
412
|
+
best_key = sorted_keys[0]
|
|
413
|
+
best_rtt = groups[best_key][0]["verified_rtt_ms"]
|
|
414
|
+
console.print(f" [dim]Fastest entry transit: [bold]{_transit_label(best_key)}[/bold] — {best_rtt:.1f} ms[/dim]")
|
|
415
|
+
console.print()
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def bufferbloat_line(idle_ms: float | None, loaded_ms: float | None) -> None:
|
|
419
|
+
idle_str = f"{idle_ms:.1f} ms" if idle_ms is not None else "—"
|
|
420
|
+
if loaded_ms is None:
|
|
421
|
+
console.print(
|
|
422
|
+
f" [dim]Bufferbloat:[/dim] idle {idle_str} loaded [dim]unavailable[/dim]"
|
|
423
|
+
)
|
|
424
|
+
console.print()
|
|
425
|
+
return
|
|
426
|
+
loaded_str = f"{loaded_ms:.1f} ms"
|
|
427
|
+
delta = loaded_ms - (idle_ms if idle_ms is not None else 0.0)
|
|
428
|
+
delta_str = f"{delta:+.1f} ms"
|
|
429
|
+
if delta < 5:
|
|
430
|
+
delta_markup = f"[dim]{delta_str}[/dim]"
|
|
431
|
+
label_markup = "[dim]None[/dim]"
|
|
432
|
+
elif delta <= 30:
|
|
433
|
+
delta_markup = f"[yellow]{delta_str}[/yellow]"
|
|
434
|
+
label_markup = "[yellow]Moderate[/yellow]"
|
|
435
|
+
else:
|
|
436
|
+
delta_markup = f"[bold red]{delta_str}[/bold red]"
|
|
437
|
+
label_markup = "[bold red]Severe[/bold red]"
|
|
438
|
+
console.print(
|
|
439
|
+
f" [dim]Bufferbloat:[/dim] idle {idle_str} loaded {loaded_str} {delta_markup} {label_markup}"
|
|
440
|
+
)
|
|
441
|
+
console.print()
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def verdict_panel(verdict: dict) -> None:
|
|
445
|
+
severity = verdict.get("severity", "ok")
|
|
446
|
+
label = verdict.get("verdict", "Healthy")
|
|
447
|
+
detail = verdict.get("detail", "")
|
|
448
|
+
signals = verdict.get("signals", [])
|
|
449
|
+
|
|
450
|
+
severity_styles = {"ok": "bold green", "warning": "bold yellow", "critical": "bold red"}
|
|
451
|
+
border_colors = {"ok": "green", "warning": "yellow", "critical": "red"}
|
|
452
|
+
style = severity_styles.get(severity, "bold green")
|
|
453
|
+
border = border_colors.get(severity, "green")
|
|
454
|
+
|
|
455
|
+
lines = [f" [{style}]{label}[/{style}]", f" {detail}"]
|
|
456
|
+
if signals:
|
|
457
|
+
lines.append("")
|
|
458
|
+
for sig in signals:
|
|
459
|
+
lines.append(f" • {sig}")
|
|
460
|
+
|
|
461
|
+
console.print(
|
|
462
|
+
Panel("\n".join(lines), title="[bold]Diagnosis[/bold]",
|
|
463
|
+
border_style=border, expand=False)
|
|
464
|
+
)
|
|
465
|
+
console.print()
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def error(msg: str):
|
|
469
|
+
console.print(f" [red]✗[/red] {msg}\n")
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def warn(msg: str):
|
|
473
|
+
console.print(f" [yellow]⚠[/yellow] {msg}\n")
|