feat(mini): send p4/p6 presence flags and connectivity metric from hbc_mini.py

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uc9V1v7iXruubpoLiSE24M
This commit is contained in:
2026-09-22 04:31:22 -04:00
co-authored by Claude Sonnet 5
parent 997279dce8
commit 3639464ba6
2 changed files with 116 additions and 17 deletions
+40 -16
View File
@@ -10,14 +10,20 @@ Built-in plugins (always available):
os_info — OS name, kernel, distro, Python version os_info — OS name, kernel, distro, Python version
ping_monitor — ICMP ping RTT to configured hosts ping_monitor — ICMP ping RTT to configured hosts
nagios_runner — run Nagios-compatible check scripts nagios_runner — run Nagios-compatible check scripts
network_monitor — per-interface TX/RX bytes/s (Linux); per-protocol connectivity (all platforms)
Linux-only plugins (via /proc, no extra tools needed): Linux-only plugins (via /proc, no extra tools needed):
cpu_monitor — CPU %, load average, core count cpu_monitor — CPU %, load average, core count
memory_monitor — RAM and swap usage memory_monitor — RAM and swap usage
network_monitor — per-interface TX/RX bytes/s
Cross-platform (via df subprocess): Cross-platform (via df subprocess):
disk_monitor — per-mount usage and capacity disk_monitor — per-mount usage and capacity
Presence probe (built in): reports per protocol (IPv4/IPv6) whether the host has internet
connectivity, as p4/p6 flags on every heartbeat, so hbd stops treating a missing IPv6 (or
IPv4) path as an outage. Config, in the network_monitor section of ~/.hbc.json:
probe_enabled (default true), probe_v4 (default 1.1.1.1), probe_v6 (default
2606:4700:4700::1111; IP literals only), probe_interval (default 60 s)
""" """
import argparse import argparse
@@ -969,7 +975,7 @@ def _read_net_dev() -> Dict[str, Tuple[int, int]]:
class NetworkMonitorPlugin(MonitorPlugin): class NetworkMonitorPlugin(MonitorPlugin):
name = "network_monitor" name = "network_monitor"
description = "Network I/O rates via /proc/net/dev (Linux)" description = "Network I/O rates via /proc/net/dev (Linux) and per-protocol internet connectivity"
interval = 300 interval = 300
def __init__(self, config: Optional[Dict[str, Any]] = None): def __init__(self, config: Optional[Dict[str, Any]] = None):
@@ -978,19 +984,25 @@ class NetworkMonitorPlugin(MonitorPlugin):
self.interval = cfg.get("interval", 300) self.interval = cfg.get("interval", 300)
self.skip_ifaces: set = set(cfg.get("skip_interfaces", ["lo"])) self.skip_ifaces: set = set(cfg.get("skip_interfaces", ["lo"]))
self._prev: Optional[Tuple[float, Dict[str, Tuple[int, int]]]] = None self._prev: Optional[Tuple[float, Dict[str, Tuple[int, int]]]] = None
self._have_dev = False
async def initialize(self) -> bool: async def initialize(self) -> bool:
if platform.system() != "Linux": dev = _read_net_dev() if platform.system() == "Linux" else {}
self.skip_reason = "Linux only (/proc/net/dev not available)" self._have_dev = bool(dev)
return False if not self._have_dev and _probe is None:
dev = _read_net_dev() self.skip_reason = "no /proc/net/dev (Linux only) and the connectivity probe is disabled"
if not dev:
self.skip_reason = "/proc/net/dev not readable"
return False return False
if self._have_dev:
self._prev = (time.time(), dev) # prime first delta self._prev = (time.time(), dev) # prime first delta
return True return True
async def _collect_metrics(self) -> Dict[str, Any]: async def _collect_metrics(self) -> Dict[str, Any]:
data: Dict[str, Any] = await self._collect_interfaces() if self._have_dev else {}
if _probe is not None:
data["connectivity"] = _probe.as_metric()
return data
async def _collect_interfaces(self) -> Dict[str, Any]:
now = time.time() now = time.time()
curr = _read_net_dev() curr = _read_net_dev()
if not curr or self._prev is None: if not curr or self._prev is None:
@@ -1251,15 +1263,21 @@ async def _handle_update(conn: AsyncConnection):
# Heartbeat sender # Heartbeat sender
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
async def _heartbeat_sender(conn: AsyncConnection, interval: int): def _build_heartbeat_msg(conn: AsyncConnection, interval: int,
probe: Optional[_Connectivity] = None) -> Dict[str, Any]:
"""HTB payload; carries p4/p6 presence flags when the probe knows them."""
msg: Dict[str, Any] = {"acks": conn.ackcount, "rtt": conn.rtts[-1], "interval": interval}
if probe is not None:
msg.update(probe.flags())
return msg
async def _heartbeat_sender(conn: AsyncConnection, interval: int,
probe: Optional[_Connectivity] = None):
log = logging.getLogger("hbc.hb") log = logging.getLogger("hbc.hb")
while _running: while _running:
try: try:
await conn.sendto({ await conn.sendto(_build_heartbeat_msg(conn, interval, probe))
"acks": conn.ackcount,
"rtt": conn.rtts[-1],
"interval": interval,
})
except Exception as e: except Exception as e:
log.error("send: %s", e) log.error("send: %s", e)
await _sleep(interval) await _sleep(interval)
@@ -1367,7 +1385,7 @@ def _reconfigure_syslog(level: int):
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
async def _async_main(args, cfg: Dict[str, Any]) -> int: async def _async_main(args, cfg: Dict[str, Any]) -> int:
global _running, _shutdown_event, _active_tasks, send_shutdown global _running, _shutdown_event, _active_tasks, send_shutdown, _probe
_running = True _running = True
_shutdown_event = asyncio.Event() _shutdown_event = asyncio.Event()
_active_tasks = [] _active_tasks = []
@@ -1424,6 +1442,10 @@ async def _async_main(args, cfg: Dict[str, Any]) -> int:
c.close() c.close()
return 0 return 0
# Per-protocol internet presence probe (shared with the network_monitor plugin)
net_cfg = dict((cfg.get("plugins") or {}).get("network_monitor") or cfg.get("network_monitor") or {})
_probe = _Connectivity.from_config(net_cfg) if net_cfg.get("probe_enabled", True) else None
plugins = await _load_plugins(cfg) plugins = await _load_plugins(cfg)
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
@@ -1437,8 +1459,10 @@ async def _async_main(args, cfg: Dict[str, Any]) -> int:
loop.add_signal_handler(signal.SIGHUP, _sighup) loop.add_signal_handler(signal.SIGHUP, _sighup)
if _probe is not None:
_active_tasks.append(asyncio.create_task(_probe.run()))
for conn in connections: for conn in connections:
_active_tasks.append(asyncio.create_task(_heartbeat_sender(conn, interval))) _active_tasks.append(asyncio.create_task(_heartbeat_sender(conn, interval, _probe)))
if plugins and connections: if plugins and connections:
_active_tasks.append(asyncio.create_task(_plugin_collector(connections[0], plugins))) _active_tasks.append(asyncio.create_task(_plugin_collector(connections[0], plugins)))
+75
View File
@@ -304,3 +304,78 @@ def test_local_address_on_this_host_never_raises():
except mini._ProbeUnavailable: except mini._ProbeUnavailable:
continue continue
assert r is None or isinstance(r, str) assert r is None or isinstance(r, str)
# ---- heartbeat message and plugin wiring ---------------------------------------------
class _Conn:
ackcount = 3
rtts = [0.0, 12.5]
class _FlagsProbe:
def __init__(self, flags):
self._flags = flags
def flags(self):
return self._flags
def test_heartbeat_without_probe_is_unchanged():
assert mini._build_heartbeat_msg(_Conn(), 60) == {"acks": 3, "rtt": 12.5, "interval": 60}
def test_heartbeat_carries_presence_flags():
msg = mini._build_heartbeat_msg(_Conn(), 60, _FlagsProbe({"p4": True, "p6": False}))
assert msg == {"acks": 3, "rtt": 12.5, "interval": 60, "p4": True, "p6": False}
def test_heartbeat_omits_unknown_flags():
msg = mini._build_heartbeat_msg(_Conn(), 60, _FlagsProbe({}))
assert "p4" not in msg and "p6" not in msg
def test_flags_survive_the_wire_encoding():
msg = mini._build_heartbeat_msg(_Conn(), 60, _FlagsProbe({"p4": True, "p6": False}))
out = mini._stodict(mini._dicttos("HTB", dict(msg, name="h", id=1)))
assert out["p4"] == 1 and out["p6"] == 0
class _FakeProbe:
def as_metric(self):
return {"ipv4": {"present": True, "addr": "192.0.2.5", "gateway": "192.0.2.1", "rtt": 9.0},
"ipv6": {"present": False, "addr": None, "gateway": None, "rtt": None}}
def test_plugin_reports_connectivity_alongside_interfaces(monkeypatch):
monkeypatch.setattr(mini, "_probe", _FakeProbe())
monkeypatch.setattr(mini.platform, "system", lambda: "Linux")
plugin = mini.NetworkMonitorPlugin({})
assert asyncio.run(plugin.initialize()) is True
data = asyncio.run(plugin._collect_metrics())
assert data["connectivity"]["ipv4"]["present"] is True
assert data["connectivity"]["ipv6"]["present"] is False
def test_plugin_probe_only_mode_on_non_linux(monkeypatch):
monkeypatch.setattr(mini, "_probe", _FakeProbe())
monkeypatch.setattr(mini.platform, "system", lambda: "FreeBSD")
plugin = mini.NetworkMonitorPlugin({})
assert asyncio.run(plugin.initialize()) is True
assert list(asyncio.run(plugin._collect_metrics())) == ["connectivity"]
def test_plugin_still_skips_on_non_linux_without_probe(monkeypatch):
monkeypatch.setattr(mini, "_probe", None)
monkeypatch.setattr(mini.platform, "system", lambda: "FreeBSD")
plugin = mini.NetworkMonitorPlugin({})
assert asyncio.run(plugin.initialize()) is False
assert plugin.skip_reason
def test_plugin_omits_connectivity_without_probe(monkeypatch):
monkeypatch.setattr(mini, "_probe", None)
monkeypatch.setattr(mini.platform, "system", lambda: "Linux")
plugin = mini.NetworkMonitorPlugin({})
assert asyncio.run(plugin.initialize()) is True
assert "connectivity" not in asyncio.run(plugin._collect_metrics())