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/scoring.py ADDED
@@ -0,0 +1,70 @@
1
+ """Region Advisor: composite per-target quality score by use-case profile."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .stats import TargetStats
6
+
7
+ # profile key -> (label, one-line description)
8
+ PROFILES = ["voip", "gaming", "web", "bulk"]
9
+ PROFILE_META = {
10
+ "voip": ("VoIP / Video call", "jitter & loss dominate (MOS / E-model)"),
11
+ "gaming": ("Gaming", "raw latency + jitter, loss punished hard"),
12
+ "web": ("Web / API", "latency-led, mild loss penalty"),
13
+ "bulk": ("Bulk / Backup", "loss-led, tolerant of latency"),
14
+ }
15
+
16
+
17
+ def score(st: TargetStats, profile: str) -> float | None:
18
+ """Return a 0–100 quality score (higher is better), or None if no data."""
19
+ if st.status in ("DOWN",):
20
+ return 0.0
21
+ lat = st.avg
22
+ if lat is None:
23
+ return None
24
+ jit = st.jitter or 0.0
25
+ loss = st.loss
26
+
27
+ if profile == "voip":
28
+ mos = st.mos
29
+ if mos is None:
30
+ return None
31
+ return (mos - 1.0) / 3.5 * 100.0 # map MOS 1..4.5 -> 0..100
32
+
33
+ if profile == "gaming":
34
+ penalty = lat * 0.25 + jit * 1.0 + loss * 6.0
35
+ elif profile == "web":
36
+ penalty = lat * 0.18 + jit * 0.3 + loss * 4.0
37
+ elif profile == "bulk":
38
+ penalty = lat * 0.05 + jit * 0.1 + loss * 8.0
39
+ else:
40
+ penalty = lat * 0.2 + jit * 0.5 + loss * 5.0
41
+
42
+ return max(0.0, 100.0 - penalty)
43
+
44
+
45
+ def grade(score_value: float | None) -> str:
46
+ if score_value is None:
47
+ return "—"
48
+ if score_value >= 85:
49
+ return "A"
50
+ if score_value >= 70:
51
+ return "B"
52
+ if score_value >= 50:
53
+ return "C"
54
+ if score_value >= 30:
55
+ return "D"
56
+ return "F"
57
+
58
+
59
+ def reason(st: TargetStats, profile: str) -> str:
60
+ """Short human explanation for the score of a target under a profile."""
61
+ if st.status == "DOWN":
62
+ return "unreachable"
63
+ lat = st.avg
64
+ if lat is None:
65
+ return "warming up"
66
+ jit = st.jitter or 0.0
67
+ loss = st.loss
68
+ if profile == "voip" and st.mos is not None:
69
+ return f"MOS {st.mos:.1f} · {lat:.0f}ms / jit {jit:.0f} / loss {loss:.0f}%"
70
+ return f"{lat:.0f}ms · jit {jit:.0f}ms · loss {loss:.0f}%"
pingmon/stats.py ADDED
@@ -0,0 +1,150 @@
1
+ """Rolling per-target statistics: latency, jitter, loss, trend."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import deque
6
+ from dataclasses import dataclass, field
7
+
8
+ # Latency status thresholds (ms)
9
+ EXCELLENT = 40.0
10
+ GOOD = 90.0
11
+ FAIR = 180.0
12
+ POOR = 350.0
13
+
14
+
15
+ @dataclass
16
+ class TargetStats:
17
+ """Ring buffer of one target's samples plus derived metrics."""
18
+
19
+ maxlen: int = 90
20
+ samples: deque[float | None] = field(default_factory=deque)
21
+ sent: int = 0
22
+ lost: int = 0
23
+ last_ok: float | None = None
24
+ consecutive_fail: int = 0
25
+
26
+ def __post_init__(self) -> None:
27
+ self.samples = deque(maxlen=self.maxlen)
28
+
29
+ def record(self, latency: float | None) -> None:
30
+ self.sent += 1
31
+ self.samples.append(latency)
32
+ if latency is None:
33
+ self.lost += 1
34
+ self.consecutive_fail += 1
35
+ else:
36
+ self.last_ok = latency
37
+ self.consecutive_fail = 0
38
+
39
+ def reset(self) -> None:
40
+ self.samples.clear()
41
+ self.sent = 0
42
+ self.lost = 0
43
+ self.last_ok = None
44
+ self.consecutive_fail = 0
45
+
46
+ # --- derived values ---
47
+
48
+ @property
49
+ def ok_values(self) -> list[float]:
50
+ return [s for s in self.samples if s is not None]
51
+
52
+ @property
53
+ def last(self) -> float | None:
54
+ return self.samples[-1] if self.samples else None
55
+
56
+ @property
57
+ def avg(self) -> float | None:
58
+ v = self.ok_values
59
+ return sum(v) / len(v) if v else None
60
+
61
+ @property
62
+ def vmin(self) -> float | None:
63
+ v = self.ok_values
64
+ return min(v) if v else None
65
+
66
+ @property
67
+ def vmax(self) -> float | None:
68
+ v = self.ok_values
69
+ return max(v) if v else None
70
+
71
+ def percentile(self, p: float) -> float | None:
72
+ """Linear-interpolated percentile of successful samples (p in 0–100)."""
73
+ v = sorted(self.ok_values)
74
+ if not v:
75
+ return None
76
+ if len(v) == 1:
77
+ return v[0]
78
+ k = (len(v) - 1) * p / 100.0
79
+ f = int(k)
80
+ c = min(f + 1, len(v) - 1)
81
+ if f == c:
82
+ return v[f]
83
+ return v[f] + (v[c] - v[f]) * (k - f)
84
+
85
+ @property
86
+ def median(self) -> float | None:
87
+ return self.percentile(50.0)
88
+
89
+ @property
90
+ def jitter(self) -> float | None:
91
+ """Mean absolute difference between consecutive successful samples."""
92
+ v = self.ok_values
93
+ if len(v) < 2:
94
+ return None
95
+ diffs = [abs(v[i] - v[i - 1]) for i in range(1, len(v))]
96
+ return sum(diffs) / len(diffs)
97
+
98
+ @property
99
+ def loss(self) -> float:
100
+ return 100.0 * self.lost / self.sent if self.sent else 0.0
101
+
102
+ @property
103
+ def r_factor(self) -> float | None:
104
+ """ITU-T E-model R-factor (0–100) from avg latency, jitter and loss.
105
+
106
+ Uses effective latency = latency + 2·jitter (jitter hurts ~2× as much
107
+ as latency) and a linear packet-loss impairment.
108
+ """
109
+ lat = self.avg
110
+ if lat is None:
111
+ return None
112
+ jit = self.jitter or 0.0
113
+ eff = lat + 2.0 * jit + 10.0 # +10ms codec/processing allowance
114
+ if eff < 160.0:
115
+ r = 93.2 - eff / 40.0
116
+ else:
117
+ r = 93.2 - (eff - 120.0) / 10.0
118
+ r -= 2.5 * self.loss # packet-loss impairment
119
+ return max(0.0, min(100.0, r))
120
+
121
+ @property
122
+ def mos(self) -> float | None:
123
+ """Pseudo Mean Opinion Score (1.0–4.5) derived from the R-factor."""
124
+ r = self.r_factor
125
+ if r is None:
126
+ return None
127
+ mos = 1.0 + 0.035 * r + 7e-6 * r * (r - 60.0) * (100.0 - r)
128
+ return max(1.0, min(4.5, mos))
129
+
130
+ @property
131
+ def status(self) -> str:
132
+ """PENDING | DOWN | UNSTABLE | EXCELLENT | GOOD | FAIR | POOR."""
133
+ if not self.samples:
134
+ return "PENDING"
135
+ if self.consecutive_fail >= 3:
136
+ return "DOWN"
137
+ last = self.last
138
+ if last is None:
139
+ return "UNSTABLE"
140
+ if self.loss >= 20.0:
141
+ return "UNSTABLE"
142
+ if last < EXCELLENT:
143
+ return "EXCELLENT"
144
+ if last < GOOD:
145
+ return "GOOD"
146
+ if last < FAIR:
147
+ return "FAIR"
148
+ if last < POOR:
149
+ return "POOR"
150
+ return "POOR"
@@ -0,0 +1,224 @@
1
+ Metadata-Version: 2.4
2
+ Name: pingmonitor
3
+ Version: 1.0.0
4
+ Summary: TUI monitor of latency and availability to servers by country
5
+ Project-URL: Homepage, https://github.com/kottot13/pingmon
6
+ Project-URL: Repository, https://github.com/kottot13/pingmon
7
+ Author: pingmon contributors
8
+ License: MIT
9
+ License-File: LICENSE
10
+ Keywords: geoip,latency,monitoring,network,ping,textual,tui
11
+ Classifier: Development Status :: 5 - Production/Stable
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: System Administrators
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Operating System :: MacOS :: MacOS X
16
+ Classifier: Operating System :: POSIX :: Linux
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Topic :: System :: Monitoring
19
+ Classifier: Topic :: System :: Networking :: Monitoring
20
+ Requires-Python: >=3.11
21
+ Requires-Dist: textual>=0.80
22
+ Description-Content-Type: text/markdown
23
+
24
+ # pingmon
25
+
26
+ A full-featured terminal UI for monitoring **latency and availability** to
27
+ servers in different countries. Built with [Textual](https://textual.textualize.io/).
28
+
29
+ It continuously TCP-pings a set of targets (one or more reachable hosts per
30
+ country), and shows a live, colour-coded dashboard with per-target latency,
31
+ average, jitter, packet loss, a status indicator, and an animated trend graph.
32
+
33
+ ![pingmon screenshot](docs/screenshot.svg)
34
+
35
+ ## Features
36
+
37
+ - **Live dashboard** — sortable table of targets with status dot, latency, average, loss and an inline coloured sparkline trend.
38
+ - **Detail panel** — for the selected target: live latency graph, quality gauge, min / max / avg / jitter / loss, **MOS** call-quality score, resolved IP, **GeoIP city / region and the hosting network (ASN + ISP)**, sample count.
39
+ - **★ Region Advisor** *(unique)* — ranks regions by a composite 0–100 score under a chosen **use-case profile** (VoIP / Gaming / Web / Bulk) and highlights the best region to pick right now. Press `g`. See [below](#region-advisor).
40
+ - **MOS / R-factor** — turns latency + jitter + loss into the single VoIP call-quality number (ITU-T E-model), the same metric paid monitoring suites charge for.
41
+ - **Threshold alerts** — fire when a target stays slow or lossy for N samples: terminal bell, an in-app toast, an OS desktop notification, and a blinking row marker. Auto-clears on recovery.
42
+ - **Traceroute drill-down** — press `Enter` (or click) on a row for an mtr-style hop-by-hop path with per-hop loss and per-probe timings, plus **GeoIP per hop** (flag, city and ASN) so you can see which countries and networks the traffic crosses.
43
+ - **Per-country set, ready to go** — the Netherlands, Germany, United Kingdom, France, Cyprus, Italy, Spain, Greece, Sweden, Ireland and the United States are pre-configured with reachable hosts.
44
+ - **Editable targets** — add (`a`), edit (`E`) and delete (`d`) targets right inside the TUI, or edit the TOML config by hand; reset stats with `r`. In-app changes are saved to the config immediately.
45
+ - **GeoIP auto-detect & enrichment** — every target is looked up via ip-api.com: the detail panel shows its **city, region and hosting network (ASN + ISP)**, and when you add a target with country/flag left blank they are filled in automatically.
46
+ - **Show filter** — flip the table between **all**, **mine only** (targets you added) and **others only** (the built-in set) with `f`.
47
+ - **No root required** — uses TCP connect timing (port 443/80), so it works without raw-socket / ICMP privileges and measures real service latency.
48
+ - **Modern terminal UX** — truecolor, mouse support, zebra-striped table, a live "heartbeat" spinner, keyboard and mouse navigation, sort modes and modal dialogs.
49
+
50
+ | Region Advisor | Traceroute drill-down |
51
+ | -------------- | --------------------- |
52
+ | ![advisor](docs/advisor.svg) | ![traceroute](docs/traceroute.svg) |
53
+
54
+ ## Requirements
55
+
56
+ - Python 3.11+ (not needed for the standalone binary below)
57
+ - `textual >= 0.80` (installed automatically)
58
+
59
+ ## Install as a system command
60
+
61
+ Use it like `htop` — install once, run `pingmon` from anywhere.
62
+
63
+ ### pipx / uv (recommended, Linux + macOS)
64
+
65
+ ```bash
66
+ pipx install pingmon # from PyPI once published
67
+ pipx install . # or from a checkout of this repo
68
+ pipx install git+https://github.com/kottot13/pingmon
69
+ # uv works the same: uv tool install pingmon / uvx pingmon
70
+ ```
71
+
72
+ `pipx` keeps pingmon in its own isolated environment and puts the `pingmon`
73
+ command on your `PATH`.
74
+
75
+ ### Homebrew (macOS / Linuxbrew)
76
+
77
+ A formula skeleton lives in [`packaging/pingmon.rb`](packaging/pingmon.rb).
78
+ Publish to PyPI, fill in the sdist URL + `brew update-python-resources`, then:
79
+
80
+ ```bash
81
+ brew install kottot13/tap/pingmon
82
+ ```
83
+
84
+ ### Standalone binary (no Python on the target)
85
+
86
+ The most htop-like option — a single executable built with PyInstaller
87
+ ([`packaging/pingmon.spec`](packaging/pingmon.spec)):
88
+
89
+ ```bash
90
+ pip install pyinstaller
91
+ pyinstaller packaging/pingmon.spec
92
+ sudo cp dist/pingmon /usr/local/bin/ # macOS
93
+ cp dist/pingmon ~/.local/bin/ # Linux
94
+ ```
95
+
96
+ Build once per OS/architecture (PyInstaller does not cross-compile).
97
+
98
+ ### From source (development)
99
+
100
+ ```bash
101
+ ./run.sh # makes a local venv, installs Textual, launches
102
+ # or
103
+ python3 -m venv .venv && .venv/bin/pip install -e . && .venv/bin/pingmon
104
+ ```
105
+
106
+ Config lives at `~/.config/pingmon/config.toml` by default (or a local
107
+ `./config.toml` if present, or `$PINGMON_CONFIG`). Run `pingmon --help` for the
108
+ CLI flags (`-V/--version`, `-c/--config PATH`).
109
+
110
+ ## Keys
111
+
112
+ | Key | Action |
113
+ | -------------- | ------------------------------- |
114
+ | `↑ / ↓`, `j/k` | Move selection |
115
+ | `Enter` | Traceroute drill-down for the selected target |
116
+ | `g` | Open the Region Advisor (`[` `]` / `p` switch profile inside) |
117
+ | `p` | Cycle the Advisor profile (VoIP / Gaming / Web / Bulk) |
118
+ | `f` | Cycle the show filter (all / mine only / others only) |
119
+ | `space` | Pause / resume probing |
120
+ | `m` / `M` | Sort by latency (ms); press again to flip fastest ⇄ slowest first |
121
+ | `s` | Cycle sort (country / latency / loss / jitter) |
122
+ | `a` | Add a target |
123
+ | `E` | Edit the selected target (form pre-filled) |
124
+ | `A` | Toggle the alert system on / off |
125
+ | `d` | Delete the selected target |
126
+ | `r` | Reset all statistics |
127
+ | `e` | Show the config file path |
128
+ | `q` | Quit |
129
+
130
+ ## Region Advisor
131
+
132
+ The Advisor (`g`) answers the question the tool was born from — *which region
133
+ should I actually pick right now?* It computes a **0–100 score** per region from
134
+ latency, jitter and loss, under a selectable **profile**:
135
+
136
+ | Profile | What it optimises for |
137
+ | ------- | --------------------- |
138
+ | **VoIP / Video call** | jitter & loss dominate; driven by the MOS / E-model score |
139
+ | **Gaming** | raw latency + jitter, loss punished hard |
140
+ | **Web / API** | latency-led, mild loss penalty |
141
+ | **Bulk / Backup** | loss-led, tolerant of high latency |
142
+
143
+ Regions are ranked best-first with the #1 pick highlighted, each with a score
144
+ bar, a letter grade (A–F) and a one-line reason. Switch profile with `[` / `]`,
145
+ `p` or `Tab`; close with `Esc`.
146
+
147
+ ## Alerts
148
+
149
+ A target enters **alert** state when, for `alert_window` consecutive samples, it
150
+ is unreachable, slower than `alert_latency` ms, or its recent loss exceeds
151
+ `alert_loss` %. On entry pingmon rings the terminal bell, shows a toast, raises
152
+ an OS desktop notification (macOS `osascript` / Linux `notify-send`, toggle with
153
+ `desktop_notify`) and blinks the row's status marker; it auto-clears with a
154
+ "Recovered" toast. Press `A` to switch the whole alert system off or on at any
155
+ time (the banner shows `⚲ alerts off` while disabled); tune or permanently
156
+ disable the triggers in `config.toml`.
157
+
158
+ ## Status colours
159
+
160
+ | Status | Meaning (last sample) |
161
+ | ----------- | ----------------------------- |
162
+ | `EXCELLENT` | < 40 ms |
163
+ | `GOOD` | < 90 ms |
164
+ | `FAIR` | < 180 ms |
165
+ | `POOR` | < 350 ms / ≥ 350 ms |
166
+ | `UNSTABLE` | loss ≥ 20% or a recent drop |
167
+ | `DOWN` | 3+ consecutive failures |
168
+ | `PENDING` | no samples yet |
169
+
170
+ ## Configuration
171
+
172
+ On first run a `config.toml` is created at `~/.config/pingmon/config.toml` (or a
173
+ local `./config.toml` if one already exists, or wherever `$PINGMON_CONFIG` /
174
+ `--config` points). It is plain TOML and meant to be hand-edited:
175
+
176
+ ```toml
177
+ interval = 2.0 # poll period per target, seconds
178
+ timeout = 2.0 # TCP connect timeout, seconds
179
+ history = 90 # samples kept in memory for the graph
180
+
181
+ alert_latency = 300.0 # alert if latency stays above this (ms); 0 disables
182
+ alert_loss = 20.0 # alert if recent loss exceeds this (%); 0 disables
183
+ alert_window = 3 # consecutive bad samples before an alert fires
184
+ desktop_notify = true # also raise an OS desktop notification on alert
185
+
186
+ [[targets]]
187
+ country = "Netherlands"
188
+ flag = "🇳🇱"
189
+ host = "speedtest.ams1.nl.leaseweb.net"
190
+ port = 80
191
+ source = "builtin" # "builtin" (shipped) or "user" (added in-app) — drives the `f` filter
192
+
193
+ [[targets]]
194
+ country = "United States"
195
+ flag = "🇺🇸"
196
+ host = "speedtest.newark.linode.com"
197
+ port = 443
198
+ source = "builtin"
199
+ ```
200
+
201
+ Add as many `[[targets]]` blocks as you like; any host or IP works, and the
202
+ port is per-target. `source` is optional — if omitted it is inferred (hosts in
203
+ the built-in set are `builtin`, everything else `user`).
204
+
205
+ ## How latency is measured
206
+
207
+ `pingmon` opens a TCP connection to `host:port` and times the round-trip of the
208
+ connection handshake (SYN → SYN/ACK). That is close to true network RTT and,
209
+ unlike ICMP, needs no elevated privileges and reflects whether the service port
210
+ is actually answering. Failed or timed-out connects count as packet loss.
211
+
212
+ ## Project layout
213
+
214
+ ```
215
+ pingmon/
216
+ app.py # Textual app: table, detail panel, graphs, advisor, alerts, actions
217
+ pinger.py # async TCP ping + DNS resolve
218
+ stats.py # rolling per-target stats (latency, jitter, loss, MOS, status)
219
+ scoring.py # Region Advisor: composite score + use-case profiles
220
+ netutil.py # async traceroute + OS desktop notifications
221
+ render.py # colours, status meta, text sparklines
222
+ config.py # TOML load/save + built-in per-country target set
223
+ app.tcss # dark theme / layout
224
+ ```
@@ -0,0 +1,15 @@
1
+ pingmon/__init__.py,sha256=HIXX6lx2XDgyr6ed8sRKJzil4K7sKWd3Zos-Rj9ih2U,104
2
+ pingmon/__main__.py,sha256=876xZTqhPeDBD2GKKr8pKX33Af8PhFTosAI8bYQsFgQ,61
3
+ pingmon/app.py,sha256=1jkCjRE4h297Gdi-Se94erllwaLmxDlno30VtbAZ56c,35541
4
+ pingmon/config.py,sha256=IC1Qrlbf3iABuHvCKE3zvXoxNrMeNY0crAKkUYaA7tc,6852
5
+ pingmon/netutil.py,sha256=779wJIN3-KykdmdKUh-01uMdnNFcgN9VcL9U9QUAN_Y,4409
6
+ pingmon/pinger.py,sha256=VNDjv9FLxLlKVlhcxXNfoF69e0dsaw-BXe-TRzbRbns,1378
7
+ pingmon/render.py,sha256=06jxLBhvRHLjvudac_UTkVEvCI62jpOne9_8may1Rqc,4177
8
+ pingmon/scoring.py,sha256=mnpWsClFus45HfUTT4kYKYFCuqx6AKTdm2J9YCVN92s,2122
9
+ pingmon/stats.py,sha256=v4J3Wu90YP-4Fi8RtkBIHC3DJdDoo1axG0W3pVsqgyY,4245
10
+ pingmon/app.tcss,sha256=ufMOKPeLxZVKSkrFg1aFR6nYKm1_dhMM0L6l-bgpkw0,2383
11
+ pingmonitor-1.0.0.dist-info/METADATA,sha256=YFbdFKYdye_9w0YtV1rvNJnpx3EudjAxOOam04Vl6DE,10511
12
+ pingmonitor-1.0.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
13
+ pingmonitor-1.0.0.dist-info/entry_points.txt,sha256=opcNVMcB7CdmgQdhj6qI-niT_Mn-P-EMaWzQdG7UnDo,45
14
+ pingmonitor-1.0.0.dist-info/licenses/LICENSE,sha256=FdUqEswHPGcrSfDe0hRzOIIDLcq3SdamHR_Y1_gUVGI,1077
15
+ pingmonitor-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ pingmon = pingmon.app:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 pingmon contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.