feat(host_agent): a reboot reads as a reboot, and lost contact as its span (#5756)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m24s
CI / unit (push) Failing after 1m29s
CI / publish (push) Skipped

#5756 "The agent says when its host rebooted and when it lost contact, so
downtime is recorded as what it was". Agent 1.14.0 posts events with the
next post that gets through: started (with the host's boot time), stopping
(on SIGTERM, with whether systemd says the host is going down) and
lost_contact (from its first unanswered post to the one carrying it).

Steward keeps them as the host's downtime (host_downtime): reboot,
agent_stop, unclean (a boot after the agent was last heard that nobody
announced) or lost_contact. Retried posts apply once.

An announced stop explains the silence for reboot_grace_seconds (Settings
-> Plugins -> host_agent, 10 minutes): seconds_since_seen reads 0 until
then, and the host's other rules go quiet with it (agents_away beside
#5721's registry). Past the grace, agent offline fires.

The host's Agent card lists the last 7 days of downtime. Rows are pruned
at data.retention_days.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-14 17:08:16 -04:00
co-authored by Claude Opus 5.5
parent 5dd9956f35
commit c7ff5bc556
16 changed files with 632 additions and 15 deletions
+27
View File
@@ -208,3 +208,30 @@ def test_sort_hosts_puts_missing_values_last_either_way():
assert names("cpu", False) == ["alpha", "bravo", "charlie"] # no reading: last both ways
assert names("name", True) == ["charlie", "bravo", "alpha"]
assert names("status", False)[0] == "charlie" # down first
async def test_the_agent_card_says_what_happened_while_the_host_was_away(app):
"""#5756: a reboot reads as a reboot with its length, one still going says so."""
from datetime import datetime, timedelta, timezone
from tests._templates import render_plugin_template
now = datetime.now(timezone.utc)
host = SimpleNamespace(id="h1", name="box", address="10.0.0.5")
reg = SimpleNamespace(agent_version="1.14.0", last_seen_at=now)
downtime = [
{"kind": "reboot", "label": "Rebooted", "started_at": now - timedelta(minutes=5),
"ended_at": None, "ongoing": True, "seconds": 300.0},
{"kind": "lost_contact", "label": "Lost contact", "started_at": now - timedelta(hours=3),
"ended_at": now - timedelta(hours=2), "ongoing": False, "seconds": 3600.0},
]
async with app.test_request_context("/plugins/host_agent/panel/h1"):
session["user_role"] = "viewer"
page = await render_plugin_template(
app, "host_agent", "panel.html", host=host, reg=reg, reporting=True, stale=False,
target=None, ansible_available=False, managed_key_set=False, downtime=downtime)
quiet = await render_plugin_template(
app, "host_agent", "panel.html", host=host, reg=reg, reporting=True, stale=False,
target=None, ansible_available=False, managed_key_set=False, downtime=[])
assert "Downtime, last 7 days" in page
assert page.index("Rebooted") < page.index("Lost contact")
assert "going on, 5m" in page and "1h" in page
assert "Downtime, last 7 days" not in quiet