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
980 lines
32 KiB
Python
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()
|