Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a42d783c0c | ||
|
|
18e33656c0 | ||
|
|
4ac5a34a43 | ||
|
|
6d39c5be47 | ||
|
|
cdb2183989 | ||
|
|
41c78e8e34 | ||
|
|
acf1eb8804 | ||
|
|
49b631f02d | ||
|
|
8204737ea6 | ||
|
|
5b2fc8fd46 | ||
|
|
70c3e93684 | ||
|
|
52968201fa | ||
|
|
a7b698e6b9 | ||
|
|
136b10239f | ||
|
|
1e46f33648 | ||
|
|
4c3a63e645 |
@@ -16,3 +16,4 @@ uv.lock
|
|||||||
.superpowers/
|
.superpowers/
|
||||||
rndc-key
|
rndc-key
|
||||||
docs/superpowers/
|
docs/superpowers/
|
||||||
|
graphify-out/
|
||||||
|
|||||||
@@ -2,6 +2,31 @@
|
|||||||
|
|
||||||
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.2]
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- preserve chart history across reconnects, show gaps for missing data
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [5.4.1]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- live-refresh Connectivity section and RTT charts every 30s
|
||||||
|
- render RTT history chart per connection in Connectivity section
|
||||||
|
- record RTT history per heartbeat for charting
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- correct empty-state copy on Host Overview page
|
||||||
|
- stop synthetic rtt_* keys from masking real plugin-data checks
|
||||||
|
- use the latest RTT sample, not the oldest, in host info API
|
||||||
|
- hide synthetic rtt history keys from plugin accordion list
|
||||||
|
- remove extra blank line to pass flake8 E303 check
|
||||||
|
- clear flap state when a host is dropped
|
||||||
|
- correct plugin import path in docs, wire up orphaned footer template
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [5.4.0]
|
## [5.4.0]
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -1,8 +1,97 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Working principles
|
||||||
|
|
||||||
1. Don't assume. Don't hide confusion. Surface tradeoffs.
|
1. Don't assume. Don't hide confusion. Surface tradeoffs.
|
||||||
2. Minimum code that solves the problem. Nothing speculative.
|
2. Minimum code that solves the problem. Nothing speculative.
|
||||||
3. Touch only what you must. Clean up only your own mess.
|
3. Touch only what you must. Clean up only your own mess.
|
||||||
4. Define success criteria. Loop until verified.
|
4. Define success criteria. Loop until verified.
|
||||||
|
|
||||||
This project's hbd daemon is deployed and in production on host w02
|
## Commands
|
||||||
under user 'nagios', which you can reach via ssh nagios@w02.
|
|
||||||
The hbc client is used on 30 hosts in multiple networks.
|
```bash
|
||||||
|
# Run tests
|
||||||
|
pytest -q # all tests
|
||||||
|
pytest tests/test_threshold.py # single file
|
||||||
|
pytest -k test_name # single test
|
||||||
|
|
||||||
|
# Lint and type check
|
||||||
|
tox -e lint # flake8 over hbd/ and tests/
|
||||||
|
tox -e mypy # mypy over hbd/
|
||||||
|
|
||||||
|
# Install for development
|
||||||
|
pip install -e ".[all,dev]"
|
||||||
|
|
||||||
|
# Run server
|
||||||
|
python -m hbd.server.cli serve -c .hb.yaml -f -v
|
||||||
|
|
||||||
|
# Generate a password hash
|
||||||
|
hbd passwd <username>
|
||||||
|
```
|
||||||
|
|
||||||
|
Tests live in `tests/` (pytest, imported as a package). A second directory `test/` contains only TLS fixtures (`.pem` files), not test code.
|
||||||
|
|
||||||
|
Line length: 111 characters (`black`, `flake8`, both configured).
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
The system has two components: `hbc` (client) and `hbd` (server), both in the `hbd` Python package.
|
||||||
|
|
||||||
|
```
|
||||||
|
hbd/
|
||||||
|
common/ # proto.py (encode/decode), utils.py
|
||||||
|
client/ # hbc: plugins/, main.py, config.py, plugin.py
|
||||||
|
server/ # hbd: all server modules
|
||||||
|
```
|
||||||
|
|
||||||
|
### Server module map
|
||||||
|
|
||||||
|
| Module | Role |
|
||||||
|
|---|---|
|
||||||
|
| `cli.py` | Argument parsing, entry point, daemon startup |
|
||||||
|
| `main.py` | Asyncio runtime: starts UDP, HTTP, WebSocket; pickle save/load |
|
||||||
|
| `hbdclass.py` | `Host` and `Connection` domain objects; DNS queue |
|
||||||
|
| `udp.py` | UDP datagram listener; processes `HTB` and `PLG` messages; sets/resets overdue timers |
|
||||||
|
| `http.py` | aiohttp web server; all REST API routes (`/api/0/`) and page routes |
|
||||||
|
| `ws.py` | WebSocket broadcast to connected dashboard clients |
|
||||||
|
| `notify.py` | Notification dispatch (Pushover, email, Mattermost, Matrix, Signal, SMS); `eventlog()` is the single choke-point for all alert events |
|
||||||
|
| `threshold.py` | Threshold evaluation against plugin metrics; state transitions (OK/WARNING/CRITICAL/UNKNOWN) |
|
||||||
|
| `monitor.py` | Timer cleanup on shutdown (reachability is event-driven via timers in `udp.py`, not polled) |
|
||||||
|
| `data.py` | Shared in-memory message ring buffer |
|
||||||
|
| `journal.py` | JSONL message journal with size-based rotation |
|
||||||
|
| `users.py` | Session management, password hashing (PBKDF2), role checks |
|
||||||
|
| `config.py` | Config loading and defaults |
|
||||||
|
| `configio.py` | Config file read/write via `ruamel.yaml` (preserves comments) |
|
||||||
|
| `settings.py` | Settings sections for the web UI settings page |
|
||||||
|
| `dns.py` | `nsupdate` integration for dynamic DNS |
|
||||||
|
| `oauth.py` | OAuth2 login (Gitea) |
|
||||||
|
|
||||||
|
### Key data flows
|
||||||
|
|
||||||
|
**Heartbeat received:** `udp.py` decodes the `HTB` datagram → updates `Connection` state in `hbdclass.py` → resets overdue asyncio timer → broadcasts via `ws.py` → `notify.py` fires connectivity alerts on state change.
|
||||||
|
|
||||||
|
**Plugin data received:** `udp.py` decodes `PLG` datagram → stores on `Host` → `threshold.py` evaluates against configured thresholds → `notify.py` fires threshold alerts on state transitions.
|
||||||
|
|
||||||
|
**State persistence:** `Host.hosts` dict + `data.msgs` ring + active sessions are pickled every 5 minutes and on clean shutdown. Asyncio timers are stripped before pickling (`Connection.__getstate__`).
|
||||||
|
|
||||||
|
**Config reload:** SIGHUP → `configio.py` re-reads YAML → live-updates hosts, thresholds, users, notification channels. Port/cert/pickle/journal changes require a full restart.
|
||||||
|
|
||||||
|
### Client plugin system
|
||||||
|
|
||||||
|
Plugins in `hbd/client/plugins/` subclass `InfoPlugin` (collected once, on demand) or `MonitorPlugin` (periodic). `initialize()` returns `False` to self-disable. Data is sent as `PLG` UDP messages.
|
||||||
|
|
||||||
|
`hbc_mini.py` (scripts/) and `hbc_mini.c` (scripts/c/) are standalone single-file clients with no external dependencies.
|
||||||
|
|
||||||
|
### Protocol
|
||||||
|
|
||||||
|
All UDP messages: `!<ID>: <zlib-compressed key=value payload>`. Encoding in `hbd/common/proto.py`. Lists/dicts encoded as JSON with `@` prefix; booleans as `1`/`0`.
|
||||||
|
|
||||||
|
### Web UI
|
||||||
|
|
||||||
|
Jinja2 templates in `hbd/server/templates/`. Static assets in `hbd/server/static/`. Live pages (`/live`, `/plugins`) use WebSocket connections for real-time push.
|
||||||
|
|
||||||
|
### CI
|
||||||
|
|
||||||
|
Gitea Actions workflow at `.gitea/workflows/release.yml`.
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ A lightweight UDP-based host monitoring system. Monitored hosts run a client (`h
|
|||||||
└────────────────────┘ └────────────────────────────┘
|
└────────────────────┘ └────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
**Package:** `hbd` v5.4.0
|
**Package:** `hbd` v5.4.2
|
||||||
**Python:** 3.11+
|
**Python:** 3.11+
|
||||||
|
|
||||||
### Subpackages
|
### Subpackages
|
||||||
|
|||||||
+10
-10
@@ -21,7 +21,7 @@ Heartbeat's plugin system is designed to be simple yet powerful. Plugins are Pyt
|
|||||||
### Key Concepts
|
### Key Concepts
|
||||||
|
|
||||||
- **Plugin Registry**: Central registry that manages all loaded plugins
|
- **Plugin Registry**: Central registry that manages all loaded plugins
|
||||||
- **Plugin Loader**: Automatically discovers and loads plugins from the `hbd/plugins/` directory
|
- **Plugin Loader**: Automatically discovers and loads plugins from the `hbd/client/plugins/` directory
|
||||||
- **Plugin Types**: InfoPlugin (static data) and MonitorPlugin (periodic metrics)
|
- **Plugin Types**: InfoPlugin (static data) and MonitorPlugin (periodic metrics)
|
||||||
- **Async/Await**: All plugin methods are async for non-blocking operation
|
- **Async/Await**: All plugin methods are async for non-blocking operation
|
||||||
|
|
||||||
@@ -64,7 +64,7 @@ Decide whether your plugin collects static information (InfoPlugin) or dynamic m
|
|||||||
|
|
||||||
### Step 2: Create Plugin File
|
### Step 2: Create Plugin File
|
||||||
|
|
||||||
Create a new Python file in `hbd/plugins/` directory:
|
Create a new Python file in `hbd/client/plugins/` directory:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
"""
|
"""
|
||||||
@@ -82,7 +82,7 @@ try:
|
|||||||
except ImportError:
|
except ImportError:
|
||||||
psutil = None
|
psutil = None
|
||||||
|
|
||||||
from hbd.plugin import MonitorPlugin # or InfoPlugin
|
from hbd.client.plugin import MonitorPlugin # or InfoPlugin
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -193,7 +193,7 @@ from pathlib import Path
|
|||||||
# Add parent directory to path
|
# Add parent directory to path
|
||||||
sys.path.insert(0, str(Path(__file__).parent))
|
sys.path.insert(0, str(Path(__file__).parent))
|
||||||
|
|
||||||
from hbd.plugins.my_awesome_plugin import MyAwesomePlugin
|
from hbd.client.plugins.my_awesome_plugin import MyAwesomePlugin
|
||||||
|
|
||||||
async def test():
|
async def test():
|
||||||
# Create plugin instance
|
# Create plugin instance
|
||||||
@@ -224,7 +224,7 @@ Understanding the plugin lifecycle helps you implement plugins correctly:
|
|||||||
|
|
||||||
```
|
```
|
||||||
1. Plugin Discovery
|
1. Plugin Discovery
|
||||||
└─> Loader scans hbd/plugins/ directory
|
└─> Loader scans hbd/client/plugins/ directory
|
||||||
└─> Finds Python files (except those starting with _)
|
└─> Finds Python files (except those starting with _)
|
||||||
└─> Imports modules
|
└─> Imports modules
|
||||||
|
|
||||||
@@ -378,7 +378,7 @@ Document your plugin thoroughly:
|
|||||||
### Example 1: Simple InfoPlugin
|
### Example 1: Simple InfoPlugin
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from hbd.plugin import InfoPlugin
|
from hbd.client.plugin import InfoPlugin
|
||||||
import platform
|
import platform
|
||||||
|
|
||||||
class SimpleInfoPlugin(InfoPlugin):
|
class SimpleInfoPlugin(InfoPlugin):
|
||||||
@@ -406,7 +406,7 @@ plugin = SimpleInfoPlugin
|
|||||||
### Example 2: MonitorPlugin with State
|
### Example 2: MonitorPlugin with State
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from hbd.plugin import MonitorPlugin
|
from hbd.client.plugin import MonitorPlugin
|
||||||
import time
|
import time
|
||||||
|
|
||||||
class CounterPlugin(MonitorPlugin):
|
class CounterPlugin(MonitorPlugin):
|
||||||
@@ -442,7 +442,7 @@ plugin = CounterPlugin
|
|||||||
### Example 3: Plugin with External Command
|
### Example 3: Plugin with External Command
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from hbd.plugin import MonitorPlugin
|
from hbd.client.plugin import MonitorPlugin
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
class CommandPlugin(MonitorPlugin):
|
class CommandPlugin(MonitorPlugin):
|
||||||
@@ -561,7 +561,7 @@ python -m hbd.hbc -c test_config.yaml --verbose
|
|||||||
|
|
||||||
## Further Reading
|
## Further Reading
|
||||||
|
|
||||||
- [Plugin Framework Source](../hbd/plugin.py) - Core plugin implementation
|
- [Plugin Framework Source](../hbd/client/plugin.py) - Core plugin implementation
|
||||||
- [Built-in Plugins](../hbd/plugins/) - Examples of working plugins
|
- [Built-in Plugins](../hbd/client/plugins/) - Examples of working plugins
|
||||||
- [Nagios Integration](NAGIOS_INTEGRATION.md) - Running external plugins
|
- [Nagios Integration](NAGIOS_INTEGRATION.md) - Running external plugins
|
||||||
- [Configuration Guide](../hbd/config_example.yaml) - Full configuration reference
|
- [Configuration Guide](../hbd/config_example.yaml) - Full configuration reference
|
||||||
|
|||||||
+1
-1
@@ -14,4 +14,4 @@ Install options:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
__all__ = ["__version__"]
|
__all__ = ["__version__"]
|
||||||
__version__ = "5.4.0"
|
__version__ = "5.4.2"
|
||||||
|
|||||||
@@ -100,6 +100,17 @@ def observe(host: str, service: str, level: str) -> str:
|
|||||||
return SUPPRESS
|
return SUPPRESS
|
||||||
|
|
||||||
|
|
||||||
|
def clear_host(host: str) -> None:
|
||||||
|
"""Discard all flap state for *host* (called when a host is dropped).
|
||||||
|
|
||||||
|
A dropped host may be mid-flap with no RECOVER ever received, in which
|
||||||
|
case ``ok_since`` stays ``None`` and ``_sweep`` can never clear it on its
|
||||||
|
own — the state would otherwise persist forever.
|
||||||
|
"""
|
||||||
|
for key in [k for k in _state if k[0] == host]:
|
||||||
|
del _state[key]
|
||||||
|
|
||||||
|
|
||||||
def flapping_services(host: str) -> list:
|
def flapping_services(host: str) -> list:
|
||||||
"""Return the services of *host* that are currently flapping.
|
"""Return the services of *host* that are currently flapping.
|
||||||
|
|
||||||
|
|||||||
+14
-2
@@ -14,6 +14,7 @@ import logging
|
|||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
import jinja2
|
import jinja2
|
||||||
from . import data
|
from . import data
|
||||||
|
from . import flap as flap_mod
|
||||||
from . import notify as notify_mod
|
from . import notify as notify_mod
|
||||||
from . import settings as settings_mod
|
from . import settings as settings_mod
|
||||||
from . import users as users_mod
|
from . import users as users_mod
|
||||||
@@ -209,6 +210,16 @@ def _mask_config_for_api(config) -> dict:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _visible_plugin_names(plugin_data: dict) -> list:
|
||||||
|
"""Plugin names to render as accordion sections.
|
||||||
|
|
||||||
|
Excludes synthetic rtt_ipv4 / rtt_ipv6 history streams (written by
|
||||||
|
udp.py for charting) which aren't real client plugins and have no
|
||||||
|
accordion renderer.
|
||||||
|
"""
|
||||||
|
return [p for p in plugin_data.keys() if not p.startswith("rtt_")]
|
||||||
|
|
||||||
|
|
||||||
def _build_host_info(host, threshold_checker=None) -> dict:
|
def _build_host_info(host, threshold_checker=None) -> dict:
|
||||||
"""Assemble the info payload for GET /api/0/hosts/{hostname}/info."""
|
"""Assemble the info payload for GET /api/0/hosts/{hostname}/info."""
|
||||||
hbc_version = None
|
hbc_version = None
|
||||||
@@ -264,7 +275,7 @@ def _build_host_info(host, threshold_checker=None) -> dict:
|
|||||||
"family": getattr(conn, "afam", family),
|
"family": getattr(conn, "afam", family),
|
||||||
"addr": getattr(conn, "addr", ""),
|
"addr": getattr(conn, "addr", ""),
|
||||||
"state": getattr(conn, "state", ""),
|
"state": getattr(conn, "state", ""),
|
||||||
"rtt": (getattr(conn, "rtts", None) or [None])[0],
|
"rtt": (getattr(conn, "rtts", None) or [None])[-1],
|
||||||
"statetime": getattr(conn, "statetime", None),
|
"statetime": getattr(conn, "statetime", None),
|
||||||
"lastbeat": getattr(conn, "lastbeat", None),
|
"lastbeat": getattr(conn, "lastbeat", None),
|
||||||
}
|
}
|
||||||
@@ -439,6 +450,7 @@ async def start(
|
|||||||
return web.json_response({"error": "Forbidden"}, status=403)
|
return web.json_response({"error": "Forbidden"}, status=403)
|
||||||
eventlog(uname, "INFO", "dropped")
|
eventlog(uname, "INFO", "dropped")
|
||||||
del hbdclass.Host.hosts[uname]
|
del hbdclass.Host.hosts[uname]
|
||||||
|
flap_mod.clear_host(uname)
|
||||||
return web.Response(text="Done")
|
return web.Response(text="Done")
|
||||||
|
|
||||||
async def register(request):
|
async def register(request):
|
||||||
@@ -765,7 +777,7 @@ async def start(
|
|||||||
if host.plugin_data:
|
if host.plugin_data:
|
||||||
hosts_with_plugins.append({
|
hosts_with_plugins.append({
|
||||||
"name": hostname,
|
"name": hostname,
|
||||||
"plugins": list(host.plugin_data.keys()),
|
"plugins": _visible_plugin_names(host.plugin_data),
|
||||||
"is_owner": _can_own_host(current_user, host),
|
"is_owner": _can_own_host(current_user, host),
|
||||||
"owner": host.owner,
|
"owner": host.owner,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -107,6 +107,8 @@
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% include 'foot.html' %}
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
(function() {
|
(function() {
|
||||||
var startEpoch = {{ start_epoch }};
|
var startEpoch = {{ start_epoch }};
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
<!-- <label for="drawer-toggle" id="drawer-toggle-label"></label>
|
|
||||||
s<header>{{ header }}</header> -->
|
|
||||||
@@ -290,8 +290,8 @@
|
|||||||
|
|
||||||
{% if not hosts %}
|
{% if not hosts %}
|
||||||
<div class="no-data">
|
<div class="no-data">
|
||||||
<p>No hosts with plugin data available</p>
|
<p>No hosts have connected yet</p>
|
||||||
<p style="font-size:0.9em;margin-top:10px;">Hosts will appear here once they start sending plugin metrics</p>
|
<p style="font-size:0.9em;margin-top:10px;">Hosts will appear here once they start sending heartbeats</p>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div id="hosts-container">
|
<div id="hosts-container">
|
||||||
@@ -445,8 +445,12 @@
|
|||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
if (data.connections && data.connections.length) {
|
if (data.connections && data.connections.length) {
|
||||||
html += `<div class="info-thresholds-title">Connectivity</div>
|
html += `<div class="info-thresholds-title">Connectivity</div>`;
|
||||||
<table class="data-table"><thead><tr>
|
for (const c of data.connections) {
|
||||||
|
html += `<div class="info-note">${escHtml(c.family)} · ${escHtml(c.addr || '—')} RTT</div>
|
||||||
|
<div id="rtt-chart-${hostname}-${escHtml(c.family)}" style="margin-bottom:8px;"></div>`;
|
||||||
|
}
|
||||||
|
html += `<table class="data-table"><thead><tr>
|
||||||
<th>Family</th><th>Address</th><th>State</th>
|
<th>Family</th><th>Address</th><th>State</th>
|
||||||
<th class="num">RTT</th><th>Last Change</th><th>Last Packet</th>
|
<th class="num">RTT</th><th>Last Change</th><th>Last Packet</th>
|
||||||
</tr></thead><tbody>`;
|
</tr></thead><tbody>`;
|
||||||
@@ -495,6 +499,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
el.innerHTML = html;
|
el.innerHTML = html;
|
||||||
|
|
||||||
|
if (data.connections && data.connections.length) {
|
||||||
|
const rttThresholds = (data.thresholds || []).find(t => t.metric === 'rtt') || null;
|
||||||
|
for (const c of data.connections) {
|
||||||
|
fetchRttHistory(hostname, c.family)
|
||||||
|
.then(samples => renderRttChart(hostname, c.family, samples, rttThresholds))
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchHostGlance(hostname) {
|
async function fetchHostGlance(hostname) {
|
||||||
@@ -809,15 +822,11 @@
|
|||||||
return json.samples || [];
|
return json.samples || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderCpuChart(hostname, samples) {
|
function renderTimeSeriesChart(elId, pts, opts) {
|
||||||
const el = document.getElementById(`cpu-chart-${hostname}`);
|
const el = document.getElementById(elId);
|
||||||
if (!el || !samples.length) return;
|
if (!el || pts.length < 2) { if (el) el.style.display = 'none'; return; }
|
||||||
|
|
||||||
const pts = samples
|
|
||||||
.filter(s => s.data.cpu_percent != null)
|
|
||||||
.map(s => ({ t: s.timestamp, v: s.data.cpu_percent }));
|
|
||||||
if (pts.length < 2) { el.style.display = 'none'; return; }
|
|
||||||
|
|
||||||
|
const unitSuffix = opts.unitSuffix || '';
|
||||||
const W = 600, H = 80, PAD = { top: 6, right: 8, bottom: 18, left: 28 };
|
const W = 600, H = 80, PAD = { top: 6, right: 8, bottom: 18, left: 28 };
|
||||||
const cW = W - PAD.left - PAD.right;
|
const cW = W - PAD.left - PAD.right;
|
||||||
const cH = H - PAD.top - PAD.bottom;
|
const cH = H - PAD.top - PAD.bottom;
|
||||||
@@ -826,26 +835,49 @@
|
|||||||
const tRange = tMax - tMin || 1;
|
const tRange = tMax - tMin || 1;
|
||||||
const x = t => PAD.left + ((t - tMin) / tRange) * cW;
|
const x = t => PAD.left + ((t - tMin) / tRange) * cW;
|
||||||
|
|
||||||
// Auto-scale Y axis with 10% padding, clamped to [0, 100]
|
// 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));
|
||||||
const vMax = Math.max(...pts.map(p => p.v));
|
const vMax = Math.max(...pts.map(p => p.v));
|
||||||
const vRange = vMax - vMin || 1;
|
const vRange = vMax - vMin || 1;
|
||||||
const vPad = Math.max(vRange * 0.1, 1);
|
const vPad = Math.max(vRange * 0.1, 1);
|
||||||
const yLow = Math.max(0, vMin - vPad);
|
const domainLow = Array.isArray(opts.yDomain) ? opts.yDomain[0] : 0;
|
||||||
const yHigh = Math.min(100, vMax + vPad);
|
const domainHigh = Array.isArray(opts.yDomain) ? opts.yDomain[1] : Infinity;
|
||||||
|
const yLow = Math.max(domainLow, vMin - 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;
|
const y = v => PAD.top + cH - ((v - yLow) / yRange) * cH;
|
||||||
|
|
||||||
// Build polyline points and filled area path
|
// Color based on latest value
|
||||||
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 absolute CPU %
|
|
||||||
const latest = pts[pts.length - 1].v;
|
const latest = pts[pts.length - 1].v;
|
||||||
const strokeColor = latest > 90 ? '#e53935' : latest > 70 ? '#fb8c00' : '#43a047';
|
const { stroke: strokeColor, fill: fillColor } = opts.colorFor(latest);
|
||||||
const fillColor = latest > 90 ? '#ffcdd2' : latest > 70 ? '#ffe0b2' : '#c8e6c9';
|
|
||||||
|
// 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 += `<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"/>`;
|
||||||
|
}
|
||||||
|
|
||||||
// Compute nice tick step for ~3-5 grid lines
|
// Compute nice tick step for ~3-5 grid lines
|
||||||
const rawStep = yRange / 4;
|
const rawStep = yRange / 4;
|
||||||
@@ -855,7 +887,7 @@
|
|||||||
let gridLines = '';
|
let gridLines = '';
|
||||||
for (let v = tickStart; v <= yHigh + 0.001; v += niceStep) {
|
for (let v = tickStart; v <= yHigh + 0.001; v += niceStep) {
|
||||||
const yy = y(v).toFixed(1);
|
const yy = y(v).toFixed(1);
|
||||||
const label = Number.isInteger(v) ? v : v.toFixed(1);
|
const label = (Number.isInteger(v) ? v : v.toFixed(1)) + unitSuffix;
|
||||||
gridLines += `<line x1="${PAD.left}" y1="${yy}" x2="${PAD.left + cW}" y2="${yy}" stroke="#e0e0e0" stroke-width="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">${label}</text>`;
|
gridLines += `<text x="${(PAD.left - 3).toFixed(1)}" y="${yy}" text-anchor="end" dominant-baseline="middle" font-size="8" fill="#999">${label}</text>`;
|
||||||
}
|
}
|
||||||
@@ -872,21 +904,65 @@
|
|||||||
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;">
|
||||||
<defs>
|
<defs>
|
||||||
<clipPath id="cpu-clip-${hostname}">
|
<clipPath id="${opts.clipId}">
|
||||||
<rect x="${PAD.left}" y="${PAD.top}" width="${cW}" height="${cH}"/>
|
<rect x="${PAD.left}" y="${PAD.top}" width="${cW}" height="${cH}"/>
|
||||||
</clipPath>
|
</clipPath>
|
||||||
</defs>
|
</defs>
|
||||||
${gridLines}
|
${gridLines}
|
||||||
<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(#cpu-clip-${hostname})">
|
<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>
|
||||||
${xLabels}
|
${xLabels}
|
||||||
</svg>`;
|
</svg>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderCpuChart(hostname, samples) {
|
||||||
|
const pts = samples
|
||||||
|
.filter(s => s.data.cpu_percent != null)
|
||||||
|
.map(s => ({ t: s.timestamp, v: s.data.cpu_percent }));
|
||||||
|
|
||||||
|
renderTimeSeriesChart(`cpu-chart-${hostname}`, pts, {
|
||||||
|
yDomain: [0, 100],
|
||||||
|
clipId: `cpu-clip-${hostname}`,
|
||||||
|
colorFor: (latest) => ({
|
||||||
|
stroke: latest > 90 ? '#e53935' : latest > 70 ? '#fb8c00' : '#43a047',
|
||||||
|
fill: latest > 90 ? '#ffcdd2' : latest > 70 ? '#ffe0b2' : '#c8e6c9',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchRttHistory(hostname, family) {
|
||||||
|
const plugin = `rtt_${family.toLowerCase()}`;
|
||||||
|
const r = await fetch(`/api/0/hosts/${encodeURIComponent(hostname)}/plugins/${plugin}?limit=100`);
|
||||||
|
if (!r.ok) return [];
|
||||||
|
const json = await r.json();
|
||||||
|
return json.samples || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRttChart(hostname, family, samples, rttThresholds) {
|
||||||
|
const pts = samples
|
||||||
|
.filter(s => s.data.rtt != null && s.data.rtt > 0)
|
||||||
|
.map(s => ({ t: s.timestamp, v: s.data.rtt }));
|
||||||
|
|
||||||
|
renderTimeSeriesChart(`rtt-chart-${hostname}-${family}`, pts, {
|
||||||
|
yDomain: 'auto',
|
||||||
|
clipId: `rtt-clip-${hostname}-${family}`,
|
||||||
|
unitSuffix: ' ms',
|
||||||
|
colorFor: (latest) => {
|
||||||
|
if (rttThresholds?.critical != null && latest > rttThresholds.critical) {
|
||||||
|
return { stroke: '#e53935', fill: '#ffcdd2' };
|
||||||
|
}
|
||||||
|
if (rttThresholds?.warning != null && latest > rttThresholds.warning) {
|
||||||
|
return { stroke: '#fb8c00', fill: '#ffe0b2' };
|
||||||
|
}
|
||||||
|
return { stroke: '#1976d2', fill: '#bbdefb' };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function renderCpuTable(hostname, d) {
|
function renderCpuTable(hostname, d) {
|
||||||
const KEYS = [
|
const KEYS = [
|
||||||
['cpu_percent', 'CPU Usage', 'bar'],
|
['cpu_percent', 'CPU Usage', 'bar'],
|
||||||
@@ -1293,6 +1369,11 @@
|
|||||||
document.querySelectorAll('.host-card:not(.collapsed)').forEach(card => {
|
document.querySelectorAll('.host-card:not(.collapsed)').forEach(card => {
|
||||||
const hostname = card.dataset.hostname;
|
const hostname = card.dataset.hostname;
|
||||||
|
|
||||||
|
fetchHostInfo(hostname).then(data => {
|
||||||
|
infoCache[hostname] = data;
|
||||||
|
renderInfoSection(hostname, data);
|
||||||
|
}).catch(() => {});
|
||||||
|
|
||||||
card.querySelectorAll('.plugin-accordion:not(.collapsed)').forEach(acc => {
|
card.querySelectorAll('.plugin-accordion:not(.collapsed)').forEach(acc => {
|
||||||
const pname = acc.dataset.plugin;
|
const pname = acc.dataset.plugin;
|
||||||
if (!GLANCE_PLUGINS.includes(pname)) {
|
if (!GLANCE_PLUGINS.includes(pname)) {
|
||||||
|
|||||||
+22
-3
@@ -319,6 +319,16 @@ def restore_connection_timers(hbdclass, ctx):
|
|||||||
logger.info("Restored timers for %d connection(s)", restored)
|
logger.info("Restored timers for %d connection(s)", restored)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_rtt_key(plugin_name: str) -> bool:
|
||||||
|
"""True for synthetic RTT-history keys (rtt_ipv4/rtt_ipv6), not real plugin data."""
|
||||||
|
return plugin_name.startswith("rtt_")
|
||||||
|
|
||||||
|
|
||||||
|
def _has_real_plugin_data(plugin_data: dict) -> bool:
|
||||||
|
"""True if plugin_data holds any real (non-RTT) client-collected plugin data."""
|
||||||
|
return any(not _is_rtt_key(k) for k in plugin_data)
|
||||||
|
|
||||||
|
|
||||||
def handle_datagram(msg: dict, addr, transport, ctx: dict):
|
def handle_datagram(msg: dict, addr, transport, ctx: dict):
|
||||||
"""Handle a parsed datagram message.
|
"""Handle a parsed datagram message.
|
||||||
|
|
||||||
@@ -385,7 +395,7 @@ def handle_datagram(msg: dict, addr, transport, ctx: dict):
|
|||||||
host.doesack = msg.get("acks", -1)
|
host.doesack = msg.get("acks", -1)
|
||||||
# send ACK back; ask client to resend plugin info when we have none yet
|
# send ACK back; ask client to resend plugin info when we have none yet
|
||||||
rmsg = {"time": time.time()}
|
rmsg = {"time": time.time()}
|
||||||
if not host.plugin_data:
|
if not _has_real_plugin_data(host.plugin_data):
|
||||||
rmsg["request_update"] = 1
|
rmsg["request_update"] = 1
|
||||||
opkt = dicttos("ACK", rmsg)
|
opkt = dicttos("ACK", rmsg)
|
||||||
try:
|
try:
|
||||||
@@ -511,12 +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)
|
||||||
|
if boot:
|
||||||
# On reboot, pre-boot plugin data and derived alerts are stale.
|
# On reboot, pre-boot plugin data and derived alerts are stale.
|
||||||
# Cancel all plugin timers and wipe plugin state so timers restart
|
# Cancel all plugin timers and wipe plugin state so timers restart
|
||||||
# cleanly from the first two post-boot samples.
|
# 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):
|
for pname in list(host.plugin_timers):
|
||||||
host.cancel_plugin_timer(pname)
|
host.cancel_plugin_timer(pname)
|
||||||
host.plugin_data.clear()
|
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.")
|
||||||
@@ -562,6 +577,10 @@ def handle_datagram(msg: dict, addr, transport, ctx: dict):
|
|||||||
if interval > 0:
|
if interval > 0:
|
||||||
host.interval = interval
|
host.interval = interval
|
||||||
|
|
||||||
|
# Record RTT history for charting
|
||||||
|
if rtt is not None:
|
||||||
|
host.add_plugin_data(f"rtt_{conn.afam.lower()}", {"rtt": rtt}, timestamp=now)
|
||||||
|
|
||||||
# Timer-based reachability monitoring
|
# Timer-based reachability monitoring
|
||||||
# Reset overdue timer on every heartbeat
|
# Reset overdue timer on every heartbeat
|
||||||
if interval > 0 and conn.getstate() != hbdcls.Connection.DOWN:
|
if interval > 0 and conn.getstate() != hbdcls.Connection.DOWN:
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "hbd"
|
name = "hbd"
|
||||||
version = "5.4.0"
|
version = "5.4.2"
|
||||||
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
@@ -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.0"
|
__version__ = "5.4.2"
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Protocol (mirrors hbd/common/proto.py)
|
# Protocol (mirrors hbd/common/proto.py)
|
||||||
|
|||||||
@@ -98,6 +98,26 @@ def test_alert_during_the_quiet_window_keeps_it_flapping():
|
|||||||
assert flap.flapping_services("h1") == ["cpu"]
|
assert flap.flapping_services("h1") == ["cpu"]
|
||||||
|
|
||||||
|
|
||||||
|
# --- host removal ------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_clear_host_drops_flapping_state_even_without_a_recover():
|
||||||
|
# A host dropped mid-flap (no RECOVER ever received) must not stay
|
||||||
|
# flapping forever: nothing will ever set ok_since for it again.
|
||||||
|
alerts(4)
|
||||||
|
assert flap.flapping_services("h1") == ["cpu"]
|
||||||
|
flap.clear_host("h1")
|
||||||
|
assert flap.flapping_services("h1") == []
|
||||||
|
assert flap.observe("h1", "cpu", "CRITICAL") == flap.PASS
|
||||||
|
|
||||||
|
|
||||||
|
def test_clear_host_only_affects_the_named_host():
|
||||||
|
alerts(4, host="h1")
|
||||||
|
alerts(4, host="h2")
|
||||||
|
flap.clear_host("h1")
|
||||||
|
assert flap.flapping_services("h1") == []
|
||||||
|
assert flap.flapping_services("h2") == ["cpu"]
|
||||||
|
|
||||||
|
|
||||||
# --- keying -----------------------------------------------------------------
|
# --- keying -----------------------------------------------------------------
|
||||||
|
|
||||||
def test_services_and_hosts_are_tracked_independently():
|
def test_services_and_hosts_are_tracked_independently():
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
"""Tests for _build_host_info helper in http.py."""
|
"""Tests for _build_host_info helper in http.py."""
|
||||||
import pytest
|
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
from hbd.server.http import _build_host_info
|
from hbd.server.http import _build_host_info
|
||||||
|
|
||||||
@@ -175,11 +174,11 @@ def test_build_host_info_covers_empty_when_exact_matches_only():
|
|||||||
|
|
||||||
|
|
||||||
class _FakeConnFull:
|
class _FakeConnFull:
|
||||||
def __init__(self, afam, addr, state, rtt, statetime, lastbeat):
|
def __init__(self, afam, addr, state, rtt, statetime, lastbeat, rtts=None):
|
||||||
self.afam = afam
|
self.afam = afam
|
||||||
self.addr = addr
|
self.addr = addr
|
||||||
self.state = state
|
self.state = state
|
||||||
self.rtts = [rtt]
|
self.rtts = rtts if rtts is not None else [rtt]
|
||||||
self.statetime = statetime
|
self.statetime = statetime
|
||||||
self.lastbeat = lastbeat
|
self.lastbeat = lastbeat
|
||||||
|
|
||||||
@@ -200,3 +199,14 @@ def test_build_host_info_connections_empty():
|
|||||||
host = _FakeHost()
|
host = _FakeHost()
|
||||||
result = _build_host_info(host)
|
result = _build_host_info(host)
|
||||||
assert result["connections"] == []
|
assert result["connections"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_host_info_connection_rtt_uses_latest_sample():
|
||||||
|
"""rtt should reflect the most recent sample, not the oldest retained one."""
|
||||||
|
host = _FakeHost(connections={
|
||||||
|
"IPv4": _FakeConnFull("IPv4", "10.0.0.5", "up", None, 1000.0, 2000.0,
|
||||||
|
rtts=[100.0, 50.0, 14.2]),
|
||||||
|
})
|
||||||
|
result = _build_host_info(host)
|
||||||
|
conns = {c["family"]: c for c in result["connections"]}
|
||||||
|
assert conns["IPv4"]["rtt"] == 14.2
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
"""Tests for the plugin-accordion visibility filter in http.py."""
|
||||||
|
from hbd.server.http import _visible_plugin_names
|
||||||
|
|
||||||
|
|
||||||
|
def test_visible_plugin_names_excludes_rtt_history_keys():
|
||||||
|
plugin_data = {
|
||||||
|
"cpu_monitor": [],
|
||||||
|
"rtt_ipv4": [],
|
||||||
|
"rtt_ipv6": [],
|
||||||
|
"network_monitor": [],
|
||||||
|
}
|
||||||
|
result = _visible_plugin_names(plugin_data)
|
||||||
|
assert sorted(result) == ["cpu_monitor", "network_monitor"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_visible_plugin_names_empty_when_no_plugins():
|
||||||
|
assert _visible_plugin_names({}) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_visible_plugin_names_keeps_names_not_prefixed_with_rtt_():
|
||||||
|
plugin_data = {"nagios_runner": [], "os_info": []}
|
||||||
|
result = _visible_plugin_names(plugin_data)
|
||||||
|
assert sorted(result) == ["nagios_runner", "os_info"]
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
"""Tests for RTT history capture in udp.py's handle_datagram."""
|
||||||
|
import time
|
||||||
|
|
||||||
|
from hbd.common.proto import dicttos
|
||||||
|
from hbd.server import hbdclass
|
||||||
|
from hbd.server.udp import handle_datagram, parse_message
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeTransport:
|
||||||
|
def __init__(self):
|
||||||
|
self.sent = []
|
||||||
|
|
||||||
|
def sendto(self, data, addr):
|
||||||
|
self.sent.append((data, addr))
|
||||||
|
|
||||||
|
|
||||||
|
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))
|
||||||
|
|
||||||
|
|
||||||
|
def _base_ctx():
|
||||||
|
return {
|
||||||
|
"config": {},
|
||||||
|
"hbdclass": hbdclass,
|
||||||
|
"msg_to_websockets": None,
|
||||||
|
"DEBUG": 0,
|
||||||
|
"verbose": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_handle_datagram_records_rtt_history_for_new_connection():
|
||||||
|
hbdclass.Host.hosts.pop("rtt-hist-host", None)
|
||||||
|
handle_datagram(_htb("rtt-hist-host", rtt=42.5), ("127.0.0.1", 50000),
|
||||||
|
_FakeTransport(), _base_ctx())
|
||||||
|
|
||||||
|
host = hbdclass.Host.hosts["rtt-hist-host"]
|
||||||
|
samples = host.plugin_data.get("rtt_ipv4")
|
||||||
|
assert samples is not None
|
||||||
|
assert len(samples) == 1
|
||||||
|
ts, data = samples[0]
|
||||||
|
assert data == {"rtt": 42.5}
|
||||||
|
assert isinstance(ts, float)
|
||||||
|
|
||||||
|
|
||||||
|
def test_handle_datagram_appends_rtt_history_across_heartbeats():
|
||||||
|
hbdclass.Host.hosts.pop("rtt-hist-host2", None)
|
||||||
|
transport = _FakeTransport()
|
||||||
|
ctx = _base_ctx()
|
||||||
|
handle_datagram(_htb("rtt-hist-host2", rtt=10.0), ("127.0.0.1", 50000), transport, ctx)
|
||||||
|
handle_datagram(_htb("rtt-hist-host2", rtt=20.0), ("127.0.0.1", 50000), transport, ctx)
|
||||||
|
|
||||||
|
host = hbdclass.Host.hosts["rtt-hist-host2"]
|
||||||
|
samples = host.plugin_data["rtt_ipv4"]
|
||||||
|
assert [d["rtt"] for _, d in samples] == [10.0, 20.0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_handle_datagram_skips_rtt_history_when_rtt_missing():
|
||||||
|
hbdclass.Host.hosts.pop("rtt-hist-host3", None)
|
||||||
|
handle_datagram(_htb("rtt-hist-host3", rtt=None), ("127.0.0.1", 50000),
|
||||||
|
_FakeTransport(), _base_ctx())
|
||||||
|
|
||||||
|
host = hbdclass.Host.hosts["rtt-hist-host3"]
|
||||||
|
assert "rtt_ipv4" not in host.plugin_data
|
||||||
|
|
||||||
|
|
||||||
|
def test_request_update_fires_on_recovery_even_with_rtt_history():
|
||||||
|
"""Regression for Finding 1: rtt_* keys must not permanently disable the
|
||||||
|
request_update gate. A connection recovering from a non-UP state must
|
||||||
|
still be asked to resend real plugin data, even though rtt_ipv4 already
|
||||||
|
holds samples from before the drop.
|
||||||
|
"""
|
||||||
|
hbdclass.Host.hosts.pop("rtt-hist-host4", None)
|
||||||
|
transport = _FakeTransport()
|
||||||
|
ctx = _base_ctx()
|
||||||
|
|
||||||
|
# First heartbeat: brand-new host, no plugin data at all yet.
|
||||||
|
handle_datagram(_htb("rtt-hist-host4", rtt=15.0), ("127.0.0.1", 50000), transport, ctx)
|
||||||
|
host = hbdclass.Host.hosts["rtt-hist-host4"]
|
||||||
|
assert host.plugin_data.get("rtt_ipv4") # rtt history now non-empty
|
||||||
|
|
||||||
|
# Simulate a recovery: connection was dropped (e.g. OVERDUE->UP after a
|
||||||
|
# missed heartbeat) and is about to come back UP on the next heartbeat.
|
||||||
|
conn = host.connections["IPv4"]
|
||||||
|
conn.state = hbdclass.Connection.DOWN
|
||||||
|
|
||||||
|
transport.sent.clear()
|
||||||
|
handle_datagram(_htb("rtt-hist-host4", rtt=16.0), ("127.0.0.1", 50000), transport, ctx)
|
||||||
|
|
||||||
|
ack_data, _ = transport.sent[0]
|
||||||
|
ack = parse_message(ack_data)
|
||||||
|
assert ack.get("request_update")
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
||||||
|
ctx = _base_ctx()
|
||||||
|
|
||||||
|
for rtt in (10.0, 11.0, 12.0):
|
||||||
|
handle_datagram(_htb("rtt-hist-host5", rtt=rtt), ("127.0.0.1", 50000), transport, ctx)
|
||||||
|
|
||||||
|
host = hbdclass.Host.hosts["rtt-hist-host5"]
|
||||||
|
assert len(host.plugin_data["rtt_ipv4"]) == 3
|
||||||
|
|
||||||
|
# Simulate a drop, and pretend the client had previously sent real
|
||||||
|
# plugin data (collected before the connection went down).
|
||||||
|
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
|
||||||
|
|
||||||
|
# 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
|
||||||
Reference in New Issue
Block a user