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.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()