417 lines
15 KiB
Python
417 lines
15 KiB
Python
import csv
|
|
import re
|
|
import threading
|
|
import time
|
|
from dataclasses import asdict, dataclass, field
|
|
from typing import Callable, Optional
|
|
|
|
from core.Function.common import popen_hidden, validate_host
|
|
|
|
|
|
OutputCallback = Callable[[str, Optional[str]], None]
|
|
DoneCallback = Callable[[], None]
|
|
StatusCallback = Callable[[dict], None]
|
|
ResultCallback = Callable[[dict], None]
|
|
|
|
|
|
TRACE_STATUS = {
|
|
"ok": "正常",
|
|
"timeout": "超时",
|
|
"high_latency": "高延迟",
|
|
"jitter": "波动大",
|
|
}
|
|
|
|
|
|
@dataclass
|
|
class TraceOptions:
|
|
max_hops: int = 20
|
|
timeout_ms: int = 800
|
|
address_family: str = "自动"
|
|
resolve_names: bool = False
|
|
mode: str = "单次"
|
|
repeat_count: int = 1
|
|
interval_ms: int = 1000
|
|
high_latency_ms: int = 100
|
|
|
|
|
|
@dataclass
|
|
class TraceHop:
|
|
run: int
|
|
hop: int
|
|
probe1: str
|
|
probe2: str
|
|
probe3: str
|
|
avg_ms: float
|
|
jitter_ms: float
|
|
host: str
|
|
ip: str
|
|
status: str
|
|
status_text: str
|
|
raw_line: str
|
|
checked_at: str = field(default_factory=lambda: time.strftime("%Y-%m-%d %H:%M:%S"))
|
|
|
|
def to_dict(self) -> dict:
|
|
data = asdict(self)
|
|
data["avg_ms"] = round(self.avg_ms, 1)
|
|
data["jitter_ms"] = round(self.jitter_ms, 1)
|
|
return data
|
|
|
|
|
|
@dataclass
|
|
class TraceStats:
|
|
total_runs: int = 1
|
|
current_run: int = 0
|
|
max_hops: int = 20
|
|
current_hop: int = 0
|
|
timeout_hops: int = 0
|
|
high_latency_hops: int = 0
|
|
jitter_hops: int = 0
|
|
max_latency_ms: float = 0.0
|
|
avg_latency_ms: float = 0.0
|
|
samples: list[float] = field(default_factory=list)
|
|
started_at: float = field(default_factory=time.perf_counter)
|
|
|
|
def record(self, hop: TraceHop) -> None:
|
|
self.current_hop = max(self.current_hop, hop.hop)
|
|
if hop.status == "timeout":
|
|
self.timeout_hops += 1
|
|
elif hop.status == "high_latency":
|
|
self.high_latency_hops += 1
|
|
elif hop.status == "jitter":
|
|
self.jitter_hops += 1
|
|
if hop.avg_ms:
|
|
self.samples.append(hop.avg_ms)
|
|
self.max_latency_ms = max(self.max_latency_ms, hop.avg_ms)
|
|
self.avg_latency_ms = sum(self.samples) / len(self.samples)
|
|
|
|
def snapshot(self, state: str) -> dict:
|
|
return {
|
|
"state": state,
|
|
"current_run": self.current_run,
|
|
"total_runs": self.total_runs,
|
|
"current_hop": self.current_hop,
|
|
"max_hops": self.max_hops,
|
|
"timeout_hops": self.timeout_hops,
|
|
"high_latency_hops": self.high_latency_hops,
|
|
"jitter_hops": self.jitter_hops,
|
|
"max_latency_ms": self.max_latency_ms,
|
|
"avg_latency_ms": self.avg_latency_ms,
|
|
"elapsed": time.perf_counter() - self.started_at,
|
|
}
|
|
|
|
|
|
class TracertFun:
|
|
def __init__(
|
|
self,
|
|
output: OutputCallback,
|
|
done: Optional[DoneCallback] = None,
|
|
status: Optional[StatusCallback] = None,
|
|
result: Optional[ResultCallback] = None,
|
|
):
|
|
self.output = output
|
|
self.done = done or (lambda: None)
|
|
self.status = status or (lambda _stats: None)
|
|
self.result = result or (lambda _row: None)
|
|
self.process = None
|
|
self.stop_event = threading.Event()
|
|
self.worker = None
|
|
self.last_results: list[dict] = []
|
|
self.last_summary = ""
|
|
|
|
def start_tracert(self, target: str, max_hops: int = 20, timeout_ms: int = 800, options: Optional[dict] = None) -> None:
|
|
target = validate_host(target)
|
|
trace_options = self.normalize_options(options or {})
|
|
trace_options.max_hops = clamp_int(max_hops, 1, 64, "最大跳数")
|
|
trace_options.timeout_ms = clamp_int(timeout_ms, 100, 60000, "单跳超时")
|
|
|
|
if self.is_running():
|
|
raise RuntimeError("路由追踪正在运行,请先停止当前任务")
|
|
|
|
self.stop_event.clear()
|
|
self.last_results = []
|
|
self.last_summary = ""
|
|
total_runs = 0 if trace_options.mode == "持续" else trace_options.repeat_count
|
|
stats = TraceStats(total_runs=total_runs, max_hops=trace_options.max_hops)
|
|
self.status(stats.snapshot("运行中"))
|
|
self.output(f"开始路由追踪: {target}\n", "muted")
|
|
self.output(self.describe_options(trace_options), "muted")
|
|
self.worker = threading.Thread(target=self._run_loop, args=(target, trace_options, stats), daemon=True)
|
|
self.worker.start()
|
|
|
|
def _run_loop(self, target: str, options: TraceOptions, stats: TraceStats) -> None:
|
|
run = 0
|
|
max_runs = None if options.mode == "持续" else options.repeat_count
|
|
try:
|
|
while not self.stop_event.is_set() and (max_runs is None or run < max_runs):
|
|
run += 1
|
|
stats.current_run = run
|
|
stats.current_hop = 0
|
|
self.output(f"\n==== 第 {run} 次追踪 ====\n", "muted")
|
|
self.status(stats.snapshot("运行中"))
|
|
self._run_once(target, options, stats, run)
|
|
if self.stop_event.is_set() or (max_runs is not None and run >= max_runs):
|
|
break
|
|
self.stop_event.wait(options.interval_ms / 1000)
|
|
finally:
|
|
state = "已停止" if self.stop_event.is_set() else "已完成"
|
|
self.status(stats.snapshot(state))
|
|
self._write_overall_summary(state)
|
|
self.done()
|
|
|
|
def _run_once(self, target: str, options: TraceOptions, stats: TraceStats, run: int) -> None:
|
|
command = self.build_command(target, options)
|
|
run_results = []
|
|
try:
|
|
self.process = popen_hidden(command)
|
|
if not self.process.stdout:
|
|
return
|
|
for raw_line in self.process.stdout:
|
|
if self.stop_event.is_set():
|
|
break
|
|
line = raw_line.rstrip()
|
|
self.output(raw_line, None)
|
|
hop = parse_trace_line(line, run, options.high_latency_ms)
|
|
if not hop:
|
|
continue
|
|
row = hop.to_dict()
|
|
self.last_results.append(row)
|
|
run_results.append(row)
|
|
stats.record(hop)
|
|
self.result(row)
|
|
self.status(stats.snapshot("运行中"))
|
|
except Exception as exc:
|
|
self.output(f"\n路由追踪失败: {exc}\n", "error")
|
|
finally:
|
|
if self.process:
|
|
try:
|
|
if self.stop_event.is_set():
|
|
self.process.terminate()
|
|
else:
|
|
self.process.wait(timeout=1)
|
|
except Exception:
|
|
pass
|
|
self.process = None
|
|
self._write_run_summary(run_results, run)
|
|
|
|
def build_command(self, target: str, options: TraceOptions) -> list[str]:
|
|
command = ["tracert"]
|
|
if not options.resolve_names:
|
|
command.append("-d")
|
|
if options.address_family == "IPv4":
|
|
command.append("-4")
|
|
elif options.address_family == "IPv6":
|
|
command.append("-6")
|
|
command += ["-w", str(options.timeout_ms), "-h", str(options.max_hops), target]
|
|
return command
|
|
|
|
def describe_options(self, options: TraceOptions) -> str:
|
|
runs = "持续" if options.mode == "持续" else f"{options.repeat_count} 次"
|
|
names = "开启" if options.resolve_names else "关闭"
|
|
return (
|
|
f"模式: {options.mode}({runs}) 地址族: {options.address_family} 最大跳数: {options.max_hops} "
|
|
f"单跳超时: {options.timeout_ms}ms 间隔: {options.interval_ms}ms "
|
|
f"解析主机名: {names} 高延迟阈值: {options.high_latency_ms}ms\n"
|
|
)
|
|
|
|
def stop_tracert(self) -> None:
|
|
if not self.is_running():
|
|
raise RuntimeError("当前没有正在运行的路由追踪")
|
|
self.stop_event.set()
|
|
if self.process:
|
|
try:
|
|
self.process.terminate()
|
|
except Exception:
|
|
pass
|
|
self.output("\n正在停止路由追踪...\n", "warning")
|
|
|
|
def export_results(self, path: str) -> None:
|
|
if not self.last_results:
|
|
raise RuntimeError("还没有可导出的路由追踪结果")
|
|
fields = [
|
|
"run",
|
|
"hop",
|
|
"probe1",
|
|
"probe2",
|
|
"probe3",
|
|
"avg_ms",
|
|
"jitter_ms",
|
|
"host",
|
|
"ip",
|
|
"status",
|
|
"status_text",
|
|
"raw_line",
|
|
"checked_at",
|
|
]
|
|
with open(path, "w", newline="", encoding="utf-8-sig") as file:
|
|
writer = csv.DictWriter(file, fieldnames=fields)
|
|
writer.writeheader()
|
|
for row in self.last_results:
|
|
writer.writerow({field: row.get(field, "") for field in fields})
|
|
|
|
def copy_summary(self) -> str:
|
|
return self.last_summary
|
|
|
|
def normalize_options(self, options: dict) -> TraceOptions:
|
|
mode = options.get("mode", "单次")
|
|
if mode not in {"单次", "指定次数", "持续"}:
|
|
mode = "单次"
|
|
repeat_count = 1 if mode == "单次" else clamp_int(options.get("repeat_count", 3), 1, 1000, "追踪次数")
|
|
address_family = options.get("address_family", "自动")
|
|
if address_family not in {"自动", "IPv4", "IPv6"}:
|
|
address_family = "自动"
|
|
return TraceOptions(
|
|
address_family=address_family,
|
|
resolve_names=bool(options.get("resolve_names", False)),
|
|
mode=mode,
|
|
repeat_count=repeat_count,
|
|
interval_ms=clamp_int(options.get("interval_ms", 1000), 0, 60000, "追踪间隔"),
|
|
high_latency_ms=clamp_int(options.get("high_latency_ms", 100), 1, 10000, "高延迟阈值"),
|
|
)
|
|
|
|
def _write_run_summary(self, rows: list[dict], run: int) -> None:
|
|
if not rows:
|
|
return
|
|
summary = build_diagnosis(rows)
|
|
self.last_summary = summary
|
|
self.output(f"\n==== 第 {run} 次诊断摘要 ====\n{summary}\n", "success" if "整体正常" in summary else "warning")
|
|
|
|
def _write_overall_summary(self, state: str) -> None:
|
|
if self.stop_event.is_set():
|
|
self.output("\n路由追踪已停止\n", "warning")
|
|
self.last_summary = "路由追踪已停止。"
|
|
return
|
|
if not self.last_results:
|
|
self.output("\n路由追踪完成,但没有解析到跳点结果\n", "warning")
|
|
self.last_summary = "路由追踪完成,但没有解析到跳点结果。"
|
|
return
|
|
self.last_summary = build_diagnosis(self.last_results)
|
|
self.output(f"\n路由追踪{state}\n", "success")
|
|
|
|
def is_running(self) -> bool:
|
|
return bool(self.worker and self.worker.is_alive())
|
|
|
|
|
|
def parse_trace_line(line: str, run: int = 1, high_latency_ms: int = 100) -> Optional[TraceHop]:
|
|
match = re.match(r"^\s*(\d+)\s+(.+)$", line)
|
|
if not match:
|
|
return None
|
|
|
|
hop = int(match.group(1))
|
|
rest = match.group(2)
|
|
probe_matches = list(re.finditer(r"\*|<\s*\d+\s*(?:ms|毫秒)|\d+\s*(?:ms|毫秒)", rest, flags=re.IGNORECASE))
|
|
if not probe_matches:
|
|
return None
|
|
|
|
probes = []
|
|
values = []
|
|
for probe_match in probe_matches[:3]:
|
|
raw = normalize_probe_text(probe_match.group(0))
|
|
probes.append(raw)
|
|
value = probe_to_ms(raw)
|
|
if value is not None:
|
|
values.append(value)
|
|
while len(probes) < 3:
|
|
probes.append("*")
|
|
|
|
endpoint = rest[probe_matches[min(len(probe_matches), 3) - 1].end() :].strip()
|
|
host, ip = parse_endpoint(endpoint)
|
|
avg_ms = sum(values) / len(values) if values else 0.0
|
|
jitter_ms = max(values) - min(values) if len(values) > 1 else 0.0
|
|
status = classify_hop(values, avg_ms, jitter_ms, high_latency_ms)
|
|
return TraceHop(
|
|
run=run,
|
|
hop=hop,
|
|
probe1=probes[0],
|
|
probe2=probes[1],
|
|
probe3=probes[2],
|
|
avg_ms=avg_ms,
|
|
jitter_ms=jitter_ms,
|
|
host=host,
|
|
ip=ip,
|
|
status=status,
|
|
status_text=TRACE_STATUS[status],
|
|
raw_line=line,
|
|
)
|
|
|
|
|
|
def normalize_probe_text(text: str) -> str:
|
|
text = re.sub(r"\s+", "", text.strip())
|
|
text = text.replace("毫秒", "ms")
|
|
return text.replace("MS", "ms").replace("Ms", "ms")
|
|
|
|
|
|
def probe_to_ms(text: str) -> Optional[float]:
|
|
if text == "*":
|
|
return None
|
|
if text.startswith("<"):
|
|
return 1.0
|
|
match = re.search(r"\d+(?:\.\d+)?", text)
|
|
return float(match.group(0)) if match else None
|
|
|
|
|
|
def parse_endpoint(endpoint: str) -> tuple[str, str]:
|
|
cleaned = endpoint.strip()
|
|
if not cleaned or re.search(r"请求超时|request timed out", cleaned, re.IGNORECASE):
|
|
return "", ""
|
|
bracket_match = re.match(r"(.+?)\s+\[([^\]]+)\]$", cleaned)
|
|
if bracket_match:
|
|
return bracket_match.group(1).strip(), bracket_match.group(2).strip()
|
|
return "", cleaned
|
|
|
|
|
|
def classify_hop(values: list[float], avg_ms: float, jitter_ms: float, high_latency_ms: int) -> str:
|
|
if not values:
|
|
return "timeout"
|
|
if avg_ms >= high_latency_ms:
|
|
return "high_latency"
|
|
if jitter_ms >= max(30, high_latency_ms * 0.5):
|
|
return "jitter"
|
|
return "ok"
|
|
|
|
|
|
def build_diagnosis(rows: list[dict]) -> str:
|
|
if not rows:
|
|
return "没有解析到可诊断的跳点。"
|
|
|
|
latest_run = max(int(row.get("run") or 1) for row in rows)
|
|
latest = [row for row in rows if int(row.get("run") or 1) == latest_run]
|
|
latest.sort(key=lambda row: int(row.get("hop") or 0))
|
|
|
|
timeout_hops = [row for row in latest if row.get("status") == "timeout"]
|
|
high_hops = [row for row in latest if row.get("status") == "high_latency"]
|
|
jitter_hops = [row for row in latest if row.get("status") == "jitter"]
|
|
ok_after_timeout = False
|
|
for index, row in enumerate(latest):
|
|
if row.get("status") == "timeout" and any(next_row.get("status") != "timeout" for next_row in latest[index + 1 :]):
|
|
ok_after_timeout = True
|
|
break
|
|
|
|
lines = [
|
|
f"本次共解析 {len(latest)} 跳,超时 {len(timeout_hops)} 跳,高延迟 {len(high_hops)} 跳,波动 {len(jitter_hops)} 跳。"
|
|
]
|
|
if latest and latest[0].get("status") == "timeout":
|
|
lines.append("第一跳无响应,优先检查本机网关、防火墙或本地网络。")
|
|
if ok_after_timeout:
|
|
lines.append("中间跳点超时但后续恢复,通常是中间路由器限制 ICMP,不一定代表链路故障。")
|
|
if len(latest) >= 2 and latest[-1].get("status") == "timeout" and latest[-2].get("status") == "timeout":
|
|
lines.append("末段连续超时,目标侧网络、跨网链路或目标防火墙可能存在限制。")
|
|
if high_hops:
|
|
detail = ", ".join(f"第 {row['hop']} 跳 {row.get('avg_ms', 0):.1f}ms" for row in high_hops[:5])
|
|
lines.append(f"发现高延迟跳点: {detail}。")
|
|
if jitter_hops:
|
|
detail = ", ".join(f"第 {row['hop']} 跳抖动 {row.get('jitter_ms', 0):.1f}ms" for row in jitter_hops[:5])
|
|
lines.append(f"发现延迟波动: {detail}。")
|
|
if not timeout_hops and not high_hops and not jitter_hops:
|
|
lines.append("整体正常,未发现明显超时、高延迟或波动。")
|
|
return "\n".join(lines)
|
|
|
|
|
|
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
|