From 18e33656c0ae2b06c48f56585b23d77a1207c05c Mon Sep 17 00:00:00 2001 From: Andreas Wrede Date: Mon, 17 Aug 2026 09:13:49 -0400 Subject: [PATCH] fix: preserve chart history across reconnects, show gaps for missing data Only wipe real (non-RTT) plugin data on an actual client reboot (boot flag), not on every ordinary OVERDUE/DOWN -> UP recovery. A transient network blip no longer erases CPU/memory/etc. history. Also split the shared time-series chart into separate line/area segments wherever the gap between samples is much larger than the typical spacing, so missing data (host overdue, or history that simply hasn't accumulated across a drop) renders as a visual gap instead of an interpolated line. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01DEimzMv4Q5EjFg3hoiZ69T --- hbd/server/templates/plugins.html | 38 ++++++++++++++++++++++------ hbd/server/udp.py | 18 +++++++------ tests/test_udp_rtt_history.py | 42 ++++++++++++++++++++++++++----- 3 files changed, 77 insertions(+), 21 deletions(-) diff --git a/hbd/server/templates/plugins.html b/hbd/server/templates/plugins.html index 07f36dc..bca788b 100644 --- a/hbd/server/templates/plugins.html +++ b/hbd/server/templates/plugins.html @@ -847,16 +847,38 @@ const yRange = yHigh - yLow || 1; const y = v => PAD.top + cH - ((v - yLow) / yRange) * cH; - // Build polyline points and filled area path - const linePoints = pts.map(p => `${x(p.t).toFixed(1)},${y(p.v).toFixed(1)}`).join(' '); - const areaPath = `M${x(pts[0].t).toFixed(1)},${(PAD.top + cH).toFixed(1)} ` + - pts.map(p => `L${x(p.t).toFixed(1)},${y(p.v).toFixed(1)}`).join(' ') + - ` L${x(pts[pts.length-1].t).toFixed(1)},${(PAD.top + cH).toFixed(1)} Z`; - // Color based on latest value const latest = pts[pts.length - 1].v; const { stroke: strokeColor, fill: fillColor } = opts.colorFor(latest); + // Split into segments wherever the gap between consecutive samples is + // much larger than the typical spacing (e.g. the host was overdue/down + // for a while) — draw each segment separately so missing data reads as + // a visual gap instead of an interpolated line across dead time. + const deltas = []; + for (let i = 1; i < pts.length; i++) deltas.push(pts[i].t - pts[i - 1].t); + deltas.sort((a, b) => a - b); + const medianDelta = deltas[Math.floor(deltas.length / 2)]; + const gapThreshold = medianDelta * 2.5; + + const segments = [[pts[0]]]; + for (let i = 1; i < pts.length; i++) { + if (pts[i].t - pts[i - 1].t > gapThreshold) segments.push([]); + segments[segments.length - 1].push(pts[i]); + } + + let linePolylines = ''; + let areaPaths = ''; + for (const seg of segments) { + if (seg.length < 2) continue; + const segPoints = seg.map(p => `${x(p.t).toFixed(1)},${y(p.v).toFixed(1)}`).join(' '); + linePolylines += ``; + const segArea = `M${x(seg[0].t).toFixed(1)},${(PAD.top + cH).toFixed(1)} ` + + seg.map(p => `L${x(p.t).toFixed(1)},${y(p.v).toFixed(1)}`).join(' ') + + ` L${x(seg[seg.length-1].t).toFixed(1)},${(PAD.top + cH).toFixed(1)} Z`; + areaPaths += ``; + } + // Compute nice tick step for ~3-5 grid lines const rawStep = yRange / 4; const mag = Math.pow(10, Math.floor(Math.log10(rawStep || 1))); @@ -890,8 +912,8 @@ - - + ${areaPaths} + ${linePolylines} ${xLabels} `; diff --git a/hbd/server/udp.py b/hbd/server/udp.py index b901a9e..f3bcd63 100644 --- a/hbd/server/udp.py +++ b/hbd/server/udp.py @@ -521,13 +521,17 @@ def handle_datagram(msg: dict, addr, transport, ctx: dict): # Transition to UP and log/notify if appropriate lasts = conn.state d = conn.newstate(hbdcls.Connection.UP, now) - # On reboot, pre-boot plugin data and derived alerts are stale. - # Cancel all plugin timers and wipe plugin state so timers restart - # cleanly from the first two post-boot samples. - for pname in list(host.plugin_timers): - host.cancel_plugin_timer(pname) - for pname in [k for k in host.plugin_data if not _is_rtt_key(k)]: - del host.plugin_data[pname] + if boot: + # On reboot, pre-boot plugin data and derived alerts are stale. + # Cancel all plugin timers and wipe plugin state so timers restart + # cleanly from the first two post-boot samples. An ordinary + # reconnect (no boot flag) doesn't invalidate the client's + # already-collected data, so it's left alone — this keeps chart + # history intact across a transient network blip. + for pname in list(host.plugin_timers): + host.cancel_plugin_timer(pname) + for pname in [k for k in host.plugin_data if not _is_rtt_key(k)]: + del host.plugin_data[pname] stale_plugin_keys = [ k for k in host.alert_states if k not in ("rtt",) and not k.startswith("connectivity.") diff --git a/tests/test_udp_rtt_history.py b/tests/test_udp_rtt_history.py index 7f2de63..38e80a7 100644 --- a/tests/test_udp_rtt_history.py +++ b/tests/test_udp_rtt_history.py @@ -14,10 +14,12 @@ class _FakeTransport: self.sent.append((data, addr)) -def _htb(name, rtt=None, interval=0): +def _htb(name, rtt=None, interval=0, boot=0): d = {"name": name, "interval": interval, "id": 0} if rtt is not None: d["rtt"] = rtt + if boot: + d["boot"] = boot return parse_message(dicttos("HTB", d)) @@ -94,10 +96,11 @@ def test_request_update_fires_on_recovery_even_with_rtt_history(): assert ack.get("request_update") -def test_recovery_clears_real_plugin_data_but_preserves_rtt_history(): - """Regression for Finding 2: host.plugin_data.clear() on recovery must - wipe real client-collected plugin data while leaving rtt_* history - samples intact. +def test_ordinary_recovery_preserves_real_plugin_data_and_rtt_history(): + """An ordinary reconnect (no boot flag) — e.g. OVERDUE->UP after a + transient network blip — must NOT wipe already-collected real plugin + data (e.g. cpu_monitor, os_info) or rtt_* history. Only an actual + client reboot invalidates that data (see the boot-flag test below). """ hbdclass.Host.hosts.pop("rtt-hist-host5", None) transport = _FakeTransport() @@ -116,8 +119,35 @@ def test_recovery_clears_real_plugin_data_but_preserves_rtt_history(): host.add_plugin_data("os_info", {"os": "linux"}, timestamp=time.time()) assert "os_info" in host.plugin_data - # Recovery heartbeat. + # Ordinary recovery heartbeat — no boot flag. handle_datagram(_htb("rtt-hist-host5", rtt=13.0), ("127.0.0.1", 50000), transport, ctx) + assert "os_info" in host.plugin_data + assert len(host.plugin_data["rtt_ipv4"]) == 4 + + +def test_boot_recovery_clears_real_plugin_data_but_preserves_rtt_history(): + """A recovery heartbeat carrying the boot flag (client process actually + restarted) must still wipe stale real plugin data, while rtt_* history + (still a valid measurement, unaffected by a client reboot) survives. + """ + hbdclass.Host.hosts.pop("rtt-hist-host6", None) + transport = _FakeTransport() + ctx = _base_ctx() + + for rtt in (10.0, 11.0, 12.0): + handle_datagram(_htb("rtt-hist-host6", rtt=rtt), ("127.0.0.1", 50000), transport, ctx) + + host = hbdclass.Host.hosts["rtt-hist-host6"] + assert len(host.plugin_data["rtt_ipv4"]) == 3 + + conn = host.connections["IPv4"] + conn.state = hbdclass.Connection.DOWN + host.add_plugin_data("os_info", {"os": "linux"}, timestamp=time.time()) + assert "os_info" in host.plugin_data + + # Recovery heartbeat with boot=1 — client process actually restarted. + handle_datagram(_htb("rtt-hist-host6", rtt=13.0, boot=1), ("127.0.0.1", 50000), transport, ctx) + assert "os_info" not in host.plugin_data assert len(host.plugin_data["rtt_ipv4"]) == 4