feat: classify offline and degraded connectivity
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
"""Execute and parse multi-packet fping probes without shell interpolation."""
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
import re
|
||||
from typing import Iterable
|
||||
|
||||
|
||||
FPING_RESULT_PATTERN = re.compile(r"^\s*(\S+)\s+:\s*(.*?)\s*$")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProbeResult:
|
||||
"""Aggregate result for one IP in one monitoring round."""
|
||||
|
||||
ip: str
|
||||
sent_count: int
|
||||
received_count: int
|
||||
average_rtt_ms: float | None
|
||||
is_valid: bool
|
||||
failure_reason: str | None = None
|
||||
|
||||
@property
|
||||
def packet_loss_percent(self) -> float:
|
||||
"""Return packet loss as a percentage of the attempted probes."""
|
||||
if self.sent_count <= 0:
|
||||
return 0.0
|
||||
return (
|
||||
(self.sent_count - self.received_count)
|
||||
/ self.sent_count
|
||||
* 100.0
|
||||
)
|
||||
|
||||
|
||||
def _invalid_result(
|
||||
ip: str,
|
||||
packets_per_round: int,
|
||||
reason: str,
|
||||
) -> ProbeResult:
|
||||
"""Build an invalid aggregate that cannot resemble measured packet loss."""
|
||||
return ProbeResult(
|
||||
ip=ip,
|
||||
sent_count=packets_per_round,
|
||||
received_count=0,
|
||||
average_rtt_ms=None,
|
||||
is_valid=False,
|
||||
failure_reason=reason,
|
||||
)
|
||||
|
||||
|
||||
def parse_fping_count_output(
|
||||
output: str,
|
||||
expected_ips: set[str],
|
||||
packets_per_round: int,
|
||||
) -> dict[str, ProbeResult]:
|
||||
"""Parse ``fping -C`` reply tokens for every expected IP.
|
||||
|
||||
A numeric token is a measured RTT and ``-`` is packet loss. Missing,
|
||||
duplicated, or malformed lines are represented as invalid aggregates.
|
||||
"""
|
||||
if packets_per_round <= 0:
|
||||
raise ValueError("packets_per_round must be positive")
|
||||
|
||||
parsed_lines: dict[str, list[str]] = {}
|
||||
duplicate_ips: set[str] = set()
|
||||
for line in output.splitlines():
|
||||
match = FPING_RESULT_PATTERN.match(line)
|
||||
if match is None:
|
||||
continue
|
||||
ip, replies = match.groups()
|
||||
if ip not in expected_ips:
|
||||
continue
|
||||
if ip in parsed_lines:
|
||||
duplicate_ips.add(ip)
|
||||
continue
|
||||
parsed_lines[ip] = replies.split()
|
||||
|
||||
results: dict[str, ProbeResult] = {}
|
||||
for ip in expected_ips:
|
||||
tokens = parsed_lines.get(ip)
|
||||
if tokens is None:
|
||||
results[ip] = _invalid_result(
|
||||
ip,
|
||||
packets_per_round,
|
||||
"missing fping output",
|
||||
)
|
||||
continue
|
||||
if ip in duplicate_ips or len(tokens) != packets_per_round:
|
||||
results[ip] = _invalid_result(
|
||||
ip,
|
||||
packets_per_round,
|
||||
"malformed fping replies",
|
||||
)
|
||||
continue
|
||||
|
||||
reply_times: list[float] = []
|
||||
malformed = False
|
||||
for token in tokens:
|
||||
if token == "-":
|
||||
continue
|
||||
try:
|
||||
reply_time = float(token)
|
||||
except ValueError:
|
||||
malformed = True
|
||||
break
|
||||
if reply_time < 0:
|
||||
malformed = True
|
||||
break
|
||||
reply_times.append(reply_time)
|
||||
|
||||
if malformed:
|
||||
results[ip] = _invalid_result(
|
||||
ip,
|
||||
packets_per_round,
|
||||
"malformed fping replies",
|
||||
)
|
||||
continue
|
||||
|
||||
average_rtt_ms = (
|
||||
sum(reply_times) / len(reply_times)
|
||||
if reply_times
|
||||
else None
|
||||
)
|
||||
results[ip] = ProbeResult(
|
||||
ip=ip,
|
||||
sent_count=packets_per_round,
|
||||
received_count=len(reply_times),
|
||||
average_rtt_ms=average_rtt_ms,
|
||||
is_valid=True,
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
async def run_fping_count(
|
||||
ips: Iterable[str],
|
||||
packets_per_round: int,
|
||||
timeout_ms: int,
|
||||
executable: str,
|
||||
) -> dict[str, ProbeResult]:
|
||||
"""Run one fping count probe and return an aggregate for every IP."""
|
||||
ip_list = list(ips)
|
||||
if not ip_list:
|
||||
return {}
|
||||
|
||||
command = [
|
||||
executable,
|
||||
"-C",
|
||||
str(packets_per_round),
|
||||
"-q",
|
||||
"-t",
|
||||
str(timeout_ms),
|
||||
*ip_list,
|
||||
]
|
||||
try:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*command,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, stderr = await process.communicate()
|
||||
except Exception as error:
|
||||
reason = f"fping execution failed: {type(error).__name__}"
|
||||
return {
|
||||
ip: _invalid_result(ip, packets_per_round, reason)
|
||||
for ip in ip_list
|
||||
}
|
||||
|
||||
output = "\n".join(
|
||||
stream.decode("utf-8", errors="replace")
|
||||
for stream in (stdout, stderr)
|
||||
if stream
|
||||
)
|
||||
return parse_fping_count_output(
|
||||
output,
|
||||
set(ip_list),
|
||||
packets_per_round,
|
||||
)
|
||||
Reference in New Issue
Block a user