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/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")