346 lines
15 KiB
Python
346 lines
15 KiB
Python
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
|