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.py
ADDED
|
@@ -0,0 +1,948 @@
|
|
|
1
|
+
"""pingmon TUI: live latency & availability monitoring per country."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
|
|
8
|
+
from rich.text import Text
|
|
9
|
+
from textual import on
|
|
10
|
+
from textual.app import App, ComposeResult
|
|
11
|
+
from textual.binding import Binding
|
|
12
|
+
from textual.containers import Horizontal, Vertical, VerticalScroll
|
|
13
|
+
from textual.screen import ModalScreen
|
|
14
|
+
from textual.widgets import (
|
|
15
|
+
Button,
|
|
16
|
+
DataTable,
|
|
17
|
+
Footer,
|
|
18
|
+
Input,
|
|
19
|
+
Label,
|
|
20
|
+
Sparkline,
|
|
21
|
+
Static,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
from .config import Config, Target, load_config, save_config
|
|
25
|
+
from .netutil import desktop_notify, flag_emoji, geo_lookup, is_global_ip, traceroute
|
|
26
|
+
from .pinger import resolve, tcp_ping
|
|
27
|
+
from .render import (
|
|
28
|
+
STATUS_META,
|
|
29
|
+
distribution_strip,
|
|
30
|
+
latency_color,
|
|
31
|
+
latency_text,
|
|
32
|
+
loss_text,
|
|
33
|
+
quality_bar,
|
|
34
|
+
sparkline,
|
|
35
|
+
)
|
|
36
|
+
from .scoring import PROFILE_META, PROFILES, grade, reason, score
|
|
37
|
+
from .stats import TargetStats
|
|
38
|
+
|
|
39
|
+
SPINNER = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class Monitor:
|
|
43
|
+
"""A target bundled with its stats and background ping worker."""
|
|
44
|
+
|
|
45
|
+
def __init__(self, target: Target, history: int) -> None:
|
|
46
|
+
self.target = target
|
|
47
|
+
self.stats = TargetStats(maxlen=history)
|
|
48
|
+
self.ip: str | None = None
|
|
49
|
+
self.geo: dict | None = None # city / ISP / ASN from GeoIP
|
|
50
|
+
self.worker = None
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def key(self) -> str:
|
|
54
|
+
return self.target.key
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class TargetFormScreen(ModalScreen[Target | None]):
|
|
58
|
+
"""Modal dialog to add or edit a target (fields pre-filled when editing)."""
|
|
59
|
+
|
|
60
|
+
BINDINGS = [Binding("escape", "cancel", "Cancel")]
|
|
61
|
+
|
|
62
|
+
def __init__(self, initial: Target | None = None, title: str = "New target") -> None:
|
|
63
|
+
super().__init__()
|
|
64
|
+
self.initial = initial
|
|
65
|
+
self.title_text = title
|
|
66
|
+
|
|
67
|
+
def compose(self) -> ComposeResult:
|
|
68
|
+
init = self.initial
|
|
69
|
+
ok_label = "Save" if init is not None else "Add"
|
|
70
|
+
with Vertical(id="add-dialog"):
|
|
71
|
+
yield Label(self.title_text, id="add-title")
|
|
72
|
+
yield Input(
|
|
73
|
+
placeholder="Country (e.g. Poland)", id="in-country",
|
|
74
|
+
value=init.country if init else "",
|
|
75
|
+
)
|
|
76
|
+
yield Input(
|
|
77
|
+
placeholder="Flag emoji 🇵🇱 (optional)", id="in-flag",
|
|
78
|
+
value=init.flag if init else "",
|
|
79
|
+
)
|
|
80
|
+
yield Input(
|
|
81
|
+
placeholder="Host or IP (e.g. 1.1.1.1)", id="in-host",
|
|
82
|
+
value=init.host if init else "",
|
|
83
|
+
)
|
|
84
|
+
yield Input(
|
|
85
|
+
placeholder="Port (default 443)", id="in-port",
|
|
86
|
+
value=str(init.port) if init else "443",
|
|
87
|
+
)
|
|
88
|
+
yield Label(
|
|
89
|
+
"Leave country/flag empty to auto-detect (GeoIP).",
|
|
90
|
+
id="add-hint",
|
|
91
|
+
)
|
|
92
|
+
with Horizontal(id="add-buttons"):
|
|
93
|
+
yield Button(ok_label, variant="success", id="add-ok")
|
|
94
|
+
yield Button("Cancel", variant="default", id="add-cancel")
|
|
95
|
+
|
|
96
|
+
def on_mount(self) -> None:
|
|
97
|
+
self.query_one("#in-country", Input).focus()
|
|
98
|
+
|
|
99
|
+
@on(Button.Pressed, "#add-cancel")
|
|
100
|
+
def action_cancel(self) -> None:
|
|
101
|
+
self.dismiss(None)
|
|
102
|
+
|
|
103
|
+
@on(Input.Submitted)
|
|
104
|
+
@on(Button.Pressed, "#add-ok")
|
|
105
|
+
def _submit(self) -> None:
|
|
106
|
+
host = self.query_one("#in-host", Input).value.strip()
|
|
107
|
+
if not host:
|
|
108
|
+
self.query_one("#in-host", Input).focus()
|
|
109
|
+
return
|
|
110
|
+
# empty country/flag are kept empty so the app can auto-detect via GeoIP
|
|
111
|
+
country = self.query_one("#in-country", Input).value.strip()
|
|
112
|
+
flag = self.query_one("#in-flag", Input).value.strip()
|
|
113
|
+
port_raw = self.query_one("#in-port", Input).value.strip() or "443"
|
|
114
|
+
try:
|
|
115
|
+
port = int(port_raw)
|
|
116
|
+
except ValueError:
|
|
117
|
+
port = 443
|
|
118
|
+
self.dismiss(Target(country=country, flag=flag, host=host, port=port, source="user"))
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _score_bar(value: float | None, width: int = 12) -> Text:
|
|
122
|
+
"""0–100 score rendered as a coloured bar."""
|
|
123
|
+
if value is None:
|
|
124
|
+
return Text("·" * width, style="#3a3a4a")
|
|
125
|
+
filled = int(round(value / 100.0 * width))
|
|
126
|
+
if value >= 70:
|
|
127
|
+
color = "#3ddc84"
|
|
128
|
+
elif value >= 50:
|
|
129
|
+
color = "#f4bf4f"
|
|
130
|
+
elif value >= 30:
|
|
131
|
+
color = "#fd8d3c"
|
|
132
|
+
else:
|
|
133
|
+
color = "#ff3860"
|
|
134
|
+
bar = Text("█" * filled, style=color)
|
|
135
|
+
bar.append("░" * (width - filled), style="#2a2a38")
|
|
136
|
+
return bar
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class AdvisorScreen(ModalScreen):
|
|
140
|
+
"""Region Advisor: ranks regions by a composite score for the chosen profile."""
|
|
141
|
+
|
|
142
|
+
BINDINGS = [
|
|
143
|
+
Binding("escape,g", "close", "Close"),
|
|
144
|
+
Binding("left,h,[", "prev_profile", "Prev profile"),
|
|
145
|
+
Binding("right,l,],p,tab", "next_profile", "Next profile"),
|
|
146
|
+
]
|
|
147
|
+
|
|
148
|
+
def compose(self) -> ComposeResult:
|
|
149
|
+
with Vertical(id="advisor-dialog"):
|
|
150
|
+
yield Static(id="advisor-head")
|
|
151
|
+
yield DataTable(id="advisor-table", cursor_type="none", zebra_stripes=True)
|
|
152
|
+
yield Static(id="advisor-foot")
|
|
153
|
+
|
|
154
|
+
def on_mount(self) -> None:
|
|
155
|
+
t = self.query_one("#advisor-table", DataTable)
|
|
156
|
+
t.add_column("#", width=2)
|
|
157
|
+
t.add_column("Region", width=20)
|
|
158
|
+
t.add_column("Best endpoint", width=30)
|
|
159
|
+
t.add_column("Score", width=16)
|
|
160
|
+
t.add_column("Gr", width=2)
|
|
161
|
+
t.add_column("Why", width=42)
|
|
162
|
+
self._repopulate()
|
|
163
|
+
self.set_interval(1.0, self._repopulate)
|
|
164
|
+
|
|
165
|
+
def _repopulate(self) -> None:
|
|
166
|
+
app = self.app
|
|
167
|
+
profile = app.profile
|
|
168
|
+
label, desc = PROFILE_META[profile]
|
|
169
|
+
|
|
170
|
+
head = Text()
|
|
171
|
+
head.append("★ Region Advisor ", style="bold #7aa2f7")
|
|
172
|
+
head.append("profile: ", style="#7a7a8c")
|
|
173
|
+
head.append(label, style="bold #c9a0dc")
|
|
174
|
+
head.append(f" · {desc}", style="#7a7a8c")
|
|
175
|
+
self.query_one("#advisor-head", Static).update(head)
|
|
176
|
+
|
|
177
|
+
# best endpoint per region (country)
|
|
178
|
+
by_country: dict[str, tuple] = {}
|
|
179
|
+
for mon in app.monitors:
|
|
180
|
+
sc = score(mon.stats, profile)
|
|
181
|
+
c = mon.target.country
|
|
182
|
+
cur = by_country.get(c)
|
|
183
|
+
if cur is None or (sc or -1.0) > (cur[1] if cur[1] is not None else -1.0):
|
|
184
|
+
by_country[c] = (mon, sc)
|
|
185
|
+
|
|
186
|
+
ranked = sorted(
|
|
187
|
+
by_country.values(),
|
|
188
|
+
key=lambda t: (t[1] is None, -(t[1] or 0.0)),
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
table = self.query_one("#advisor-table", DataTable)
|
|
192
|
+
table.clear()
|
|
193
|
+
for i, (mon, sc) in enumerate(ranked, start=1):
|
|
194
|
+
rank_style = "bold #3ddc84" if i == 1 else "#9a9ab0"
|
|
195
|
+
rank = Text(str(i), style=rank_style)
|
|
196
|
+
flag = mon.target.flag
|
|
197
|
+
name = Text.assemble((f"{flag} ", ""), (mon.target.country, "bold"))
|
|
198
|
+
if i == 1:
|
|
199
|
+
name.append(" ★", style="bold #3ddc84")
|
|
200
|
+
host = Text(mon.target.host, style="#9a9ab0")
|
|
201
|
+
bar = _score_bar(sc, width=10)
|
|
202
|
+
sc_txt = Text(f" {sc:3.0f}" if sc is not None else " —", style="bold #e0e0f0")
|
|
203
|
+
bar.append_text(sc_txt)
|
|
204
|
+
g = grade(sc)
|
|
205
|
+
gcolor = {"A": "#3ddc84", "B": "#a6e22e", "C": "#f4bf4f", "D": "#fd8d3c"}.get(g, "#ff3860")
|
|
206
|
+
why = Text(reason(mon.stats, profile), style="#7a7a8c")
|
|
207
|
+
table.add_row(rank, name, host, bar, Text(g, style=f"bold {gcolor}"), why)
|
|
208
|
+
|
|
209
|
+
foot = Text(
|
|
210
|
+
"[ ] or p / Tab — switch profile · Esc — close",
|
|
211
|
+
style="#6a6a7c",
|
|
212
|
+
)
|
|
213
|
+
self.query_one("#advisor-foot", Static).update(foot)
|
|
214
|
+
|
|
215
|
+
def action_close(self) -> None:
|
|
216
|
+
self.dismiss(None)
|
|
217
|
+
|
|
218
|
+
def action_prev_profile(self) -> None:
|
|
219
|
+
i = PROFILES.index(self.app.profile)
|
|
220
|
+
self.app.profile = PROFILES[(i - 1) % len(PROFILES)]
|
|
221
|
+
self._repopulate()
|
|
222
|
+
|
|
223
|
+
def action_next_profile(self) -> None:
|
|
224
|
+
i = PROFILES.index(self.app.profile)
|
|
225
|
+
self.app.profile = PROFILES[(i + 1) % len(PROFILES)]
|
|
226
|
+
self._repopulate()
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
class TracerouteScreen(ModalScreen):
|
|
230
|
+
"""mtr-style hop-by-hop path to a single target."""
|
|
231
|
+
|
|
232
|
+
BINDINGS = [Binding("escape,q,enter", "close", "Close")]
|
|
233
|
+
|
|
234
|
+
def __init__(self, target: Target) -> None:
|
|
235
|
+
super().__init__()
|
|
236
|
+
self.target = target
|
|
237
|
+
|
|
238
|
+
def compose(self) -> ComposeResult:
|
|
239
|
+
with Vertical(id="trace-dialog"):
|
|
240
|
+
yield Static(id="trace-head")
|
|
241
|
+
yield DataTable(id="trace-table", cursor_type="row", zebra_stripes=True)
|
|
242
|
+
yield Static(id="trace-foot")
|
|
243
|
+
|
|
244
|
+
def on_mount(self) -> None:
|
|
245
|
+
head = Text()
|
|
246
|
+
head.append("⇄ Traceroute ", style="bold #7aa2f7")
|
|
247
|
+
head.append(f"{self.target.flag} {self.target.country} ", style="bold")
|
|
248
|
+
head.append(self.target.host, style="#9a9ab0")
|
|
249
|
+
self.query_one("#trace-head", Static).update(head)
|
|
250
|
+
|
|
251
|
+
t = self.query_one("#trace-table", DataTable)
|
|
252
|
+
t.add_column("Hop", key="hop", width=3)
|
|
253
|
+
t.add_column("Host / IP", key="host", width=22)
|
|
254
|
+
t.add_column("Loss", key="loss", width=5)
|
|
255
|
+
t.add_column("Probes (ms)", key="probes", width=24)
|
|
256
|
+
t.add_column("Best", key="best", width=5)
|
|
257
|
+
t.add_column("Location", key="loc", width=34)
|
|
258
|
+
self.query_one("#trace-foot", Static).update(
|
|
259
|
+
Text("running… · Esc close", style="#6a6a7c")
|
|
260
|
+
)
|
|
261
|
+
self.run_worker(self._run(), exclusive=True)
|
|
262
|
+
|
|
263
|
+
def _initial_loc(self, ip: str) -> Text:
|
|
264
|
+
if ip == "*":
|
|
265
|
+
return Text("—", style="#3a3a4a")
|
|
266
|
+
if is_global_ip(ip):
|
|
267
|
+
return Text("…", style="#6a6a7c")
|
|
268
|
+
return Text("private network", style="#6a6a7c")
|
|
269
|
+
|
|
270
|
+
async def _run(self) -> None:
|
|
271
|
+
table = self.query_one("#trace-table", DataTable)
|
|
272
|
+
async for hop in traceroute(self.target.host, max_hops=24, queries=3):
|
|
273
|
+
if "error" in hop:
|
|
274
|
+
self.query_one("#trace-foot", Static).update(
|
|
275
|
+
Text(hop["error"], style="bold #ff3860")
|
|
276
|
+
)
|
|
277
|
+
return
|
|
278
|
+
ok = [t for t in hop["times"] if t is not None]
|
|
279
|
+
best = min(ok) if ok else None
|
|
280
|
+
probes = Text()
|
|
281
|
+
for t in hop["times"]:
|
|
282
|
+
if t is None:
|
|
283
|
+
probes.append("* ", style="bold #ff3860")
|
|
284
|
+
else:
|
|
285
|
+
probes.append(f"{t:.1f} ", style=latency_color(t))
|
|
286
|
+
loss = hop["loss"]
|
|
287
|
+
loss_txt = (
|
|
288
|
+
Text("0%", style="#3ddc84")
|
|
289
|
+
if loss <= 0
|
|
290
|
+
else Text(f"{loss:.0f}%", style="bold #ff3860")
|
|
291
|
+
)
|
|
292
|
+
ip = hop["host"]
|
|
293
|
+
host_style = "#9a9ab0" if ip != "*" else "#ff3860"
|
|
294
|
+
rowkey = f"hop-{hop['hop']}"
|
|
295
|
+
table.add_row(
|
|
296
|
+
Text(str(hop["hop"]), style="#7a7a8c"),
|
|
297
|
+
Text(ip, style=host_style),
|
|
298
|
+
loss_txt,
|
|
299
|
+
probes,
|
|
300
|
+
latency_text(best),
|
|
301
|
+
self._initial_loc(ip),
|
|
302
|
+
key=rowkey,
|
|
303
|
+
)
|
|
304
|
+
if is_global_ip(ip):
|
|
305
|
+
self.run_worker(self._geo_hop(rowkey, ip), group="trace-geo")
|
|
306
|
+
self.query_one("#trace-foot", Static).update(
|
|
307
|
+
Text("done · Esc close", style="#6a6a7c")
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
async def _geo_hop(self, rowkey: str, ip: str) -> None:
|
|
311
|
+
data = await geo_lookup(ip)
|
|
312
|
+
if not data:
|
|
313
|
+
return
|
|
314
|
+
flag = flag_emoji(data.get("countryCode"))
|
|
315
|
+
place = data.get("city") or data.get("country") or ip
|
|
316
|
+
asn = (data.get("as") or "").split(" ", 1)[0]
|
|
317
|
+
txt = Text(f"{flag} {place}", style="#9a9ab0")
|
|
318
|
+
if asn.startswith("AS"):
|
|
319
|
+
txt.append(f" {asn}", style="#7a7a8c")
|
|
320
|
+
try:
|
|
321
|
+
self.query_one("#trace-table", DataTable).update_cell(
|
|
322
|
+
rowkey, "loc", txt, update_width=False
|
|
323
|
+
)
|
|
324
|
+
except Exception:
|
|
325
|
+
pass
|
|
326
|
+
|
|
327
|
+
def action_close(self) -> None:
|
|
328
|
+
self.dismiss(None)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
class PingMonApp(App):
|
|
332
|
+
CSS_PATH = "app.tcss"
|
|
333
|
+
TITLE = "pingmon"
|
|
334
|
+
SUB_TITLE = "availability monitor by country"
|
|
335
|
+
|
|
336
|
+
BINDINGS = [
|
|
337
|
+
Binding("q", "quit", "Quit"),
|
|
338
|
+
Binding("space", "toggle_pause", "Pause"),
|
|
339
|
+
Binding("m,M", "sort_latency", "By ms"),
|
|
340
|
+
Binding("s", "cycle_sort", "Sort"),
|
|
341
|
+
Binding("g", "advisor", "Advisor"),
|
|
342
|
+
Binding("p", "cycle_profile", "Profile"),
|
|
343
|
+
Binding("f", "cycle_filter", "Filter"),
|
|
344
|
+
Binding("enter", "traceroute", "Trace"),
|
|
345
|
+
Binding("a", "add_target", "Add"),
|
|
346
|
+
Binding("A", "toggle_alerts", "Alerts on/off"),
|
|
347
|
+
Binding("E", "edit_target", "Edit"),
|
|
348
|
+
Binding("d", "delete_target", "Delete"),
|
|
349
|
+
Binding("r", "reset_stats", "Reset"),
|
|
350
|
+
Binding("e", "open_config", "Config"),
|
|
351
|
+
Binding("up,k", "cursor_up", "Up", show=False),
|
|
352
|
+
Binding("down,j", "cursor_down", "Down", show=False),
|
|
353
|
+
]
|
|
354
|
+
|
|
355
|
+
SORT_MODES = ["country", "latency", "loss", "jitter"]
|
|
356
|
+
SORT_LABELS = {
|
|
357
|
+
"country": "by country",
|
|
358
|
+
"latency": "by latency",
|
|
359
|
+
"loss": "by loss",
|
|
360
|
+
"jitter": "by jitter",
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
def __init__(self) -> None:
|
|
364
|
+
super().__init__()
|
|
365
|
+
self.cfg: Config = load_config()
|
|
366
|
+
self.monitors: list[Monitor] = [
|
|
367
|
+
Monitor(t, self.cfg.history) for t in self.cfg.targets
|
|
368
|
+
]
|
|
369
|
+
self.order: list[Monitor] = list(self.monitors)
|
|
370
|
+
self.paused = False
|
|
371
|
+
self.sort_mode = "country"
|
|
372
|
+
self.latency_desc = False # False = fastest first, True = slowest first
|
|
373
|
+
self.profile = "voip" # Region Advisor use-case profile
|
|
374
|
+
self.filter_mode = "all" # all | mine | others
|
|
375
|
+
self.frame = 0
|
|
376
|
+
self.selected: Monitor | None = None
|
|
377
|
+
self.alerting: set[str] = set() # keys of targets currently in alert
|
|
378
|
+
self.alerts_enabled = True # master switch for the alert system
|
|
379
|
+
|
|
380
|
+
# ---------- layout ----------
|
|
381
|
+
|
|
382
|
+
def compose(self) -> ComposeResult:
|
|
383
|
+
yield Static(id="banner")
|
|
384
|
+
with Horizontal(id="body"):
|
|
385
|
+
with Vertical(id="left"):
|
|
386
|
+
yield DataTable(id="table", zebra_stripes=True, cursor_type="row")
|
|
387
|
+
with VerticalScroll(id="detail"):
|
|
388
|
+
yield Static(id="detail-title")
|
|
389
|
+
yield Static(id="detail-meta")
|
|
390
|
+
yield Label("Latency · recent samples", classes="detail-h")
|
|
391
|
+
yield Sparkline([0], id="detail-spark", summary_function=max)
|
|
392
|
+
yield Static(id="detail-quality")
|
|
393
|
+
yield Label("Distribution · min — median — max", classes="detail-h")
|
|
394
|
+
yield Static(id="detail-dist")
|
|
395
|
+
yield Static(id="detail-distsum")
|
|
396
|
+
yield Static(id="detail-stats")
|
|
397
|
+
yield Static(id="detail-hint")
|
|
398
|
+
yield Footer()
|
|
399
|
+
|
|
400
|
+
def on_mount(self) -> None:
|
|
401
|
+
table = self.query_one("#table", DataTable)
|
|
402
|
+
table.add_column("", key="status", width=2)
|
|
403
|
+
table.add_column("Country", key="country", width=16)
|
|
404
|
+
table.add_column("Host", key="host", width=26)
|
|
405
|
+
table.add_column(Text("ms", justify="right"), key="last", width=5)
|
|
406
|
+
table.add_column(Text("avg", justify="right"), key="avg", width=5)
|
|
407
|
+
table.add_column(Text("loss", justify="right"), key="loss", width=5)
|
|
408
|
+
table.add_column("trend", key="trend", width=20)
|
|
409
|
+
|
|
410
|
+
for mon in self.order:
|
|
411
|
+
table.add_row(*self._row_cells(mon), key=mon.key)
|
|
412
|
+
|
|
413
|
+
for i, mon in enumerate(self.monitors):
|
|
414
|
+
delay = (self.cfg.interval / max(1, len(self.monitors))) * i
|
|
415
|
+
mon.worker = self.run_worker(
|
|
416
|
+
self._ping_loop(mon, delay), name=mon.key, group="ping"
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
if self.order:
|
|
420
|
+
self.selected = self.order[0]
|
|
421
|
+
self.set_interval(0.2, self._tick)
|
|
422
|
+
self._tick()
|
|
423
|
+
|
|
424
|
+
# ---------- ping engine ----------
|
|
425
|
+
|
|
426
|
+
async def _ping_loop(self, mon: Monitor, delay: float) -> None:
|
|
427
|
+
await asyncio.sleep(delay)
|
|
428
|
+
mon.ip = await resolve(mon.target.host)
|
|
429
|
+
if mon.geo is None:
|
|
430
|
+
mon.geo = await geo_lookup(mon.target.host)
|
|
431
|
+
if mon.geo and not mon.ip:
|
|
432
|
+
mon.ip = mon.geo.get("query")
|
|
433
|
+
while True:
|
|
434
|
+
if not self.paused:
|
|
435
|
+
lat = await tcp_ping(
|
|
436
|
+
mon.target.host, mon.target.port, self.cfg.timeout
|
|
437
|
+
)
|
|
438
|
+
mon.stats.record(lat)
|
|
439
|
+
await asyncio.sleep(self.cfg.interval)
|
|
440
|
+
|
|
441
|
+
# ---------- row rendering ----------
|
|
442
|
+
|
|
443
|
+
def _row_cells(self, mon: Monitor) -> list:
|
|
444
|
+
st = mon.stats
|
|
445
|
+
name = Text.assemble((f"{mon.target.flag} ", ""), (mon.target.country, "bold"))
|
|
446
|
+
glyph_color = STATUS_META[st.status][1]
|
|
447
|
+
glyph_style = f"bold {glyph_color}"
|
|
448
|
+
if mon.key in self.alerting:
|
|
449
|
+
glyph_style = f"blink bold {glyph_color}"
|
|
450
|
+
glyph = Text(STATUS_META[st.status][2], style=glyph_style)
|
|
451
|
+
return [
|
|
452
|
+
glyph,
|
|
453
|
+
name,
|
|
454
|
+
Text(mon.target.host, style="#9a9ab0"),
|
|
455
|
+
latency_text(st.last),
|
|
456
|
+
latency_text(st.avg),
|
|
457
|
+
loss_text(st.loss),
|
|
458
|
+
sparkline(st.samples, width=20),
|
|
459
|
+
]
|
|
460
|
+
|
|
461
|
+
def _update_row(self, mon: Monitor) -> None:
|
|
462
|
+
table = self.query_one("#table", DataTable)
|
|
463
|
+
cells = self._row_cells(mon)
|
|
464
|
+
keys = ["status", "country", "host", "last", "avg", "loss", "trend"]
|
|
465
|
+
for col, val in zip(keys, cells):
|
|
466
|
+
try:
|
|
467
|
+
table.update_cell(mon.key, col, val, update_width=False)
|
|
468
|
+
except Exception:
|
|
469
|
+
pass
|
|
470
|
+
|
|
471
|
+
# ---------- periodic tick ----------
|
|
472
|
+
|
|
473
|
+
def _tick(self) -> None:
|
|
474
|
+
self.frame += 1
|
|
475
|
+
self._check_alerts()
|
|
476
|
+
for mon in self.monitors:
|
|
477
|
+
self._update_row(mon)
|
|
478
|
+
self._update_banner()
|
|
479
|
+
self._update_detail()
|
|
480
|
+
|
|
481
|
+
# ---------- alerts ----------
|
|
482
|
+
|
|
483
|
+
def _is_bad(self, mon: Monitor) -> bool:
|
|
484
|
+
st = mon.stats
|
|
485
|
+
cfg = self.cfg
|
|
486
|
+
if st.consecutive_fail >= cfg.alert_window:
|
|
487
|
+
return True
|
|
488
|
+
recent = list(st.samples)[-max(cfg.alert_window, 10):]
|
|
489
|
+
if cfg.alert_latency > 0 and len(recent) >= cfg.alert_window:
|
|
490
|
+
tail = recent[-cfg.alert_window:]
|
|
491
|
+
if all((s is None or s > cfg.alert_latency) for s in tail):
|
|
492
|
+
return True
|
|
493
|
+
if cfg.alert_loss > 0 and len(recent) >= cfg.alert_window:
|
|
494
|
+
rloss = 100.0 * sum(1 for s in recent if s is None) / len(recent)
|
|
495
|
+
if rloss >= cfg.alert_loss:
|
|
496
|
+
return True
|
|
497
|
+
return False
|
|
498
|
+
|
|
499
|
+
def _check_alerts(self) -> None:
|
|
500
|
+
if not self.alerts_enabled:
|
|
501
|
+
if self.alerting:
|
|
502
|
+
self.alerting.clear()
|
|
503
|
+
return
|
|
504
|
+
for mon in self.monitors:
|
|
505
|
+
key = mon.key
|
|
506
|
+
bad = self._is_bad(mon)
|
|
507
|
+
if bad and key not in self.alerting:
|
|
508
|
+
self.alerting.add(key)
|
|
509
|
+
self._raise_alert(mon)
|
|
510
|
+
elif not bad and key in self.alerting:
|
|
511
|
+
self.alerting.discard(key)
|
|
512
|
+
self.notify(
|
|
513
|
+
f"Recovered · {mon.target.country} ({mon.target.host})",
|
|
514
|
+
timeout=3,
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
def _raise_alert(self, mon: Monitor) -> None:
|
|
518
|
+
st = mon.stats
|
|
519
|
+
if st.status == "DOWN" or st.last is None:
|
|
520
|
+
detail = "unreachable"
|
|
521
|
+
else:
|
|
522
|
+
detail = f"{st.last:.0f}ms · loss {st.loss:.0f}%"
|
|
523
|
+
self.bell()
|
|
524
|
+
self.notify(
|
|
525
|
+
f"ALERT · {mon.target.country} ({mon.target.host}) — {detail}",
|
|
526
|
+
severity="error",
|
|
527
|
+
timeout=6,
|
|
528
|
+
)
|
|
529
|
+
if self.cfg.desktop_notify:
|
|
530
|
+
self.run_worker(
|
|
531
|
+
desktop_notify(
|
|
532
|
+
"pingmon alert",
|
|
533
|
+
f"{mon.target.country} ({mon.target.host}) — {detail}",
|
|
534
|
+
),
|
|
535
|
+
group="notify",
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
def _update_banner(self) -> None:
|
|
539
|
+
total = len(self.monitors)
|
|
540
|
+
up = sum(1 for m in self.monitors if m.stats.status not in ("DOWN", "PENDING"))
|
|
541
|
+
down = sum(1 for m in self.monitors if m.stats.status == "DOWN")
|
|
542
|
+
oks = [m.stats.last for m in self.monitors if m.stats.last is not None]
|
|
543
|
+
best = min(oks) if oks else None
|
|
544
|
+
avg = sum(oks) / len(oks) if oks else None
|
|
545
|
+
|
|
546
|
+
spin = SPINNER[self.frame % len(SPINNER)]
|
|
547
|
+
live = Text(
|
|
548
|
+
f" {spin} PAUSED " if self.paused else f" {spin} LIVE ",
|
|
549
|
+
style="bold #1a1a24 on #f4bf4f" if self.paused else "bold #1a1a24 on #3ddc84",
|
|
550
|
+
)
|
|
551
|
+
|
|
552
|
+
b = Text()
|
|
553
|
+
b.append(" ⚡ pingmon ", style="bold #1a1a24 on #7aa2f7")
|
|
554
|
+
b.append(" ")
|
|
555
|
+
b.append_text(live)
|
|
556
|
+
b.append(" online ", style="#7a7a8c")
|
|
557
|
+
b.append(f"{up}/{total}", style="bold #3ddc84")
|
|
558
|
+
b.append(" · down ", style="#7a7a8c")
|
|
559
|
+
b.append(str(down), style="bold #ff3860" if down else "bold #3ddc84")
|
|
560
|
+
b.append(" · best ", style="#7a7a8c")
|
|
561
|
+
b.append_text(latency_text(best, "ms"))
|
|
562
|
+
b.append(" · avg ", style="#7a7a8c")
|
|
563
|
+
b.append_text(latency_text(avg, "ms"))
|
|
564
|
+
b.append(" · sort ", style="#7a7a8c")
|
|
565
|
+
sort_label = self.SORT_LABELS[self.sort_mode]
|
|
566
|
+
if self.sort_mode == "latency":
|
|
567
|
+
sort_label += " ↑" if not self.latency_desc else " ↓"
|
|
568
|
+
b.append(sort_label, style="#c9a0dc")
|
|
569
|
+
|
|
570
|
+
if self.filter_mode != "all":
|
|
571
|
+
b.append(" · filter ", style="#7a7a8c")
|
|
572
|
+
b.append(self.FILTER_LABELS[self.filter_mode], style="bold #7aa2f7")
|
|
573
|
+
|
|
574
|
+
if not self.alerts_enabled:
|
|
575
|
+
b.append(" · ⚲ alerts off", style="bold #7a7a8c")
|
|
576
|
+
elif self.alerting:
|
|
577
|
+
b.append(f" · ⚠ {len(self.alerting)} alert", style="bold #ff3860")
|
|
578
|
+
|
|
579
|
+
best, best_score = self._best_for_profile()
|
|
580
|
+
if best is not None:
|
|
581
|
+
b.append(" · ★ ", style="#7a7a8c")
|
|
582
|
+
b.append(PROFILE_META[self.profile][0], style="#7a7a8c")
|
|
583
|
+
b.append(f": {best.target.flag} {best.target.country}", style="bold #3ddc84")
|
|
584
|
+
b.append(" · " + datetime.now().strftime("%H:%M:%S"), style="#7a7a8c")
|
|
585
|
+
self.query_one("#banner", Static).update(b)
|
|
586
|
+
|
|
587
|
+
def _best_for_profile(self) -> tuple[Monitor | None, float]:
|
|
588
|
+
best: Monitor | None = None
|
|
589
|
+
best_score = -1.0
|
|
590
|
+
for mon in self.monitors:
|
|
591
|
+
sc = score(mon.stats, self.profile)
|
|
592
|
+
if sc is None:
|
|
593
|
+
continue
|
|
594
|
+
if sc > best_score:
|
|
595
|
+
best_score = sc
|
|
596
|
+
best = mon
|
|
597
|
+
return best, best_score
|
|
598
|
+
|
|
599
|
+
def _update_detail(self) -> None:
|
|
600
|
+
mon = self.selected
|
|
601
|
+
if mon is None:
|
|
602
|
+
return
|
|
603
|
+
st = mon.stats
|
|
604
|
+
|
|
605
|
+
title = Text()
|
|
606
|
+
title.append(f"{mon.target.flag} ")
|
|
607
|
+
title.append(mon.target.country, style="bold #e0e0f0")
|
|
608
|
+
title.append(" ")
|
|
609
|
+
label, color, glyph = STATUS_META[st.status]
|
|
610
|
+
title.append(f"{glyph} {label}", style=f"bold {color}")
|
|
611
|
+
self.query_one("#detail-title", Static).update(title)
|
|
612
|
+
|
|
613
|
+
meta = Text()
|
|
614
|
+
meta.append("host ", style="#7a7a8c")
|
|
615
|
+
meta.append(f"{mon.target.host}:{mon.target.port}\n", style="#9a9ab0")
|
|
616
|
+
meta.append("addr ", style="#7a7a8c")
|
|
617
|
+
meta.append(f"{mon.ip or '…'}", style="#9a9ab0")
|
|
618
|
+
if mon.geo:
|
|
619
|
+
city = mon.geo.get("city")
|
|
620
|
+
region = mon.geo.get("regionName")
|
|
621
|
+
loc = ", ".join(p for p in (city, region) if p)
|
|
622
|
+
if loc:
|
|
623
|
+
meta.append("\nloc ", style="#7a7a8c")
|
|
624
|
+
meta.append(loc, style="#9a9ab0")
|
|
625
|
+
net = mon.geo.get("as") or mon.geo.get("isp")
|
|
626
|
+
if net:
|
|
627
|
+
meta.append("\nnet ", style="#7a7a8c")
|
|
628
|
+
meta.append(net, style="#9a9ab0")
|
|
629
|
+
self.query_one("#detail-meta", Static).update(meta)
|
|
630
|
+
|
|
631
|
+
spark = self.query_one("#detail-spark", Sparkline)
|
|
632
|
+
ok = st.ok_values
|
|
633
|
+
spark.data = ok[-60:] if ok else [0]
|
|
634
|
+
|
|
635
|
+
q = Text("quality ", style="#7a7a8c")
|
|
636
|
+
q.append_text(quality_bar(st.last, width=24))
|
|
637
|
+
self.query_one("#detail-quality", Static).update(q)
|
|
638
|
+
|
|
639
|
+
self.query_one("#detail-dist", Static).update(distribution_strip(st.samples, width=34))
|
|
640
|
+
self.query_one("#detail-distsum", Static).update(self._dist_summary(st))
|
|
641
|
+
|
|
642
|
+
self.query_one("#detail-stats", Static).update(self._stats_block(st))
|
|
643
|
+
|
|
644
|
+
hint = Text(
|
|
645
|
+
"[g] advisor [p] profile [f] filter [enter] traceroute [m] by ms [space] pause\n"
|
|
646
|
+
"[A] alerts on/off [s] sort [a] add [E] edit [d] delete [r] reset [e] config [q] quit",
|
|
647
|
+
style="#6a6a7c",
|
|
648
|
+
)
|
|
649
|
+
self.query_one("#detail-hint", Static).update(hint)
|
|
650
|
+
|
|
651
|
+
def _dist_summary(self, st: TargetStats) -> Text:
|
|
652
|
+
out = Text()
|
|
653
|
+
for label, val in (
|
|
654
|
+
("min", st.vmin),
|
|
655
|
+
("med", st.median),
|
|
656
|
+
("p90", st.percentile(90.0)),
|
|
657
|
+
("max", st.vmax),
|
|
658
|
+
):
|
|
659
|
+
out.append(f"{label} ", style="#7a7a8c")
|
|
660
|
+
out.append_text(latency_text(val))
|
|
661
|
+
out.append(" ")
|
|
662
|
+
return out
|
|
663
|
+
|
|
664
|
+
def _stats_block(self, st: TargetStats) -> Text:
|
|
665
|
+
def row(name: str, value: Text) -> Text:
|
|
666
|
+
t = Text(f"{name:<9}", style="#7a7a8c")
|
|
667
|
+
t.append_text(value)
|
|
668
|
+
t.append("\n")
|
|
669
|
+
return t
|
|
670
|
+
|
|
671
|
+
out = Text()
|
|
672
|
+
out.append_text(row("current", latency_text(st.last, " ms")))
|
|
673
|
+
out.append_text(row("min", latency_text(st.vmin, " ms")))
|
|
674
|
+
out.append_text(row("avg", latency_text(st.avg, " ms")))
|
|
675
|
+
out.append_text(row("max", latency_text(st.vmax, " ms")))
|
|
676
|
+
jit = (
|
|
677
|
+
Text(f"{st.jitter:.0f} ms", style="#c9a0dc")
|
|
678
|
+
if st.jitter is not None
|
|
679
|
+
else Text("—", style="#555566")
|
|
680
|
+
)
|
|
681
|
+
out.append_text(row("jitter", jit))
|
|
682
|
+
out.append_text(row("loss", loss_text(st.loss)))
|
|
683
|
+
mos = st.mos
|
|
684
|
+
if mos is None:
|
|
685
|
+
mos_txt = Text("—", style="#555566")
|
|
686
|
+
else:
|
|
687
|
+
mcolor = (
|
|
688
|
+
"#3ddc84" if mos >= 4.0
|
|
689
|
+
else "#a6e22e" if mos >= 3.6
|
|
690
|
+
else "#f4bf4f" if mos >= 3.0
|
|
691
|
+
else "#ff3860"
|
|
692
|
+
)
|
|
693
|
+
mos_txt = Text(f"{mos:.2f} / 4.5", style=f"bold {mcolor}")
|
|
694
|
+
out.append_text(row("MOS", mos_txt))
|
|
695
|
+
out.append_text(
|
|
696
|
+
row("samples", Text(f"{st.sent} (lost {st.lost})", style="#9a9ab0"))
|
|
697
|
+
)
|
|
698
|
+
return out
|
|
699
|
+
|
|
700
|
+
# ---------- selection ----------
|
|
701
|
+
|
|
702
|
+
@on(DataTable.RowHighlighted)
|
|
703
|
+
def _on_highlight(self, event: DataTable.RowHighlighted) -> None:
|
|
704
|
+
idx = event.cursor_row
|
|
705
|
+
if 0 <= idx < len(self.order):
|
|
706
|
+
self.selected = self.order[idx]
|
|
707
|
+
self._update_detail()
|
|
708
|
+
|
|
709
|
+
@on(DataTable.RowSelected)
|
|
710
|
+
def _on_row_selected(self, event: DataTable.RowSelected) -> None:
|
|
711
|
+
# Enter (or click) on a row opens the traceroute drill-down.
|
|
712
|
+
idx = event.cursor_row
|
|
713
|
+
if 0 <= idx < len(self.order):
|
|
714
|
+
self.selected = self.order[idx]
|
|
715
|
+
self.push_screen(TracerouteScreen(self.selected.target))
|
|
716
|
+
|
|
717
|
+
# ---------- actions ----------
|
|
718
|
+
|
|
719
|
+
def action_toggle_pause(self) -> None:
|
|
720
|
+
self.paused = not self.paused
|
|
721
|
+
self.notify("Paused" if self.paused else "Resumed", timeout=1.5)
|
|
722
|
+
|
|
723
|
+
def action_toggle_alerts(self) -> None:
|
|
724
|
+
self.alerts_enabled = not self.alerts_enabled
|
|
725
|
+
if not self.alerts_enabled:
|
|
726
|
+
self.alerting.clear()
|
|
727
|
+
self.notify(
|
|
728
|
+
"Alerts enabled" if self.alerts_enabled else "Alerts disabled",
|
|
729
|
+
timeout=1.5,
|
|
730
|
+
)
|
|
731
|
+
self._tick()
|
|
732
|
+
|
|
733
|
+
def action_cycle_sort(self) -> None:
|
|
734
|
+
i = self.SORT_MODES.index(self.sort_mode)
|
|
735
|
+
self.sort_mode = self.SORT_MODES[(i + 1) % len(self.SORT_MODES)]
|
|
736
|
+
self._resort()
|
|
737
|
+
self.notify(f"Sort: {self.SORT_LABELS[self.sort_mode]}", timeout=1.5)
|
|
738
|
+
|
|
739
|
+
def action_sort_latency(self) -> None:
|
|
740
|
+
# press once -> fastest first; press again -> slowest first
|
|
741
|
+
if self.sort_mode == "latency":
|
|
742
|
+
self.latency_desc = not self.latency_desc
|
|
743
|
+
else:
|
|
744
|
+
self.sort_mode = "latency"
|
|
745
|
+
self.latency_desc = False
|
|
746
|
+
self._resort()
|
|
747
|
+
which = "slowest first" if self.latency_desc else "fastest first"
|
|
748
|
+
self.notify(f"Sort: latency · {which}", timeout=1.5)
|
|
749
|
+
|
|
750
|
+
def _sort_key(self, mon: Monitor):
|
|
751
|
+
st = mon.stats
|
|
752
|
+
big = float("inf")
|
|
753
|
+
last = st.last
|
|
754
|
+
if self.sort_mode == "country":
|
|
755
|
+
return (mon.target.country, mon.target.host)
|
|
756
|
+
if self.sort_mode == "latency":
|
|
757
|
+
if not self.latency_desc:
|
|
758
|
+
# fastest first; unreachable sinks to the bottom
|
|
759
|
+
return (1 if last is None else 0, last if last is not None else 0.0, mon.target.host)
|
|
760
|
+
# slowest first; unreachable rises to the top (worst case)
|
|
761
|
+
return (0 if last is None else 1, -(last or 0.0), mon.target.host)
|
|
762
|
+
if self.sort_mode == "loss":
|
|
763
|
+
return (-st.loss, last if last is not None else big)
|
|
764
|
+
if self.sort_mode == "jitter":
|
|
765
|
+
return (-(st.jitter or 0.0),)
|
|
766
|
+
return (mon.target.country,)
|
|
767
|
+
|
|
768
|
+
def _passes_filter(self, mon: Monitor) -> bool:
|
|
769
|
+
if self.filter_mode == "mine":
|
|
770
|
+
return mon.target.source == "user"
|
|
771
|
+
if self.filter_mode == "others":
|
|
772
|
+
return mon.target.source != "user"
|
|
773
|
+
return True
|
|
774
|
+
|
|
775
|
+
def _resort(self) -> None:
|
|
776
|
+
table = self.query_one("#table", DataTable)
|
|
777
|
+
ordered = sorted(self.monitors, key=self._sort_key)
|
|
778
|
+
self.order = [m for m in ordered if self._passes_filter(m)]
|
|
779
|
+
sel_key = self.selected.key if self.selected else None
|
|
780
|
+
table.clear()
|
|
781
|
+
for mon in self.order:
|
|
782
|
+
table.add_row(*self._row_cells(mon), key=mon.key)
|
|
783
|
+
# keep a valid selection within the visible rows
|
|
784
|
+
if self.order and (self.selected not in self.order):
|
|
785
|
+
self.selected = self.order[0]
|
|
786
|
+
sel_key = self.selected.key
|
|
787
|
+
if sel_key:
|
|
788
|
+
try:
|
|
789
|
+
table.move_cursor(row=table.get_row_index(sel_key))
|
|
790
|
+
except Exception:
|
|
791
|
+
pass
|
|
792
|
+
|
|
793
|
+
def action_reset_stats(self) -> None:
|
|
794
|
+
for mon in self.monitors:
|
|
795
|
+
mon.stats.reset()
|
|
796
|
+
self.notify("Statistics reset", timeout=1.5)
|
|
797
|
+
self._tick()
|
|
798
|
+
|
|
799
|
+
def action_add_target(self) -> None:
|
|
800
|
+
def handle(target: Target | None) -> None:
|
|
801
|
+
if target is None:
|
|
802
|
+
return
|
|
803
|
+
mon = Monitor(target, self.cfg.history)
|
|
804
|
+
self.monitors.append(mon)
|
|
805
|
+
self.cfg.targets.append(target)
|
|
806
|
+
save_config(self.cfg)
|
|
807
|
+
mon.worker = self.run_worker(
|
|
808
|
+
self._ping_loop(mon, 0.0), name=mon.key, group="ping"
|
|
809
|
+
)
|
|
810
|
+
self._resort()
|
|
811
|
+
self.notify(f"Added: {target.host}", timeout=2)
|
|
812
|
+
self._maybe_autodetect(mon)
|
|
813
|
+
|
|
814
|
+
self.push_screen(TargetFormScreen(title="New target"), handle)
|
|
815
|
+
|
|
816
|
+
def _maybe_autodetect(self, mon: Monitor) -> None:
|
|
817
|
+
"""Fill country/flag from GeoIP when the user left them blank."""
|
|
818
|
+
t = mon.target
|
|
819
|
+
if t.country and t.flag:
|
|
820
|
+
return
|
|
821
|
+
if not t.country:
|
|
822
|
+
t.country = "…"
|
|
823
|
+
if not t.flag:
|
|
824
|
+
t.flag = "🏳"
|
|
825
|
+
self._resort()
|
|
826
|
+
self.run_worker(self._autodetect(mon), group="geo")
|
|
827
|
+
|
|
828
|
+
async def _autodetect(self, mon: Monitor) -> None:
|
|
829
|
+
data = await geo_lookup(mon.target.host)
|
|
830
|
+
t = mon.target
|
|
831
|
+
if data:
|
|
832
|
+
mon.geo = data
|
|
833
|
+
if t.country in ("", "…"):
|
|
834
|
+
t.country = data.get("country") or t.host
|
|
835
|
+
if t.flag in ("", "🏳"):
|
|
836
|
+
t.flag = flag_emoji(data.get("countryCode"))
|
|
837
|
+
self.cfg.targets = [m.target for m in self.monitors]
|
|
838
|
+
save_config(self.cfg)
|
|
839
|
+
city = data.get("city")
|
|
840
|
+
where = f"{city}, {t.country}" if city else t.country
|
|
841
|
+
self.notify(f"Detected · {t.flag} {where}", timeout=2)
|
|
842
|
+
else:
|
|
843
|
+
if t.country in ("", "…"):
|
|
844
|
+
t.country = t.host
|
|
845
|
+
self.notify("GeoIP detection failed", severity="warning", timeout=2)
|
|
846
|
+
self._resort()
|
|
847
|
+
|
|
848
|
+
def action_edit_target(self) -> None:
|
|
849
|
+
mon = self.selected
|
|
850
|
+
if mon is None:
|
|
851
|
+
return
|
|
852
|
+
|
|
853
|
+
def handle(target: Target | None) -> None:
|
|
854
|
+
if target is None:
|
|
855
|
+
return
|
|
856
|
+
old = mon.target
|
|
857
|
+
mon.target = target
|
|
858
|
+
self.cfg.targets = [m.target for m in self.monitors]
|
|
859
|
+
save_config(self.cfg)
|
|
860
|
+
# if the endpoint changed, reset stats and restart the probe worker
|
|
861
|
+
if (target.host, target.port) != (old.host, old.port):
|
|
862
|
+
self.alerting.discard(old.key)
|
|
863
|
+
mon.stats.reset()
|
|
864
|
+
mon.ip = None
|
|
865
|
+
if mon.worker is not None:
|
|
866
|
+
mon.worker.cancel()
|
|
867
|
+
mon.worker = self.run_worker(
|
|
868
|
+
self._ping_loop(mon, 0.0), name=mon.key, group="ping"
|
|
869
|
+
)
|
|
870
|
+
self._resort()
|
|
871
|
+
self.notify(f"Updated: {target.host}", timeout=2)
|
|
872
|
+
self._maybe_autodetect(mon)
|
|
873
|
+
|
|
874
|
+
self.push_screen(
|
|
875
|
+
TargetFormScreen(initial=mon.target, title="Edit target"), handle
|
|
876
|
+
)
|
|
877
|
+
|
|
878
|
+
def action_delete_target(self) -> None:
|
|
879
|
+
mon = self.selected
|
|
880
|
+
if mon is None or len(self.monitors) <= 1:
|
|
881
|
+
self.notify("Cannot delete the last target", severity="warning", timeout=2)
|
|
882
|
+
return
|
|
883
|
+
if mon.worker is not None:
|
|
884
|
+
mon.worker.cancel()
|
|
885
|
+
self.monitors.remove(mon)
|
|
886
|
+
self.cfg.targets = [m.target for m in self.monitors]
|
|
887
|
+
save_config(self.cfg)
|
|
888
|
+
self.selected = self.monitors[0]
|
|
889
|
+
self._resort()
|
|
890
|
+
self.notify(f"Removed: {mon.target.country}", timeout=2)
|
|
891
|
+
|
|
892
|
+
def action_open_config(self) -> None:
|
|
893
|
+
self.notify(f"Config file: {self.cfg.path}", timeout=4)
|
|
894
|
+
|
|
895
|
+
def action_advisor(self) -> None:
|
|
896
|
+
self.push_screen(AdvisorScreen())
|
|
897
|
+
|
|
898
|
+
FILTER_MODES = ["all", "mine", "others"]
|
|
899
|
+
FILTER_LABELS = {"all": "all", "mine": "mine only", "others": "others only"}
|
|
900
|
+
|
|
901
|
+
def action_cycle_filter(self) -> None:
|
|
902
|
+
i = self.FILTER_MODES.index(self.filter_mode)
|
|
903
|
+
self.filter_mode = self.FILTER_MODES[(i + 1) % len(self.FILTER_MODES)]
|
|
904
|
+
self._resort()
|
|
905
|
+
self.notify(f"Filter: {self.FILTER_LABELS[self.filter_mode]}", timeout=1.5)
|
|
906
|
+
|
|
907
|
+
def action_cycle_profile(self) -> None:
|
|
908
|
+
i = PROFILES.index(self.profile)
|
|
909
|
+
self.profile = PROFILES[(i + 1) % len(PROFILES)]
|
|
910
|
+
self.notify(f"Profile: {PROFILE_META[self.profile][0]}", timeout=1.5)
|
|
911
|
+
|
|
912
|
+
def action_traceroute(self) -> None:
|
|
913
|
+
if self.selected is not None:
|
|
914
|
+
self.push_screen(TracerouteScreen(self.selected.target))
|
|
915
|
+
|
|
916
|
+
def action_cursor_up(self) -> None:
|
|
917
|
+
self.query_one("#table", DataTable).action_cursor_up()
|
|
918
|
+
|
|
919
|
+
def action_cursor_down(self) -> None:
|
|
920
|
+
self.query_one("#table", DataTable).action_cursor_down()
|
|
921
|
+
|
|
922
|
+
|
|
923
|
+
def main() -> None:
|
|
924
|
+
import argparse
|
|
925
|
+
import os
|
|
926
|
+
|
|
927
|
+
from . import __version__
|
|
928
|
+
|
|
929
|
+
parser = argparse.ArgumentParser(
|
|
930
|
+
prog="pingmon",
|
|
931
|
+
description="TUI monitor of latency and availability to servers by country.",
|
|
932
|
+
)
|
|
933
|
+
parser.add_argument(
|
|
934
|
+
"-V", "--version", action="version", version=f"pingmon {__version__}"
|
|
935
|
+
)
|
|
936
|
+
parser.add_argument(
|
|
937
|
+
"-c", "--config", metavar="PATH",
|
|
938
|
+
help="path to config.toml (overrides $PINGMON_CONFIG)",
|
|
939
|
+
)
|
|
940
|
+
args = parser.parse_args()
|
|
941
|
+
if args.config:
|
|
942
|
+
os.environ["PINGMON_CONFIG"] = args.config
|
|
943
|
+
|
|
944
|
+
PingMonApp().run()
|
|
945
|
+
|
|
946
|
+
|
|
947
|
+
if __name__ == "__main__":
|
|
948
|
+
main()
|