Files
yaping/yaping.py
yair aacf975446 Initial commit: yaping - SmokePing-like network latency monitoring tool
Features:
- Multiple probe methods: ICMP (subprocess), TCP connect, HTTP/HTTPS
- No root required
- SQLite storage for measurements
- Beautiful terminal graphs with plotext
- Single-file script with PEP 723 inline dependencies
- CLI interface with rich output

Commands: add, remove, list, enable, disable, probe, run, stats, graph, history, import-config

Run with: uv run yaping.py
2026-01-13 18:20:07 +02:00

980 lines
32 KiB
Python

#!/usr/bin/env python3
# /// script
# requires-python = ">=3.11"
# dependencies = [
# "click>=8.1",
# "httpx>=0.27",
# "plotext>=5.2",
# "rich>=13.7",
# ]
# ///
"""
yaping - Yet Another PING
A SmokePing-like network latency monitoring tool with CLI graphs.
Supports ICMP (via subprocess), TCP, and HTTP probes.
Stores measurements in SQLite and displays terminal graphs.
Usage:
uv run yaping.py add google --host google.com
uv run yaping.py add cloudflare --host 1.1.1.1 --method tcp --port 443
uv run yaping.py run
uv run yaping.py graph
"""
from __future__ import annotations
import os
import platform
import re
import signal
import socket
import sqlite3
import subprocess
import sys
import time
from contextlib import contextmanager
from dataclasses import dataclass
from datetime import datetime, timedelta
from pathlib import Path
from statistics import mean, stdev
from typing import TYPE_CHECKING
import click
import httpx
import plotext as plt
from rich.console import Console
from rich.table import Table
if TYPE_CHECKING:
from collections.abc import Generator
# =============================================================================
# Configuration
# =============================================================================
DEFAULT_DB_PATH = Path.home() / ".local" / "share" / "yaping" / "yaping.db"
DEFAULT_INTERVAL = 60 # seconds
DEFAULT_TIMEOUT = 5.0 # seconds
console = Console()
# =============================================================================
# Data Classes
# =============================================================================
@dataclass
class Target:
"""Represents a monitoring target."""
id: int
name: str
host: str
probe_type: str
port: int | None
interval: int
enabled: bool
created_at: datetime
@dataclass
class Measurement:
"""Represents a single measurement result."""
id: int
target_id: int
timestamp: datetime
latency_ms: float | None
success: bool
error_message: str | None
@dataclass
class Stats:
"""Statistics for a target."""
count: int
avg: float | None
min: float | None
max: float | None
stddev: float | None
loss_percent: float
# =============================================================================
# Database Layer
# =============================================================================
class Database:
"""SQLite database for storing targets and measurements."""
def __init__(self, db_path: str | Path) -> None:
self.db_path = Path(db_path)
self.db_path.parent.mkdir(parents=True, exist_ok=True)
self._conn: sqlite3.Connection | None = None
@property
def conn(self) -> sqlite3.Connection:
"""Get database connection, creating if necessary."""
if self._conn is None:
self._conn = sqlite3.connect(self.db_path)
self._conn.row_factory = sqlite3.Row
return self._conn
def init(self) -> None:
"""Initialize database schema."""
self.conn.executescript("""
CREATE TABLE IF NOT EXISTS targets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
host TEXT NOT NULL,
probe_type TEXT NOT NULL DEFAULT 'icmp',
port INTEGER,
interval INTEGER DEFAULT 60,
enabled INTEGER DEFAULT 1,
created_at TIMESTAMP DEFAULT (DATETIME('now', 'localtime'))
);
CREATE TABLE IF NOT EXISTS measurements (
id INTEGER PRIMARY KEY AUTOINCREMENT,
target_id INTEGER NOT NULL,
timestamp TIMESTAMP DEFAULT (DATETIME('now', 'localtime')),
latency_ms REAL,
success INTEGER NOT NULL,
error_message TEXT,
FOREIGN KEY (target_id) REFERENCES targets(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_measurements_target_time
ON measurements(target_id, timestamp);
""")
self.conn.commit()
def close(self) -> None:
"""Close database connection."""
if self._conn:
self._conn.close()
self._conn = None
def add_target(
self,
name: str,
host: str,
probe_type: str,
port: int | None = None,
interval: int = DEFAULT_INTERVAL,
) -> int:
"""Add a new target. Returns the target ID."""
cursor = self.conn.execute(
"""
INSERT INTO targets (name, host, probe_type, port, interval)
VALUES (?, ?, ?, ?, ?)
""",
(name, host, probe_type, port, interval),
)
self.conn.commit()
return cursor.lastrowid # type: ignore[return-value]
def remove_target(self, name: str) -> bool:
"""Remove a target by name. Returns True if target was found."""
cursor = self.conn.execute("DELETE FROM targets WHERE name = ?", (name,))
self.conn.commit()
return cursor.rowcount > 0
def get_target(self, name: str) -> Target | None:
"""Get a target by name."""
row = self.conn.execute(
"SELECT * FROM targets WHERE name = ?", (name,)
).fetchone()
if row:
return self._row_to_target(row)
return None
def get_targets(self, enabled_only: bool = False) -> list[Target]:
"""Get all targets."""
query = "SELECT * FROM targets"
if enabled_only:
query += " WHERE enabled = 1"
query += " ORDER BY name"
rows = self.conn.execute(query).fetchall()
return [self._row_to_target(row) for row in rows]
def set_target_enabled(self, name: str, enabled: bool) -> bool:
"""Enable or disable a target. Returns True if target was found."""
cursor = self.conn.execute(
"UPDATE targets SET enabled = ? WHERE name = ?", (int(enabled), name)
)
self.conn.commit()
return cursor.rowcount > 0
def record_measurement(
self,
target_id: int,
latency_ms: float | None,
success: bool,
error: str | None = None,
) -> None:
"""Record a measurement result."""
self.conn.execute(
"""
INSERT INTO measurements (target_id, latency_ms, success, error_message)
VALUES (?, ?, ?, ?)
""",
(target_id, latency_ms, int(success), error),
)
self.conn.commit()
def get_measurements(
self,
target_id: int,
period: str | None = None,
limit: int | None = None,
) -> list[Measurement]:
"""Get measurements for a target, optionally filtered by time period."""
query = "SELECT * FROM measurements WHERE target_id = ?"
params: list = [target_id]
if period:
delta = parse_period(period)
if delta:
cutoff = datetime.now() - delta
query += " AND timestamp >= ?"
# SQLite CURRENT_TIMESTAMP uses space separator, not 'T'
params.append(cutoff.strftime("%Y-%m-%d %H:%M:%S"))
query += " ORDER BY timestamp DESC"
if limit:
query += " LIMIT ?"
params.append(limit)
rows = self.conn.execute(query, params).fetchall()
return [self._row_to_measurement(row) for row in rows]
def get_stats(self, target_id: int, period: str | None = None) -> Stats:
"""Calculate statistics for a target."""
measurements = self.get_measurements(target_id, period)
return calculate_stats(measurements)
def _row_to_target(self, row: sqlite3.Row) -> Target:
"""Convert a database row to a Target object."""
created = row["created_at"]
if isinstance(created, str):
created = datetime.fromisoformat(created)
return Target(
id=row["id"],
name=row["name"],
host=row["host"],
probe_type=row["probe_type"],
port=row["port"],
interval=row["interval"],
enabled=bool(row["enabled"]),
created_at=created,
)
def _row_to_measurement(self, row: sqlite3.Row) -> Measurement:
"""Convert a database row to a Measurement object."""
ts = row["timestamp"]
if isinstance(ts, str):
ts = datetime.fromisoformat(ts)
return Measurement(
id=row["id"],
target_id=row["target_id"],
timestamp=ts,
latency_ms=row["latency_ms"],
success=bool(row["success"]),
error_message=row["error_message"],
)
# =============================================================================
# Probe Methods
# =============================================================================
def icmp_probe(host: str, timeout: float = DEFAULT_TIMEOUT) -> tuple[float | None, str | None]:
"""
Perform ICMP ping using system ping command.
Returns (latency_ms, error_message).
"""
system = platform.system().lower()
if system == "windows":
cmd = ["ping", "-n", "1", "-w", str(int(timeout * 1000)), host]
pattern = r"time[=<](\d+(?:\.\d+)?)\s*ms"
elif system == "darwin": # macOS
cmd = ["ping", "-c", "1", "-W", str(int(timeout * 1000)), host]
pattern = r"time=(\d+(?:\.\d+)?)\s*ms"
else: # Linux and others
cmd = ["ping", "-c", "1", "-W", str(int(timeout)), host]
pattern = r"time=(\d+(?:\.\d+)?)\s*ms"
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=timeout + 2,
)
if result.returncode == 0:
match = re.search(pattern, result.stdout, re.IGNORECASE)
if match:
return float(match.group(1)), None
return None, "Could not parse ping output"
return None, f"Ping failed: {result.stderr.strip() or 'No response'}"
except subprocess.TimeoutExpired:
return None, "Ping timed out"
except FileNotFoundError:
return None, "ping command not found"
except Exception as e:
return None, str(e)
def tcp_probe(
host: str, port: int, timeout: float = DEFAULT_TIMEOUT
) -> tuple[float | None, str | None]:
"""
Perform TCP connect probe.
Returns (latency_ms, error_message).
"""
start = time.perf_counter()
try:
sock = socket.create_connection((host, port), timeout=timeout)
sock.close()
latency = (time.perf_counter() - start) * 1000
return latency, None
except socket.timeout:
return None, "Connection timed out"
except ConnectionRefusedError:
return None, "Connection refused"
except socket.gaierror as e:
return None, f"DNS resolution failed: {e}"
except OSError as e:
return None, str(e)
def http_probe(
url: str, timeout: float = DEFAULT_TIMEOUT
) -> tuple[float | None, str | None]:
"""
Perform HTTP/HTTPS probe.
Returns (latency_ms, error_message).
"""
# Ensure URL has scheme
if not url.startswith(("http://", "https://")):
url = f"https://{url}"
start = time.perf_counter()
try:
with httpx.Client(timeout=timeout, follow_redirects=True) as client:
response = client.get(url)
response.raise_for_status()
latency = (time.perf_counter() - start) * 1000
return latency, None
except httpx.TimeoutException:
return None, "Request timed out"
except httpx.HTTPStatusError as e:
return None, f"HTTP {e.response.status_code}"
except httpx.RequestError as e:
return None, str(e)
def probe_target(target: Target, timeout: float = DEFAULT_TIMEOUT) -> tuple[float | None, str | None]:
"""Probe a target using its configured method."""
if target.probe_type == "icmp":
return icmp_probe(target.host, timeout)
elif target.probe_type == "tcp":
if target.port is None:
return None, "TCP probe requires a port"
return tcp_probe(target.host, target.port, timeout)
elif target.probe_type == "http":
return http_probe(target.host, timeout)
else:
return None, f"Unknown probe type: {target.probe_type}"
# =============================================================================
# Statistics
# =============================================================================
def parse_period(period: str) -> timedelta | None:
"""Parse a period string like '1h', '24h', '7d' into a timedelta."""
match = re.match(r"^(\d+)([smhd])$", period.lower())
if not match:
return None
value = int(match.group(1))
unit = match.group(2)
if unit == "s":
return timedelta(seconds=value)
elif unit == "m":
return timedelta(minutes=value)
elif unit == "h":
return timedelta(hours=value)
elif unit == "d":
return timedelta(days=value)
return None
def calculate_stats(measurements: list[Measurement]) -> Stats:
"""Calculate statistics from a list of measurements."""
if not measurements:
return Stats(
count=0,
avg=None,
min=None,
max=None,
stddev=None,
loss_percent=0.0,
)
total = len(measurements)
successful = [m for m in measurements if m.success and m.latency_ms is not None]
latencies = [m.latency_ms for m in successful if m.latency_ms is not None]
loss_percent = ((total - len(successful)) / total) * 100 if total > 0 else 0.0
if not latencies:
return Stats(
count=total,
avg=None,
min=None,
max=None,
stddev=None,
loss_percent=loss_percent,
)
return Stats(
count=total,
avg=mean(latencies),
min=min(latencies),
max=max(latencies),
stddev=stdev(latencies) if len(latencies) > 1 else 0.0,
loss_percent=loss_percent,
)
# =============================================================================
# Graphing
# =============================================================================
def draw_latency_graph(
measurements: list[Measurement],
title: str = "Latency",
width: int | None = None,
height: int | None = None,
) -> None:
"""Draw a terminal graph of latency over time."""
# Filter successful measurements with latency
valid = [(m.timestamp, m.latency_ms) for m in measurements if m.success and m.latency_ms is not None]
if not valid:
console.print("[yellow]No data to graph[/yellow]")
return
# Sort by timestamp (oldest first for proper graph)
valid.sort(key=lambda x: x[0])
timestamps = [m[0] for m in valid]
latencies = [m[1] for m in valid]
# Convert timestamps to relative minutes from start
start_time = timestamps[0]
x_values = [(t - start_time).total_seconds() / 60 for t in timestamps]
plt.clear_figure()
if width:
plt.plot_size(width, height or 15)
plt.plot(x_values, latencies, marker="braille")
plt.title(title)
plt.xlabel("Minutes ago")
plt.ylabel("Latency (ms)")
# Add statistics in the plot
stats = calculate_stats(measurements)
if stats.avg is not None:
plt.hline(stats.avg, "blue")
plt.show()
def draw_multi_target_graph(
db: Database,
targets: list[Target],
period: str = "1h",
width: int | None = None,
height: int | None = None,
) -> None:
"""Draw a comparison graph for multiple targets."""
plt.clear_figure()
if width:
plt.plot_size(width, height or 20)
colors = ["red", "green", "blue", "yellow", "cyan", "magenta"]
labels = []
for i, target in enumerate(targets):
measurements = db.get_measurements(target.id, period)
valid = [(m.timestamp, m.latency_ms) for m in measurements if m.success and m.latency_ms is not None]
if not valid:
continue
valid.sort(key=lambda x: x[0])
timestamps = [m[0] for m in valid]
latencies = [m[1] for m in valid]
# Convert to relative minutes
if timestamps:
now = datetime.now()
x_values = [(now - t).total_seconds() / 60 for t in timestamps]
color = colors[i % len(colors)]
plt.plot(x_values, latencies, marker="braille", color=color, label=target.name)
stats = calculate_stats(measurements)
labels.append(f"{target.name} (avg: {stats.avg:.1f}ms)" if stats.avg else target.name)
if labels:
plt.title(f"Latency Comparison (last {period})")
plt.xlabel("Minutes ago")
plt.ylabel("Latency (ms)")
plt.show()
else:
console.print("[yellow]No data to graph[/yellow]")
# =============================================================================
# CLI Commands
# =============================================================================
@contextmanager
def get_db(db_path: str | None = None) -> Generator[Database, None, None]:
"""Context manager for database access."""
path = Path(db_path) if db_path else DEFAULT_DB_PATH
db = Database(path)
db.init()
try:
yield db
finally:
db.close()
@click.group()
@click.option(
"--db",
"db_path",
envvar="YAPING_DB",
help="Path to SQLite database file",
)
@click.pass_context
def cli(ctx: click.Context, db_path: str | None) -> None:
"""yaping - Yet Another PING - Network latency monitoring with CLI graphs."""
ctx.ensure_object(dict)
ctx.obj["db_path"] = db_path
@cli.command()
@click.argument("name")
@click.option("--host", "-h", required=True, help="Target host or URL")
@click.option(
"--method",
"-m",
type=click.Choice(["icmp", "tcp", "http"]),
default="icmp",
help="Probe method",
)
@click.option("--port", "-p", type=int, help="Port for TCP probe")
@click.option("--interval", "-i", type=int, default=DEFAULT_INTERVAL, help="Probe interval in seconds")
@click.pass_context
def add(ctx: click.Context, name: str, host: str, method: str, port: int | None, interval: int) -> None:
"""Add a new target to monitor."""
if method == "tcp" and port is None:
raise click.BadParameter("--port is required for TCP probe method")
with get_db(ctx.obj["db_path"]) as db:
try:
db.add_target(name, host, method, port, interval)
port_str = f":{port}" if port else ""
console.print(f"[green]✓[/green] Added target '[bold]{name}[/bold]' ({method}{host}{port_str})")
except sqlite3.IntegrityError:
console.print(f"[red]✗[/red] Target '[bold]{name}[/bold]' already exists")
sys.exit(1)
@cli.command()
@click.argument("name")
@click.pass_context
def remove(ctx: click.Context, name: str) -> None:
"""Remove a target."""
with get_db(ctx.obj["db_path"]) as db:
if db.remove_target(name):
console.print(f"[green]✓[/green] Removed target '[bold]{name}[/bold]'")
else:
console.print(f"[red]✗[/red] Target '[bold]{name}[/bold]' not found")
sys.exit(1)
@cli.command("list")
@click.pass_context
def list_targets(ctx: click.Context) -> None:
"""List all targets."""
with get_db(ctx.obj["db_path"]) as db:
targets = db.get_targets()
if not targets:
console.print("[dim]No targets configured. Use 'add' to add one.[/dim]")
return
table = Table(title="Monitoring Targets")
table.add_column("Name", style="cyan")
table.add_column("Host")
table.add_column("Method")
table.add_column("Interval")
table.add_column("Status")
for target in targets:
host = target.host
if target.port:
host += f":{target.port}"
status = "[green]enabled[/green]" if target.enabled else "[dim]disabled[/dim]"
table.add_row(
target.name,
host,
target.probe_type,
f"{target.interval}s",
status,
)
console.print(table)
@cli.command()
@click.argument("name")
@click.pass_context
def enable(ctx: click.Context, name: str) -> None:
"""Enable a target."""
with get_db(ctx.obj["db_path"]) as db:
if db.set_target_enabled(name, True):
console.print(f"[green]✓[/green] Enabled target '[bold]{name}[/bold]'")
else:
console.print(f"[red]✗[/red] Target '[bold]{name}[/bold]' not found")
sys.exit(1)
@cli.command()
@click.argument("name")
@click.pass_context
def disable(ctx: click.Context, name: str) -> None:
"""Disable a target."""
with get_db(ctx.obj["db_path"]) as db:
if db.set_target_enabled(name, False):
console.print(f"[green]✓[/green] Disabled target '[bold]{name}[/bold]'")
else:
console.print(f"[red]✗[/red] Target '[bold]{name}[/bold]' not found")
sys.exit(1)
@cli.command()
@click.option("--timeout", "-t", type=float, default=DEFAULT_TIMEOUT, help="Probe timeout in seconds")
@click.pass_context
def probe(ctx: click.Context, timeout: float) -> None:
"""Run a single probe for all enabled targets."""
with get_db(ctx.obj["db_path"]) as db:
targets = db.get_targets(enabled_only=True)
if not targets:
console.print("[dim]No enabled targets. Use 'add' to add one.[/dim]")
return
for target in targets:
latency, error = probe_target(target, timeout)
success = latency is not None
db.record_measurement(target.id, latency, success, error)
timestamp = datetime.now().strftime("%H:%M:%S")
if success:
console.print(f"[dim]{timestamp}[/dim] [cyan]{target.name}[/cyan]: [green]{latency:.1f}ms[/green] ✓")
else:
console.print(f"[dim]{timestamp}[/dim] [cyan]{target.name}[/cyan]: [red]{error}[/red] ✗")
@cli.command()
@click.option("--interval", "-i", type=int, help="Override probe interval (seconds)")
@click.option("--timeout", "-t", type=float, default=DEFAULT_TIMEOUT, help="Probe timeout in seconds")
@click.pass_context
def run(ctx: click.Context, interval: int | None, timeout: float) -> None:
"""Run continuous monitoring (Ctrl+C to stop)."""
running = True
def handle_signal(signum: int, frame: object) -> None:
nonlocal running
running = False
console.print("\n[yellow]Stopping...[/yellow]")
signal.signal(signal.SIGINT, handle_signal)
signal.signal(signal.SIGTERM, handle_signal)
with get_db(ctx.obj["db_path"]) as db:
targets = db.get_targets(enabled_only=True)
if not targets:
console.print("[dim]No enabled targets. Use 'add' to add one.[/dim]")
return
console.print(f"[bold]Starting monitoring of {len(targets)} target(s)...[/bold]")
console.print("[dim]Press Ctrl+C to stop[/dim]\n")
# Track next probe time for each target
next_probe = {t.id: time.time() for t in targets}
while running:
now = time.time()
for target in targets:
target_interval = interval if interval else target.interval
if now >= next_probe[target.id]:
latency, error = probe_target(target, timeout)
success = latency is not None
db.record_measurement(target.id, latency, success, error)
timestamp = datetime.now().strftime("%H:%M:%S")
if success:
console.print(f"[dim]{timestamp}[/dim] [cyan]{target.name}[/cyan]: [green]{latency:.1f}ms[/green] ✓")
else:
console.print(f"[dim]{timestamp}[/dim] [cyan]{target.name}[/cyan]: [red]{error}[/red] ✗")
next_probe[target.id] = now + target_interval
# Sleep briefly to avoid busy-waiting
time.sleep(0.1)
@cli.command()
@click.argument("name", required=False)
@click.option("--period", "-p", default="1h", help="Time period (e.g., 1h, 24h, 7d)")
@click.pass_context
def stats(ctx: click.Context, name: str | None, period: str) -> None:
"""Show statistics for targets."""
with get_db(ctx.obj["db_path"]) as db:
if name:
target = db.get_target(name)
if not target:
console.print(f"[red]✗[/red] Target '[bold]{name}[/bold]' not found")
sys.exit(1)
targets = [target]
else:
targets = db.get_targets()
if not targets:
console.print("[dim]No targets configured.[/dim]")
return
table = Table(title=f"Statistics (last {period})")
table.add_column("Target", style="cyan")
table.add_column("Avg", justify="right")
table.add_column("Min", justify="right")
table.add_column("Max", justify="right")
table.add_column("StdDev", justify="right")
table.add_column("Loss", justify="right")
table.add_column("Samples", justify="right")
for target in targets:
target_stats = db.get_stats(target.id, period)
def fmt_ms(val: float | None) -> str:
return f"{val:.1f}ms" if val is not None else "-"
loss_style = "green" if target_stats.loss_percent < 1 else "yellow" if target_stats.loss_percent < 5 else "red"
table.add_row(
target.name,
fmt_ms(target_stats.avg),
fmt_ms(target_stats.min),
fmt_ms(target_stats.max),
fmt_ms(target_stats.stddev),
f"[{loss_style}]{target_stats.loss_percent:.1f}%[/{loss_style}]",
str(target_stats.count),
)
console.print(table)
@cli.command()
@click.argument("name", required=False)
@click.option("--period", "-p", default="1h", help="Time period (e.g., 1h, 24h, 7d)")
@click.option("--width", "-w", type=int, help="Graph width")
@click.option("--height", "-H", type=int, help="Graph height")
@click.pass_context
def graph(ctx: click.Context, name: str | None, period: str, width: int | None, height: int | None) -> None:
"""Display latency graph."""
with get_db(ctx.obj["db_path"]) as db:
if name:
target = db.get_target(name)
if not target:
console.print(f"[red]✗[/red] Target '[bold]{name}[/bold]' not found")
sys.exit(1)
measurements = db.get_measurements(target.id, period)
if not measurements:
console.print(f"[yellow]No measurements for '{name}' in the last {period}[/yellow]")
return
stats_data = calculate_stats(measurements)
draw_latency_graph(
measurements,
title=f"Latency - {name} (last {period})",
width=width,
height=height,
)
# Print summary below graph
if stats_data.avg is not None:
console.print(
f"\n[dim]Statistics:[/dim] avg=[green]{stats_data.avg:.1f}ms[/green], "
f"min=[cyan]{stats_data.min:.1f}ms[/cyan], "
f"max=[yellow]{stats_data.max:.1f}ms[/yellow], "
f"loss=[{'red' if stats_data.loss_percent > 0 else 'green'}]{stats_data.loss_percent:.1f}%[/]"
)
else:
targets = db.get_targets()
if not targets:
console.print("[dim]No targets configured.[/dim]")
return
draw_multi_target_graph(db, targets, period, width, height)
@cli.command()
@click.argument("name")
@click.option("--period", "-p", default="1h", help="Time period (e.g., 1h, 24h, 7d)")
@click.option("--limit", "-l", type=int, help="Limit number of results")
@click.pass_context
def history(ctx: click.Context, name: str, period: str, limit: int | None) -> None:
"""Show measurement history for a target."""
with get_db(ctx.obj["db_path"]) as db:
target = db.get_target(name)
if not target:
console.print(f"[red]✗[/red] Target '[bold]{name}[/bold]' not found")
sys.exit(1)
measurements = db.get_measurements(target.id, period, limit)
if not measurements:
console.print(f"[yellow]No measurements for '{name}' in the last {period}[/yellow]")
return
table = Table(title=f"History - {name} (last {period})")
table.add_column("Timestamp")
table.add_column("Latency", justify="right")
table.add_column("Status")
for m in measurements[:50]: # Limit displayed rows
if m.success and m.latency_ms is not None:
status = "[green]✓[/green]"
latency = f"{m.latency_ms:.1f}ms"
else:
status = "[red]✗[/red]"
latency = m.error_message or "Failed"
table.add_row(
m.timestamp.strftime("%Y-%m-%d %H:%M:%S"),
latency,
status,
)
console.print(table)
if len(measurements) > 50:
console.print(f"[dim]Showing 50 of {len(measurements)} measurements[/dim]")
# =============================================================================
# Config File Support
# =============================================================================
def load_config(config_path: Path) -> dict | None:
"""Load configuration from TOML file."""
if not config_path.exists():
return None
try:
# Use tomllib in Python 3.11+
import tomllib
with open(config_path, "rb") as f:
return tomllib.load(f)
except ImportError:
# Fallback for older Python (shouldn't happen with requires-python >= 3.11)
console.print("[yellow]Warning: tomllib not available, config file ignored[/yellow]")
return None
except Exception as e:
console.print(f"[yellow]Warning: Failed to load config: {e}[/yellow]")
return None
@cli.command()
@click.argument("config_file", type=click.Path(exists=True), required=False)
@click.pass_context
def import_config(ctx: click.Context, config_file: str | None) -> None:
"""Import targets from a TOML configuration file."""
config_path = Path(config_file) if config_file else Path("yaping.toml")
if not config_path.exists():
console.print(f"[red]✗[/red] Config file '{config_path}' not found")
sys.exit(1)
config = load_config(config_path)
if not config:
console.print("[red]✗[/red] Failed to parse config file")
sys.exit(1)
targets = config.get("targets", [])
if not targets:
console.print("[yellow]No targets found in config file[/yellow]")
return
with get_db(ctx.obj["db_path"]) as db:
added = 0
for target_config in targets:
name = target_config.get("name")
host = target_config.get("host")
method = target_config.get("method", "icmp")
port = target_config.get("port")
interval = target_config.get("interval", DEFAULT_INTERVAL)
if not name or not host:
console.print(f"[yellow]Skipping invalid target: {target_config}[/yellow]")
continue
try:
db.add_target(name, host, method, port, interval)
console.print(f"[green]✓[/green] Added '{name}'")
added += 1
except sqlite3.IntegrityError:
console.print(f"[yellow]⚠[/yellow] Target '{name}' already exists, skipping")
console.print(f"\n[bold]Imported {added} target(s)[/bold]")
# =============================================================================
# Entry Point
# =============================================================================
if __name__ == "__main__":
cli()