Compare commits

...
4 Commits
Author SHA1 Message Date
andreas f838920f7c version 5.4.3
Release / release (push) Successful in 1m16s
2026-08-17 13:54:02 -04:00
andreasandClaude Sonnet 5 45d11a66b5 fix: fix RTT y-axis label clipping, add x-axis tickmarks, enlarge charts
Y-axis labels on the RTT chart were truncated above 99ms because the
left margin was a fixed guess; it's now sized to the widest tick
label. The x-axis only showed start/end timestamps; added evenly
spaced intermediate tickmarks and labels. Plot area enlarged 15%.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 13:53:57 -04:00
andreas a42d783c0c version 5.4.2
Release / release (push) Successful in 1m10s
2026-08-17 09:24:23 -04:00
andreasandClaude Sonnet 5 18e33656c0 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DEimzMv4Q5EjFg3hoiZ69T
2026-08-17 09:13:49 -04:00
8 changed files with 131 additions and 44 deletions
+14
View File
@@ -2,6 +2,20 @@
All notable changes to this project are documented here, organized by release. All notable changes to this project are documented here, organized by release.
## [5.4.3]
### Fixed
- fix RTT y-axis label clipping, add x-axis tickmarks, enlarge charts
---
## [5.4.2]
### Fixed
- preserve chart history across reconnects, show gaps for missing data
---
## [5.4.1] ## [5.4.1]
### Added ### Added
+1 -1
View File
@@ -20,7 +20,7 @@ A lightweight UDP-based host monitoring system. Monitored hosts run a client (`h
└────────────────────┘ └────────────────────────────┘ └────────────────────┘ └────────────────────────────┘
``` ```
**Package:** `hbd` v5.4.1 **Package:** `hbd` v5.4.3
**Python:** 3.11+ **Python:** 3.11+
### Subpackages ### Subpackages
+1 -1
View File
@@ -14,4 +14,4 @@ Install options:
""" """
__all__ = ["__version__"] __all__ = ["__version__"]
__version__ = "5.4.1" __version__ = "5.4.3"
+66 -27
View File
@@ -827,13 +827,10 @@
if (!el || pts.length < 2) { if (el) el.style.display = 'none'; return; } if (!el || pts.length < 2) { if (el) el.style.display = 'none'; return; }
const unitSuffix = opts.unitSuffix || ''; const unitSuffix = opts.unitSuffix || '';
const W = 600, H = 80, PAD = { top: 6, right: 8, bottom: 18, left: 28 }; const W = 690, H = 92, PAD = { top: 6, right: 8, bottom: 18, left: 28 };
const cW = W - PAD.left - PAD.right;
const cH = H - PAD.top - PAD.bottom;
const tMin = pts[0].t, tMax = pts[pts.length - 1].t; const tMin = pts[0].t, tMax = pts[pts.length - 1].t;
const tRange = tMax - tMin || 1; const tRange = tMax - tMin || 1;
const x = t => PAD.left + ((t - tMin) / tRange) * cW;
// Auto-scale Y axis with 10% padding, optionally clamped to opts.yDomain // Auto-scale Y axis with 10% padding, optionally clamped to opts.yDomain
const vMin = Math.min(...pts.map(p => p.v)); const vMin = Math.min(...pts.map(p => p.v));
@@ -845,39 +842,80 @@
const yLow = Math.max(domainLow, vMin - vPad); const yLow = Math.max(domainLow, vMin - vPad);
const yHigh = Math.min(domainHigh, vMax + vPad); const yHigh = Math.min(domainHigh, vMax + vPad);
const yRange = yHigh - yLow || 1; const yRange = yHigh - yLow || 1;
const y = v => PAD.top + cH - ((v - yLow) / yRange) * cH;
// Build polyline points and filled area path // Compute nice tick step for ~3-5 grid lines, then size the left
const linePoints = pts.map(p => `${x(p.t).toFixed(1)},${y(p.v).toFixed(1)}`).join(' '); // margin to fit the widest label (values/units can run to 3+ digits,
const areaPath = `M${x(pts[0].t).toFixed(1)},${(PAD.top + cH).toFixed(1)} ` + // e.g. RTT samples above 99ms) instead of a fixed guess that clips them.
pts.map(p => `L${x(p.t).toFixed(1)},${y(p.v).toFixed(1)}`).join(' ') + const rawStep = yRange / 4;
` L${x(pts[pts.length-1].t).toFixed(1)},${(PAD.top + cH).toFixed(1)} Z`; const mag = Math.pow(10, Math.floor(Math.log10(rawStep || 1)));
const niceStep = [1, 2, 5, 10].map(f => f * mag).find(s => yRange / s <= 5) || mag * 10;
const tickStart = Math.ceil(yLow / niceStep) * niceStep;
const yTicks = [];
for (let v = tickStart; v <= yHigh + 0.001; v += niceStep) yTicks.push(v);
const yTickLabels = yTicks.map(v => (Number.isInteger(v) ? v : v.toFixed(1)) + unitSuffix);
const maxLabelLen = yTickLabels.reduce((m, s) => Math.max(m, s.length), 0);
PAD.left = Math.max(28, Math.ceil(maxLabelLen * 5.5) + 10);
const cW = W - PAD.left - PAD.right;
const cH = H - PAD.top - PAD.bottom;
const x = t => PAD.left + ((t - tMin) / tRange) * cW;
const y = v => PAD.top + cH - ((v - yLow) / yRange) * cH;
// Color based on latest value // Color based on latest value
const latest = pts[pts.length - 1].v; const latest = pts[pts.length - 1].v;
const { stroke: strokeColor, fill: fillColor } = opts.colorFor(latest); const { stroke: strokeColor, fill: fillColor } = opts.colorFor(latest);
// Compute nice tick step for ~3-5 grid lines // Split into segments wherever the gap between consecutive samples is
const rawStep = yRange / 4; // much larger than the typical spacing (e.g. the host was overdue/down
const mag = Math.pow(10, Math.floor(Math.log10(rawStep || 1))); // for a while) — draw each segment separately so missing data reads as
const niceStep = [1, 2, 5, 10].map(f => f * mag).find(s => yRange / s <= 5) || mag * 10; // a visual gap instead of an interpolated line across dead time.
const tickStart = Math.ceil(yLow / niceStep) * niceStep; const deltas = [];
let gridLines = ''; for (let i = 1; i < pts.length; i++) deltas.push(pts[i].t - pts[i - 1].t);
for (let v = tickStart; v <= yHigh + 0.001; v += niceStep) { deltas.sort((a, b) => a - b);
const yy = y(v).toFixed(1); const medianDelta = deltas[Math.floor(deltas.length / 2)];
const label = (Number.isInteger(v) ? v : v.toFixed(1)) + unitSuffix; const gapThreshold = medianDelta * 2.5;
gridLines += `<line x1="${PAD.left}" y1="${yy}" x2="${PAD.left + cW}" y2="${yy}" stroke="#e0e0e0" stroke-width="1"/>`;
gridLines += `<text x="${(PAD.left - 3).toFixed(1)}" y="${yy}" text-anchor="end" dominant-baseline="middle" font-size="8" fill="#999">${label}</text>`; 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]);
} }
// X-axis time labels 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 += `<polyline points="${segPoints}" fill="none" stroke="${strokeColor}" stroke-width="1.5" stroke-linejoin="round"/>`;
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 += `<path d="${segArea}" fill="${fillColor}" opacity="0.6"/>`;
}
let gridLines = '';
yTicks.forEach((v, i) => {
const yy = y(v).toFixed(1);
gridLines += `<line x1="${PAD.left}" y1="${yy}" x2="${PAD.left + cW}" y2="${yy}" stroke="#e0e0e0" stroke-width="1"/>`;
gridLines += `<text x="${(PAD.left - 3).toFixed(1)}" y="${yy}" text-anchor="end" dominant-baseline="middle" font-size="8" fill="#999">${yTickLabels[i]}</text>`;
});
// X-axis: start/end plus evenly spaced intermediate tickmarks + labels
const fmt = ts => { const fmt = ts => {
const d = new Date(ts * 1000); const d = new Date(ts * 1000);
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}; };
const xLabels = ` const xTickCount = 5;
<text x="${PAD.left}" y="${H - 2}" text-anchor="start" font-size="8" fill="#999">${fmt(pts[0].t)}</text> const xAxisY = (PAD.top + cH).toFixed(1);
<text x="${PAD.left + cW}" y="${H - 2}" text-anchor="end" font-size="8" fill="#999">${fmt(pts[pts.length-1].t)}</text>`; let xAxisMarks = '';
let xLabels = '';
for (let i = 0; i < xTickCount; i++) {
const t = tMin + (tRange * i) / (xTickCount - 1);
const xx = x(t).toFixed(1);
const anchor = i === 0 ? 'start' : (i === xTickCount - 1 ? 'end' : 'middle');
xAxisMarks += `<line x1="${xx}" y1="${xAxisY}" x2="${xx}" y2="${(PAD.top + cH + 3).toFixed(1)}" stroke="#ccc" stroke-width="1"/>`;
xLabels += `<text x="${xx}" y="${H - 2}" text-anchor="${anchor}" font-size="8" fill="#999">${fmt(t)}</text>`;
}
el.innerHTML = `<svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="none" el.innerHTML = `<svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="none"
style="width:100%;height:${H}px;display:block;"> style="width:100%;height:${H}px;display:block;">
@@ -890,9 +928,10 @@
<line x1="${PAD.left}" y1="${PAD.top}" x2="${PAD.left}" y2="${PAD.top + cH}" stroke="#ccc" stroke-width="1"/> <line x1="${PAD.left}" y1="${PAD.top}" x2="${PAD.left}" y2="${PAD.top + cH}" stroke="#ccc" stroke-width="1"/>
<line x1="${PAD.left}" y1="${PAD.top + cH}" x2="${PAD.left + cW}" y2="${PAD.top + cH}" stroke="#ccc" stroke-width="1"/> <line x1="${PAD.left}" y1="${PAD.top + cH}" x2="${PAD.left + cW}" y2="${PAD.top + cH}" stroke="#ccc" stroke-width="1"/>
<g clip-path="url(#${opts.clipId})"> <g clip-path="url(#${opts.clipId})">
<path d="${areaPath}" fill="${fillColor}" opacity="0.6"/> ${areaPaths}
<polyline points="${linePoints}" fill="none" stroke="${strokeColor}" stroke-width="1.5" stroke-linejoin="round"/> ${linePolylines}
</g> </g>
${xAxisMarks}
${xLabels} ${xLabels}
</svg>`; </svg>`;
} }
+11 -7
View File
@@ -521,13 +521,17 @@ def handle_datagram(msg: dict, addr, transport, ctx: dict):
# Transition to UP and log/notify if appropriate # Transition to UP and log/notify if appropriate
lasts = conn.state lasts = conn.state
d = conn.newstate(hbdcls.Connection.UP, now) d = conn.newstate(hbdcls.Connection.UP, now)
# On reboot, pre-boot plugin data and derived alerts are stale. if boot:
# Cancel all plugin timers and wipe plugin state so timers restart # On reboot, pre-boot plugin data and derived alerts are stale.
# cleanly from the first two post-boot samples. # Cancel all plugin timers and wipe plugin state so timers restart
for pname in list(host.plugin_timers): # cleanly from the first two post-boot samples. An ordinary
host.cancel_plugin_timer(pname) # reconnect (no boot flag) doesn't invalidate the client's
for pname in [k for k in host.plugin_data if not _is_rtt_key(k)]: # already-collected data, so it's left alone — this keeps chart
del host.plugin_data[pname] # 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 = [ stale_plugin_keys = [
k for k in host.alert_states k for k in host.alert_states
if k not in ("rtt",) and not k.startswith("connectivity.") if k not in ("rtt",) and not k.startswith("connectivity.")
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "hbd" name = "hbd"
version = "5.4.1" version = "5.4.3"
description = "Heartbeat monitoring system — client (hbc) and server (hbd)" description = "Heartbeat monitoring system — client (hbc) and server (hbd)"
readme = "README.md" readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"
+1 -1
View File
@@ -41,7 +41,7 @@ from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple
# updated by scripts/bumpminor.sh # updated by scripts/bumpminor.sh
__version__ = "5.4.1" __version__ = "5.4.3"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Protocol (mirrors hbd/common/proto.py) # Protocol (mirrors hbd/common/proto.py)
+36 -6
View File
@@ -14,10 +14,12 @@ class _FakeTransport:
self.sent.append((data, addr)) 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} d = {"name": name, "interval": interval, "id": 0}
if rtt is not None: if rtt is not None:
d["rtt"] = rtt d["rtt"] = rtt
if boot:
d["boot"] = boot
return parse_message(dicttos("HTB", d)) 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") assert ack.get("request_update")
def test_recovery_clears_real_plugin_data_but_preserves_rtt_history(): def test_ordinary_recovery_preserves_real_plugin_data_and_rtt_history():
"""Regression for Finding 2: host.plugin_data.clear() on recovery must """An ordinary reconnect (no boot flag) — e.g. OVERDUE->UP after a
wipe real client-collected plugin data while leaving rtt_* history transient network blip must NOT wipe already-collected real plugin
samples intact. 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) hbdclass.Host.hosts.pop("rtt-hist-host5", None)
transport = _FakeTransport() 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()) host.add_plugin_data("os_info", {"os": "linux"}, timestamp=time.time())
assert "os_info" in host.plugin_data 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) 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 "os_info" not in host.plugin_data
assert len(host.plugin_data["rtt_ipv4"]) == 4 assert len(host.plugin_data["rtt_ipv4"]) == 4