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/__init__.py +3 -0
- pingmon/__main__.py +4 -0
- pingmon/app.py +948 -0
- pingmon/app.tcss +183 -0
- pingmon/config.py +163 -0
- pingmon/netutil.py +139 -0
- pingmon/pinger.py +44 -0
- pingmon/render.py +129 -0
- pingmon/scoring.py +70 -0
- pingmon/stats.py +150 -0
- pingmonitor-1.0.0.dist-info/METADATA +224 -0
- pingmonitor-1.0.0.dist-info/RECORD +15 -0
- pingmonitor-1.0.0.dist-info/WHEEL +4 -0
- pingmonitor-1.0.0.dist-info/entry_points.txt +2 -0
- pingmonitor-1.0.0.dist-info/licenses/LICENSE +21 -0
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
|