pingmonitor 1.0.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.
pingmon/app.tcss ADDED
@@ -0,0 +1,183 @@
1
+ /* pingmon dark theme */
2
+
3
+ Screen {
4
+ background: #14141c;
5
+ color: #e0e0f0;
6
+ }
7
+
8
+ #banner {
9
+ height: 1;
10
+ padding: 0 1;
11
+ background: #1c1c28;
12
+ color: #e0e0f0;
13
+ }
14
+
15
+ #body {
16
+ height: 1fr;
17
+ }
18
+
19
+ #left {
20
+ width: 1fr;
21
+ min-width: 70;
22
+ border-right: tall #2a2a3a;
23
+ }
24
+
25
+ #table {
26
+ height: 1fr;
27
+ background: #14141c;
28
+ }
29
+
30
+ DataTable > .datatable--header {
31
+ background: #1c1c28;
32
+ color: #8a8aa0;
33
+ text-style: bold;
34
+ }
35
+
36
+ DataTable > .datatable--cursor {
37
+ background: #2d3354;
38
+ }
39
+
40
+ DataTable > .datatable--hover {
41
+ background: #20202e;
42
+ }
43
+
44
+ #detail {
45
+ width: 54;
46
+ padding: 1 2;
47
+ background: #16161f;
48
+ }
49
+
50
+ #detail-title {
51
+ text-style: bold;
52
+ margin-bottom: 1;
53
+ }
54
+
55
+ #detail-meta {
56
+ margin-bottom: 1;
57
+ color: #9a9ab0;
58
+ }
59
+
60
+ .detail-h {
61
+ color: #7a7a8c;
62
+ text-style: bold;
63
+ margin-top: 1;
64
+ }
65
+
66
+ #detail-spark {
67
+ height: 6;
68
+ margin: 0 0 1 0;
69
+ }
70
+
71
+ #detail-spark > .sparkline--max-color {
72
+ color: #7aa2f7;
73
+ }
74
+
75
+ #detail-spark > .sparkline--min-color {
76
+ color: #3ddc84;
77
+ }
78
+
79
+ #detail-quality {
80
+ margin-bottom: 1;
81
+ }
82
+
83
+ #detail-stats {
84
+ margin-top: 1;
85
+ }
86
+
87
+ #detail-hint {
88
+ margin-top: 2;
89
+ color: #6a6a7c;
90
+ }
91
+
92
+ /* Add / edit target modal */
93
+ TargetFormScreen {
94
+ align: center middle;
95
+ }
96
+
97
+ #add-dialog {
98
+ width: 60;
99
+ height: auto;
100
+ padding: 1 2;
101
+ background: #1c1c28;
102
+ border: thick #7aa2f7;
103
+ }
104
+
105
+ #add-title {
106
+ text-style: bold;
107
+ color: #7aa2f7;
108
+ margin-bottom: 1;
109
+ }
110
+
111
+ #add-dialog Input {
112
+ margin-bottom: 1;
113
+ }
114
+
115
+ #add-hint {
116
+ color: #6a6a7c;
117
+ margin-bottom: 1;
118
+ }
119
+
120
+ #add-buttons {
121
+ height: auto;
122
+ align: right middle;
123
+ }
124
+
125
+ #add-buttons Button {
126
+ margin-left: 2;
127
+ }
128
+
129
+ /* Region Advisor modal */
130
+ AdvisorScreen {
131
+ align: center middle;
132
+ }
133
+
134
+ #advisor-dialog {
135
+ width: 116;
136
+ height: auto;
137
+ max-height: 90%;
138
+ padding: 1 2;
139
+ background: #1c1c28;
140
+ border: thick #7aa2f7;
141
+ }
142
+
143
+ #advisor-head {
144
+ margin-bottom: 1;
145
+ }
146
+
147
+ #advisor-table {
148
+ height: auto;
149
+ max-height: 24;
150
+ background: #1c1c28;
151
+ }
152
+
153
+ #advisor-foot {
154
+ margin-top: 1;
155
+ }
156
+
157
+ /* Traceroute modal */
158
+ TracerouteScreen {
159
+ align: center middle;
160
+ }
161
+
162
+ #trace-dialog {
163
+ width: 108;
164
+ height: auto;
165
+ max-height: 90%;
166
+ padding: 1 2;
167
+ background: #1c1c28;
168
+ border: thick #c9a0dc;
169
+ }
170
+
171
+ #trace-head {
172
+ margin-bottom: 1;
173
+ }
174
+
175
+ #trace-table {
176
+ height: auto;
177
+ max-height: 26;
178
+ background: #1c1c28;
179
+ }
180
+
181
+ #trace-foot {
182
+ margin-top: 1;
183
+ }
pingmon/config.py ADDED
@@ -0,0 +1,163 @@
1
+ """Load/save target configuration. Format is TOML, meant to be hand-edited."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import tomllib
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+
10
+ # Built-in set: 1-2 reachable hosts per country from the VPS list + the US.
11
+ # All verified reachable over TCP. The port is configurable per target.
12
+ DEFAULT_TARGETS: list[dict] = [
13
+ {"country": "Netherlands", "flag": "🇳🇱", "host": "speedtest.ams1.nl.leaseweb.net", "port": 80},
14
+ {"country": "Netherlands", "flag": "🇳🇱", "host": "ams.speedtest.clouvider.net", "port": 80},
15
+ {"country": "Germany", "flag": "🇩🇪", "host": "speedtest.frankfurt.linode.com", "port": 443},
16
+ {"country": "Germany", "flag": "🇩🇪", "host": "ftp.fau.de", "port": 80},
17
+ {"country": "United Kingdom", "flag": "🇬🇧", "host": "speedtest.london.linode.com", "port": 443},
18
+ {"country": "United Kingdom", "flag": "🇬🇧", "host": "lon.speedtest.clouvider.net", "port": 443},
19
+ {"country": "France", "flag": "🇫🇷", "host": "scaleway.testdebit.info", "port": 80},
20
+ {"country": "Cyprus", "flag": "🇨🇾", "host": "ftp.cs.ucy.ac.cy", "port": 80},
21
+ {"country": "Cyprus", "flag": "🇨🇾", "host": "mirror.library.ucy.ac.cy", "port": 80},
22
+ {"country": "Italy", "flag": "🇮🇹", "host": "mirror.garr.it", "port": 80},
23
+ {"country": "Italy", "flag": "🇮🇹", "host": "giano.com.dist.unige.it", "port": 80},
24
+ {"country": "Spain", "flag": "🇪🇸", "host": "ftp.cica.es", "port": 80},
25
+ {"country": "Greece", "flag": "🇬🇷", "host": "ftp.ntua.gr", "port": 80},
26
+ {"country": "Greece", "flag": "🇬🇷", "host": "ftp.cc.uoc.gr", "port": 80},
27
+ {"country": "Sweden", "flag": "🇸🇪", "host": "speedtest.tele2.net", "port": 80},
28
+ {"country": "Ireland", "flag": "🇮🇪", "host": "ftp.heanet.ie", "port": 80},
29
+ {"country": "United States", "flag": "🇺🇸", "host": "speedtest.newark.linode.com", "port": 443},
30
+ {"country": "United States", "flag": "🇺🇸", "host": "la.speedtest.clouvider.net", "port": 443},
31
+ {"country": "United States", "flag": "🇺🇸", "host": "mirror.us.leaseweb.net", "port": 80},
32
+ ]
33
+
34
+ DEFAULTS = {
35
+ "interval": 2.0, # poll period per target, seconds
36
+ "timeout": 2.0, # TCP connect timeout, seconds
37
+ "history": 90, # number of samples kept in memory for the graph
38
+ "alert_latency": 300.0, # alert if latency exceeds this (ms); 0 disables
39
+ "alert_loss": 20.0, # alert if loss over the window exceeds this (%); 0 disables
40
+ "alert_window": 3, # consecutive bad samples before an alert fires
41
+ "desktop_notify": True, # also raise an OS desktop notification on alert
42
+ }
43
+
44
+
45
+ @dataclass
46
+ class Target:
47
+ country: str
48
+ flag: str
49
+ host: str
50
+ port: int = 443
51
+ source: str = "builtin" # "builtin" = shipped default, "user" = added/edited
52
+
53
+ @property
54
+ def key(self) -> str:
55
+ return f"{self.host}:{self.port}"
56
+
57
+
58
+ @dataclass
59
+ class Config:
60
+ interval: float = DEFAULTS["interval"]
61
+ timeout: float = DEFAULTS["timeout"]
62
+ history: int = DEFAULTS["history"]
63
+ alert_latency: float = DEFAULTS["alert_latency"]
64
+ alert_loss: float = DEFAULTS["alert_loss"]
65
+ alert_window: int = DEFAULTS["alert_window"]
66
+ desktop_notify: bool = DEFAULTS["desktop_notify"]
67
+ targets: list[Target] = field(default_factory=list)
68
+ path: Path | None = None
69
+
70
+
71
+ def config_path() -> Path:
72
+ """Resolve the config file location, htop-style.
73
+
74
+ Order: $PINGMON_CONFIG → a local ./config.toml if it already exists (handy
75
+ for development) → $XDG_CONFIG_HOME/pingmon/config.toml (the default for an
76
+ installed, run-from-anywhere command).
77
+ """
78
+ env = os.environ.get("PINGMON_CONFIG")
79
+ if env:
80
+ return Path(env).expanduser()
81
+ local = Path.cwd() / "config.toml"
82
+ if local.exists():
83
+ return local
84
+ xdg = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
85
+ return xdg / "pingmon" / "config.toml"
86
+
87
+
88
+ def load_config() -> Config:
89
+ path = config_path()
90
+ if not path.exists():
91
+ cfg = Config(
92
+ targets=[Target(**t) for t in DEFAULT_TARGETS],
93
+ path=path,
94
+ )
95
+ save_config(cfg)
96
+ return cfg
97
+
98
+ with path.open("rb") as fh:
99
+ data = tomllib.load(fh)
100
+
101
+ default_hosts = {t["host"] for t in DEFAULT_TARGETS}
102
+ targets = [
103
+ Target(
104
+ country=t.get("country", "Unknown"),
105
+ flag=t.get("flag", "🏳"),
106
+ host=t["host"],
107
+ port=int(t.get("port", 443)),
108
+ # honour an explicit source; otherwise infer from the built-in set
109
+ source=t.get("source") or ("builtin" if t["host"] in default_hosts else "user"),
110
+ )
111
+ for t in data.get("targets", [])
112
+ ]
113
+ if not targets:
114
+ targets = [Target(**t) for t in DEFAULT_TARGETS]
115
+
116
+ return Config(
117
+ interval=float(data.get("interval", DEFAULTS["interval"])),
118
+ timeout=float(data.get("timeout", DEFAULTS["timeout"])),
119
+ history=int(data.get("history", DEFAULTS["history"])),
120
+ alert_latency=float(data.get("alert_latency", DEFAULTS["alert_latency"])),
121
+ alert_loss=float(data.get("alert_loss", DEFAULTS["alert_loss"])),
122
+ alert_window=int(data.get("alert_window", DEFAULTS["alert_window"])),
123
+ desktop_notify=bool(data.get("desktop_notify", DEFAULTS["desktop_notify"])),
124
+ targets=targets,
125
+ path=path,
126
+ )
127
+
128
+
129
+ def _toml_escape(s: str) -> str:
130
+ return s.replace("\\", "\\\\").replace('"', '\\"')
131
+
132
+
133
+ def save_config(cfg: Config) -> None:
134
+ """Minimal serialiser for our schema (no external dependencies)."""
135
+ path = cfg.path or config_path()
136
+ path.parent.mkdir(parents=True, exist_ok=True)
137
+
138
+ lines = [
139
+ "# pingmon configuration. Edit freely: add your own hosts/IPs.",
140
+ "# interval — poll period per target (s), timeout — connect timeout (s).",
141
+ "",
142
+ f"interval = {cfg.interval}",
143
+ f"timeout = {cfg.timeout}",
144
+ f"history = {cfg.history}",
145
+ "",
146
+ "# Alerts: fire when a target stays bad for `alert_window` samples.",
147
+ "# Set alert_latency or alert_loss to 0 to disable that trigger.",
148
+ f"alert_latency = {cfg.alert_latency}",
149
+ f"alert_loss = {cfg.alert_loss}",
150
+ f"alert_window = {cfg.alert_window}",
151
+ f"desktop_notify = {'true' if cfg.desktop_notify else 'false'}",
152
+ "",
153
+ ]
154
+ for t in cfg.targets:
155
+ lines.append("[[targets]]")
156
+ lines.append(f'country = "{_toml_escape(t.country)}"')
157
+ lines.append(f'flag = "{_toml_escape(t.flag)}"')
158
+ lines.append(f'host = "{_toml_escape(t.host)}"')
159
+ lines.append(f"port = {t.port}")
160
+ lines.append(f'source = "{_toml_escape(t.source)}"')
161
+ lines.append("")
162
+
163
+ path.write_text("\n".join(lines), encoding="utf-8")
pingmon/netutil.py ADDED
@@ -0,0 +1,139 @@
1
+ """System helpers: async traceroute and OS desktop notifications."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import re
7
+ import sys
8
+
9
+ _TIME_RE = re.compile(r"([\d.]+)\s*ms")
10
+ _IP_RE = re.compile(r"\b\d{1,3}(?:\.\d{1,3}){3}\b")
11
+
12
+
13
+ def parse_hop(line: str) -> dict | None:
14
+ """Parse one traceroute line into {hop, host, times, loss}."""
15
+ line = line.strip()
16
+ m = re.match(r"^\s*(\d+)\s+(.*)$", line)
17
+ if not m:
18
+ return None
19
+ hop = int(m.group(1))
20
+ rest = m.group(2)
21
+
22
+ times: list[float | None] = []
23
+ # count probes: every "* " is a loss, every "N ms" is a sample
24
+ for token in re.findall(r"\*|[\d.]+\s*ms", rest):
25
+ if token == "*":
26
+ times.append(None)
27
+ else:
28
+ tm = _TIME_RE.search(token)
29
+ times.append(float(tm.group(1)) if tm else None)
30
+
31
+ ip_match = _IP_RE.search(rest)
32
+ host = ip_match.group(0) if ip_match else ("*" if rest.strip().startswith("*") else rest.split()[0])
33
+
34
+ sent = len(times) or 1
35
+ lost = sum(1 for t in times if t is None)
36
+ loss = 100.0 * lost / sent
37
+ return {"hop": hop, "host": host, "times": times, "loss": loss}
38
+
39
+
40
+ async def traceroute(host: str, max_hops: int = 20, queries: int = 3):
41
+ """Async generator yielding parsed hops as traceroute emits them."""
42
+ if sys.platform == "win32":
43
+ cmd = ["tracert", "-d", "-h", str(max_hops), host]
44
+ else:
45
+ cmd = ["traceroute", "-n", "-q", str(queries), "-w", "1",
46
+ "-m", str(max_hops), host]
47
+ try:
48
+ proc = await asyncio.create_subprocess_exec(
49
+ *cmd,
50
+ stdout=asyncio.subprocess.PIPE,
51
+ stderr=asyncio.subprocess.STDOUT,
52
+ )
53
+ except FileNotFoundError:
54
+ yield {"error": f"`{cmd[0]}` not found on this system"}
55
+ return
56
+
57
+ try:
58
+ assert proc.stdout is not None
59
+ async for raw in proc.stdout:
60
+ text = raw.decode(errors="replace").rstrip()
61
+ hop = parse_hop(text)
62
+ if hop:
63
+ yield hop
64
+ finally:
65
+ if proc.returncode is None:
66
+ try:
67
+ proc.terminate()
68
+ except ProcessLookupError:
69
+ pass
70
+ await proc.wait()
71
+
72
+
73
+ def is_global_ip(value: str) -> bool:
74
+ """True if `value` is a public, internet-routable IP address."""
75
+ import ipaddress
76
+
77
+ try:
78
+ return ipaddress.ip_address(value).is_global
79
+ except ValueError:
80
+ return False
81
+
82
+
83
+ def flag_emoji(iso2: str | None) -> str:
84
+ """ISO-3166 alpha-2 code -> regional-indicator flag emoji ('NL' -> 🇳🇱)."""
85
+ if not iso2 or len(iso2) != 2 or not iso2.isalpha():
86
+ return "🏳"
87
+ iso2 = iso2.upper()
88
+ return chr(0x1F1E6 + ord(iso2[0]) - 65) + chr(0x1F1E6 + ord(iso2[1]) - 65)
89
+
90
+
91
+ _GEO_FIELDS = "status,country,countryCode,regionName,city,isp,as,query"
92
+
93
+
94
+ async def geo_lookup(host: str) -> dict | None:
95
+ """Look a host/IP up via ip-api.com (free, no key); returns a geo dict.
96
+
97
+ Keys: country, countryCode, regionName, city, isp, as (ASN + name), query
98
+ (the resolved IP). Returns None on any failure. The blocking HTTP call runs
99
+ in a thread so the UI event loop is never stalled.
100
+ """
101
+ def fetch() -> dict:
102
+ import json
103
+ import urllib.request
104
+
105
+ url = f"http://ip-api.com/json/{host}?fields={_GEO_FIELDS}"
106
+ with urllib.request.urlopen(url, timeout=4) as resp:
107
+ return json.load(resp)
108
+
109
+ try:
110
+ data = await asyncio.to_thread(fetch)
111
+ except Exception:
112
+ return None
113
+ if data.get("status") != "success":
114
+ return None
115
+ if not data.get("country") and not data.get("countryCode"):
116
+ return None
117
+ return data
118
+
119
+
120
+ async def desktop_notify(title: str, message: str) -> None:
121
+ """Best-effort OS desktop notification; silently ignored if unavailable."""
122
+ safe = message.replace('"', "'")
123
+ safe_title = title.replace('"', "'")
124
+ if sys.platform == "darwin":
125
+ script = f'display notification "{safe}" with title "{safe_title}"'
126
+ cmd = ["osascript", "-e", script]
127
+ elif sys.platform.startswith("linux"):
128
+ cmd = ["notify-send", safe_title, safe]
129
+ else:
130
+ return
131
+ try:
132
+ proc = await asyncio.create_subprocess_exec(
133
+ *cmd,
134
+ stdout=asyncio.subprocess.DEVNULL,
135
+ stderr=asyncio.subprocess.DEVNULL,
136
+ )
137
+ await proc.wait()
138
+ except (FileNotFoundError, OSError):
139
+ pass
pingmon/pinger.py ADDED
@@ -0,0 +1,44 @@
1
+ """Asynchronous TCP ping: measure connect time to host:port."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+
7
+
8
+ async def tcp_ping(host: str, port: int, timeout: float = 2.0) -> float | None:
9
+ """Return latency in milliseconds, or None if unreachable.
10
+
11
+ Measures the full TCP connect round-trip (SYN -> SYN/ACK), which is
12
+ close to the real network latency to the service on the given port.
13
+ """
14
+ loop = asyncio.get_running_loop()
15
+ start = loop.time()
16
+ writer = None
17
+ try:
18
+ fut = asyncio.open_connection(host, port)
19
+ reader, writer = await asyncio.wait_for(fut, timeout=timeout)
20
+ elapsed = (loop.time() - start) * 1000.0
21
+ return elapsed
22
+ except (OSError, asyncio.TimeoutError):
23
+ return None
24
+ finally:
25
+ if writer is not None:
26
+ try:
27
+ writer.close()
28
+ # don't await wait_closed — close timing is irrelevant here
29
+ except Exception:
30
+ pass
31
+
32
+
33
+ async def resolve(host: str) -> str | None:
34
+ """Resolve a host to an IP (for display in the detail panel)."""
35
+ loop = asyncio.get_running_loop()
36
+ try:
37
+ infos = await asyncio.wait_for(
38
+ loop.getaddrinfo(host, None), timeout=3.0
39
+ )
40
+ if infos:
41
+ return infos[0][4][0]
42
+ except (OSError, asyncio.TimeoutError):
43
+ return None
44
+ return None
pingmon/render.py ADDED
@@ -0,0 +1,129 @@
1
+ """Visual helpers: latency colours, status meta, text sparklines."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from rich.text import Text
6
+
7
+ from .stats import EXCELLENT, FAIR, GOOD, POOR, TargetStats
8
+
9
+ BARS = "▁▂▃▄▅▆▇█"
10
+
11
+ # status -> (label, colour, marker)
12
+ STATUS_META = {
13
+ "EXCELLENT": ("EXCELLENT", "#3ddc84", "●"),
14
+ "GOOD": ("GOOD", "#a6e22e", "●"),
15
+ "FAIR": ("FAIR", "#f4bf4f", "●"),
16
+ "POOR": ("POOR", "#fd8d3c", "▲"),
17
+ "UNSTABLE": ("UNSTABLE", "#ff6f91", "◆"),
18
+ "DOWN": ("DOWN", "#ff3860", "✕"),
19
+ "PENDING": ("PENDING", "#7a7a8c", "◌"),
20
+ }
21
+
22
+
23
+ def latency_color(ms: float | None) -> str:
24
+ if ms is None:
25
+ return "#ff3860"
26
+ if ms < EXCELLENT:
27
+ return "#3ddc84"
28
+ if ms < GOOD:
29
+ return "#a6e22e"
30
+ if ms < FAIR:
31
+ return "#f4bf4f"
32
+ if ms < POOR:
33
+ return "#fd8d3c"
34
+ return "#ff3860"
35
+
36
+
37
+ def fmt_ms(value: float | None, width: int = 0) -> str:
38
+ s = "—" if value is None else f"{value:.0f}"
39
+ return s.rjust(width) if width else s
40
+
41
+
42
+ def status_text(stats: TargetStats) -> Text:
43
+ label, color, glyph = STATUS_META[stats.status]
44
+ return Text(f"{glyph} {label}", style=f"bold {color}")
45
+
46
+
47
+ def latency_text(value: float | None, suffix: str = "") -> Text:
48
+ color = latency_color(value)
49
+ s = "—" if value is None else f"{value:.0f}{suffix}"
50
+ return Text(s, style=color)
51
+
52
+
53
+ def sparkline(samples, width: int = 18, lo: float | None = None, hi: float | None = None) -> Text:
54
+ """Coloured text sparkline from the last `width` samples.
55
+
56
+ Failures (None) render as a red cross; every bar is tinted with the
57
+ colour of its own latency, giving a lively mini graph.
58
+ """
59
+ data = list(samples)[-width:]
60
+ if not data:
61
+ return Text("·" * width, style="#3a3a4a")
62
+
63
+ ok = [s for s in data if s is not None]
64
+ lo = min(ok) if (lo is None and ok) else (lo if lo is not None else 0.0)
65
+ hi = max(ok) if (hi is None and ok) else (hi if hi is not None else 1.0)
66
+ span = (hi - lo) or 1.0
67
+
68
+ out = Text()
69
+ for s in data:
70
+ if s is None:
71
+ out.append("╳", style="bold #ff3860")
72
+ continue
73
+ idx = int((s - lo) / span * (len(BARS) - 1))
74
+ idx = max(0, min(len(BARS) - 1, idx))
75
+ out.append(BARS[idx], style=latency_color(s))
76
+ pad = width - len(data)
77
+ if pad > 0:
78
+ return Text("·" * pad, style="#3a3a4a") + out
79
+ return out
80
+
81
+
82
+ def distribution_strip(samples, width: int = 24) -> Text:
83
+ """SmokePing-style density strip across the latency range of the window.
84
+
85
+ The x-axis runs from the window's min to max latency; each column is a bin
86
+ shaded by how many samples fall in it (darker = denser), tinted by the
87
+ latency at that point. Reveals whether a link is tight or smeared.
88
+ """
89
+ ok = [s for s in samples if s is not None]
90
+ if len(ok) < 2:
91
+ return Text("·" * width, style="#3a3a4a")
92
+ lo, hi = min(ok), max(ok)
93
+ span = (hi - lo) or 1.0
94
+ bins = [0] * width
95
+ for v in ok:
96
+ idx = int((v - lo) / span * (width - 1))
97
+ bins[min(width - 1, max(0, idx))] += 1
98
+ peak = max(bins) or 1
99
+ shades = " ░▒▓█"
100
+ out = Text()
101
+ for i, count in enumerate(bins):
102
+ if count == 0:
103
+ out.append("·", style="#2a2a38")
104
+ continue
105
+ level = max(1, int(count / peak * (len(shades) - 1)))
106
+ center = lo + (i + 0.5) / width * span
107
+ out.append(shades[level], style=latency_color(center))
108
+ return out
109
+
110
+
111
+ def loss_text(loss: float) -> Text:
112
+ if loss <= 0:
113
+ return Text("0%", style="#3ddc84")
114
+ if loss < 5:
115
+ return Text(f"{loss:.0f}%", style="#f4bf4f")
116
+ return Text(f"{loss:.0f}%", style="bold #ff3860")
117
+
118
+
119
+ def quality_bar(value: float | None, width: int = 20) -> Text:
120
+ """Horizontal quality gauge: lower latency -> longer bar."""
121
+ if value is None:
122
+ return Text("░" * width, style="#ff3860")
123
+ # 0ms -> full, 400ms+ -> empty
124
+ frac = max(0.0, min(1.0, 1.0 - value / 400.0))
125
+ filled = int(round(frac * width))
126
+ color = latency_color(value)
127
+ bar = Text("█" * filled, style=color)
128
+ bar.append("░" * (width - filled), style="#2a2a38")
129
+ return bar