This commit is contained in:
qsc
2026-06-06 02:37:14 +08:00
parent 129dc41208
commit 00dd907f65
10 changed files with 2328 additions and 61 deletions
+363 -16
View File
@@ -1,4 +1,8 @@
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
@@ -6,53 +10,208 @@ 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):
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) -> None:
def start_tracert(self, target: str, max_hops: int = 20, timeout_ms: int = 800, options: Optional[dict] = None) -> None:
target = validate_host(target)
max_hops = max(1, min(int(max_hops), 64))
timeout_ms = max(100, min(int(timeout_ms), 10000))
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()
command = ["tracert", "-d", "-w", str(timeout_ms), "-h", str(max_hops), target]
self.output(f"开始路由追踪: {target},最大 {max_hops} 跳,超时 {timeout_ms}ms\n\n", "muted")
self.worker = threading.Thread(target=self._run, args=(command,), daemon=True)
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(self, command) -> None:
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 line in self.process.stdout:
for raw_line in self.process.stdout:
if self.stop_event.is_set():
break
self.output(line, None)
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:
self.process.terminate()
if self.stop_event.is_set():
self.process.terminate()
else:
self.process.wait(timeout=1)
except Exception:
pass
self.process = None
if self.stop_event.is_set():
self.output("\n路由追踪已停止\n", "warning")
else:
self.output("\n路由追踪完成\n", "success")
self.done()
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():
@@ -65,5 +224,193 @@ class TracertFun:
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