Files
Network-tools/core/Function/ping_fun.py
T
2026-06-06 01:57:43 +08:00

346 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import concurrent.futures
import csv
import ipaddress
import re
import threading
import time
from dataclasses import dataclass, field
from typing import Callable, Optional
from core.Function.common import run_hidden, validate_host, validate_ip
OutputCallback = Callable[[str, Optional[str]], None]
DoneCallback = Callable[[], None]
StatusCallback = Callable[[dict], None]
@dataclass
class PingStats:
sent: int = 0
received: int = 0
rtts: list[float] = field(default_factory=list)
current_loss_streak: int = 0
max_loss_streak: int = 0
last_success_at: str = ""
@property
def lost(self) -> int:
return max(self.sent - self.received, 0)
@property
def loss_rate(self) -> float:
return self.lost / self.sent * 100 if self.sent else 0.0
@property
def avg_rtt(self) -> float:
return sum(self.rtts) / len(self.rtts) if self.rtts else 0.0
@property
def jitter(self) -> float:
if len(self.rtts) < 2:
return 0.0
deltas = [abs(self.rtts[index] - self.rtts[index - 1]) for index in range(1, len(self.rtts))]
return sum(deltas) / len(deltas)
@property
def quality(self) -> str:
if not self.sent:
return "等待"
if self.loss_rate >= 30 or self.max_loss_streak >= 3:
return "丢包严重"
if self.loss_rate > 0 or self.jitter >= 50:
return "波动"
return "稳定"
def snapshot(self) -> dict:
return {
"sent": self.sent,
"received": self.received,
"lost": self.lost,
"loss_rate": self.loss_rate,
"min_rtt": min(self.rtts) if self.rtts else 0.0,
"max_rtt": max(self.rtts) if self.rtts else 0.0,
"avg_rtt": self.avg_rtt,
"jitter": self.jitter,
"max_loss_streak": self.max_loss_streak,
"last_success_at": self.last_success_at,
"quality": self.quality,
}
class PingFun:
def __init__(
self,
output: OutputCallback,
done: Optional[DoneCallback] = None,
status: Optional[StatusCallback] = None,
):
self.output = output
self.done = done or (lambda: None)
self.status = status or (lambda _stats: None)
self.stop_event = threading.Event()
self.worker = None
self.batch_worker = None
self.stats = PingStats()
self.last_batch_results = []
def start_ping(self, host: str, local_ip: str = "", options: Optional[dict] = None) -> None:
host = validate_host(host)
local_ip = validate_ip(local_ip, allow_empty=True)
options = self.normalize_options(options)
if self.is_running() or self.is_batch_running():
raise RuntimeError("Ping 正在运行,请先停止当前任务")
self.stop_event.clear()
self.stats = PingStats()
self.status(self.stats.snapshot() | {"state": "运行中"})
self.output(f"开始 Ping: {host}\n", "muted")
self.output(self.describe_options(options, local_ip), "muted")
self.worker = threading.Thread(target=self._run_ping_loop, args=(host, local_ip, options), daemon=True)
self.worker.start()
def _run_ping_loop(self, host: str, local_ip: str, options: dict) -> None:
max_count = None if options["mode"] == "持续" else options["count"]
try:
while not self.stop_event.is_set() and (max_count is None or self.stats.sent < max_count):
started = time.perf_counter()
result = self.ping_once(host, local_ip, options)
self._record_single_result(result)
elapsed_ms = (time.perf_counter() - started) * 1000
wait_ms = max(options["interval_ms"] - elapsed_ms, 0)
self.stop_event.wait(wait_ms / 1000)
finally:
self._write_statistics("Ping 统计")
self.status(self.stats.snapshot() | {"state": "已停止" if self.stop_event.is_set() else "已完成"})
self.done()
def _record_single_result(self, result: dict) -> None:
self.stats.sent += 1
if result["ok"]:
self.stats.received += 1
self.stats.current_loss_streak = 0
self.stats.rtts.append(result["rtt"])
self.stats.last_success_at = time.strftime("%H:%M:%S")
self.output(f"[{self.stats.sent}] {result['host']}{result['rtt']:.1f} ms\n", "success")
else:
self.stats.current_loss_streak += 1
self.stats.max_loss_streak = max(self.stats.max_loss_streak, self.stats.current_loss_streak)
self.output(f"[{self.stats.sent}] {result['host']} {result['message']}\n", "warning")
self.status(self.stats.snapshot() | {"state": "运行中"})
def stop_ping(self) -> None:
self.stop_event.set()
self.output("\n已请求停止 Ping\n", "warning")
def start_batch_ping(self, target_text: str, local_ip: str = "", options: Optional[dict] = None) -> None:
local_ip = validate_ip(local_ip, allow_empty=True)
hosts = parse_ping_targets(target_text)
options = self.normalize_options(options)
if self.is_running() or self.is_batch_running():
raise RuntimeError("Ping 正在运行,请先停止当前任务")
self.stop_event.clear()
self.last_batch_results = []
self.status({"state": "批量运行中", "sent": 0, "received": 0, "lost": 0, "loss_rate": 0, "avg_rtt": 0, "jitter": 0, "quality": "等待"})
self.output(f"开始批量 Ping: 共 {len(hosts)} 个目标\n", "muted")
self.output(self.describe_options(options, local_ip), "muted")
self.batch_worker = threading.Thread(target=self._run_batch_ping, args=(hosts, local_ip, options), daemon=True)
self.batch_worker.start()
def _run_batch_ping(self, hosts: list[str], local_ip: str, options: dict) -> None:
results = []
done = 0
workers = min(options["workers"], max(1, len(hosts)))
def task(host: str) -> dict:
if self.stop_event.is_set():
return {"host": host, "ok": False, "rtt": 0.0, "message": "已取消"}
return self.ping_once(host, local_ip, options)
try:
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as executor:
future_to_host = {executor.submit(task, host): host for host in hosts}
for future in concurrent.futures.as_completed(future_to_host):
if self.stop_event.is_set():
for item in future_to_host:
item.cancel()
break
result = future.result()
done += 1
results.append(result)
tag = "success" if result["ok"] else None
message = f"{result['rtt']:.1f} ms" if result["ok"] else result["message"]
self.output(f"[{done}/{len(hosts)}] {result['host']:<15} {message}\n", tag)
self.status(self._batch_snapshot(results, len(hosts)) | {"state": "批量运行中"})
finally:
ordered = sorted(results, key=lambda item: ipaddress.ip_address(item["host"]))
self.last_batch_results = ordered
if self.stop_event.is_set():
self.output("\n批量 Ping 已停止\n", "warning")
else:
self._write_batch_summary(ordered, len(hosts))
self.status(self._batch_snapshot(ordered, len(hosts)) | {"state": "已停止" if self.stop_event.is_set() else "已完成"})
self.done()
def stop_batch_ping(self) -> None:
self.stop_event.set()
self.output("\n正在停止批量 Ping...\n", "warning")
def ping_once(self, host: str, local_ip: str, options: dict) -> dict:
command = ["ping", host, "-n", "1", "-w", str(options["timeout_ms"])]
if local_ip:
command += ["-S", local_ip]
if options["size"] > 0:
command += ["-l", str(options["size"])]
if options["ttl"] > 0:
command += ["-i", str(options["ttl"])]
if options["dont_fragment"]:
command.append("-f")
try:
result = run_hidden(command, timeout=max(2, options["timeout_ms"] / 1000 + 2))
return parse_ping_output(host, result.stdout)
except Exception as exc:
return {"host": host, "ok": False, "rtt": 0.0, "message": str(exc)}
def normalize_options(self, options: Optional[dict]) -> dict:
options = options or {}
return {
"mode": options.get("mode", "持续"),
"count": clamp_int(options.get("count", 4), 1, 100000, "次数"),
"interval_ms": clamp_int(options.get("interval_ms", 1000), 100, 60000, "间隔"),
"timeout_ms": clamp_int(options.get("timeout_ms", 1200), 100, 60000, "超时"),
"size": clamp_int(options.get("size", 32), 0, 65500, "包大小"),
"ttl": clamp_int(options.get("ttl", 0), 0, 255, "TTL"),
"dont_fragment": bool(options.get("dont_fragment", False)),
"workers": clamp_int(options.get("workers", 64), 1, 256, "并发数"),
}
def describe_options(self, options: dict, local_ip: str) -> str:
mode = "持续" if options["mode"] == "持续" else f"{options['count']} 次"
source = local_ip or "默认路由"
df = "是" if options["dont_fragment"] else "否"
ttl = options["ttl"] if options["ttl"] else "默认"
return (
f"模式: {mode} 源地址: {source} 间隔: {options['interval_ms']}ms "
f"超时: {options['timeout_ms']}ms 包大小: {options['size']} bytes TTL: {ttl} 禁止分片: {df}\n\n"
)
def _write_statistics(self, title: str) -> None:
snapshot = self.stats.snapshot()
if not snapshot["sent"]:
return
lines = [
f"\n==== {title} ====\n",
f"发送: {snapshot['sent']} 接收: {snapshot['received']} 丢失: {snapshot['lost']} 丢包率: {snapshot['loss_rate']:.1f}%\n",
f"延迟: 最小 {snapshot['min_rtt']:.1f} ms 最大 {snapshot['max_rtt']:.1f} ms 平均 {snapshot['avg_rtt']:.1f} ms 抖动 {snapshot['jitter']:.1f} ms\n",
f"最大连续丢包: {snapshot['max_loss_streak']} 最后成功: {snapshot['last_success_at'] or '-'} 状态: {snapshot['quality']}\n",
]
self.output("".join(lines), "success" if snapshot["loss_rate"] == 0 else "warning")
def _write_batch_summary(self, results: list[dict], total: int) -> None:
online = [item for item in results if item["ok"]]
offline = [item for item in results if not item["ok"] and item["message"] != "超时"]
timeout = [item for item in results if item["message"] == "超时"]
snapshot = self._batch_snapshot(results, total)
self.output("\n==== 批量 Ping 统计 ====\n", "muted")
self.output(f"总数: {total} 在线: {len(online)} 离线: {len(offline)} 超时: {len(timeout)} 成功率: {100 - snapshot['loss_rate']:.1f}%\n", "success")
if online:
self.output("在线 IP: " + ", ".join(item["host"] for item in online) + "\n", "success")
if timeout:
self.output("超时 IP: " + ", ".join(item["host"] for item in timeout) + "\n", "warning")
if offline:
self.output("离线 IP: " + ", ".join(item["host"] for item in offline) + "\n", "warning")
def _batch_snapshot(self, results: list[dict], total: int) -> dict:
received = len([item for item in results if item["ok"]])
rtts = [item["rtt"] for item in results if item["ok"]]
sent = len(results)
loss_rate = (sent - received) / sent * 100 if sent else 0.0
avg = sum(rtts) / len(rtts) if rtts else 0.0
jitter = 0.0
if len(rtts) > 1:
deltas = [abs(rtts[index] - rtts[index - 1]) for index in range(1, len(rtts))]
jitter = sum(deltas) / len(deltas)
return {
"sent": sent,
"received": received,
"lost": max(sent - received, 0),
"loss_rate": loss_rate,
"avg_rtt": avg,
"jitter": jitter,
"quality": "稳定" if loss_rate == 0 else "波动" if loss_rate < 30 else "丢包严重",
}
def export_batch_results(self, path: str) -> None:
if not self.last_batch_results:
raise RuntimeError("还没有可导出的批量 Ping 结果")
with open(path, "w", newline="", encoding="utf-8-sig") as file:
writer = csv.DictWriter(file, fieldnames=["host", "ok", "rtt", "message"])
writer.writeheader()
writer.writerows(self.last_batch_results)
def is_running(self) -> bool:
return bool(self.worker and self.worker.is_alive())
def is_batch_running(self) -> bool:
return bool(self.batch_worker and self.batch_worker.is_alive())
def parse_ping_output(host: str, output: str) -> dict:
ttl_found = re.search(r"\bTTL=", output, re.IGNORECASE)
rtt_match = re.search(r"(?:time|时间)[=<]?\s*(\d+(?:\.\d+)?)\s*ms", output, re.IGNORECASE)
if ttl_found:
rtt = float(rtt_match.group(1)) if rtt_match else 0.0
return {"host": host, "ok": True, "rtt": rtt, "message": "在线"}
if re.search(r"请求超时|timed out|timeout", output, re.IGNORECASE):
return {"host": host, "ok": False, "rtt": 0.0, "message": "超时"}
if re.search(r"无法访问|unreachable|could not find host|找不到主机", output, re.IGNORECASE):
return {"host": host, "ok": False, "rtt": 0.0, "message": "不可达"}
return {"host": host, "ok": False, "rtt": 0.0, "message": "无响应"}
def parse_ping_targets(text: str) -> list[str]:
raw = text.strip()
if not raw:
raise ValueError("请输入批量 Ping 目标")
targets = []
for part in re.split(r"[,\s]+", raw):
item = part.strip()
if not item:
continue
if "/" in item:
network = ipaddress.ip_network(item, strict=False)
targets.extend(str(ip) for ip in network.hosts())
elif re.match(r"^\d{1,3}(?:\.\d{1,3}){3}-\d{1,3}$", item):
prefix, end_text = item.rsplit(".", 1)
start_text, end_host = end_text.split("-", 1)
start = int(start_text)
end = int(end_host)
if start > end:
raise ValueError("IP 范围起始值不能大于结束值")
targets.extend(str(ipaddress.ip_address(f"{prefix}.{index}")) for index in range(start, end + 1))
else:
targets.append(str(ipaddress.ip_address(item)))
unique = list(dict.fromkeys(targets))
if not unique:
raise ValueError("没有解析到有效目标")
return unique
def clamp_int(value, min_value: int, max_value: int, label: str) -> int:
try:
number = int(str(value).strip())
except ValueError as exc:
raise ValueError(f"{label}必须是整数") from exc
if number < min_value or number > max_value:
raise ValueError(f"{label}必须在 {min_value}-{max_value} 之间")
return number