#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>
238 lines
13 KiB
Python
238 lines
13 KiB
Python
"""The host page: what sits where (#5214)."""
|
||
import re
|
||
from types import SimpleNamespace
|
||
|
||
from quart import render_template, session
|
||
|
||
from steward.hosts.service import HOST_STATES, NO_GROUP
|
||
|
||
|
||
def _list_ctx(**over):
|
||
"""What the Hosts list route hands its template (#5294), with no filter set."""
|
||
ctx = dict(hosts=[], host_status={}, uptime={}, agent={}, groups={}, group_names=[],
|
||
f={"q": "", "group": "", "state": "", "sort": "status", "dir": "asc"},
|
||
filter_url=lambda **kw: "/hosts/?" + "&".join(f"{k}={v}" for k, v in kw.items()),
|
||
sort_url=lambda key: f"/hosts/?sort={key}",
|
||
states=HOST_STATES, no_group=NO_GROUP, add=None)
|
||
ctx.update(over)
|
||
ctx.setdefault("total", len(ctx["hosts"]))
|
||
return ctx
|
||
|
||
|
||
def _headers(page):
|
||
"""Each table header's text, tags and arrows stripped."""
|
||
return [re.sub(r"<[^>]+>|[↑↓]", "", h).strip() for h in re.findall(r"<th[^>]*>(.*?)</th>", page, re.S)]
|
||
|
||
|
||
async def test_ansible_comes_before_the_docker_list(app, monkeypatch):
|
||
# The Docker list can run to dozens of containers; Ansible under it was
|
||
# easy to miss, so it sits above.
|
||
monkeypatch.setitem(app.config, "PLUGINS", {"docker": {"enabled": True}})
|
||
host = SimpleNamespace(id="h1", name="box", address="10.0.0.5")
|
||
async with app.test_request_context("/hosts/h1"):
|
||
session["user_role"] = "operator"
|
||
page = await render_template(
|
||
"hosts/detail.html", host=host, monitors=[], uptime=None, alerts=[],
|
||
linked_target=None, linkable_targets=[], ansible_sources=[])
|
||
docker = page.index('hx-get="/plugins/docker/host/h1"')
|
||
# #5290: Monitors and Agent share a row whose cards drop their own margin.
|
||
assert '<div class="host-pair">' in page
|
||
assert page.index(">Ansible</h3>") < docker
|
||
assert page.index('hx-get="/plugins/host_agent/panel/h1"') < docker
|
||
# Monitors are added on their own form, filed under this host (#5277);
|
||
# each row's own page has the labelled Edit/Pause/Delete.
|
||
assert 'href="/monitors/new?host_id=h1"' in page
|
||
assert "row-actions" not in page and 'action="/monitors/add"' not in page
|
||
|
||
|
||
async def test_adding_a_monitor_from_a_host_page_says_whose_it_is(app):
|
||
host = SimpleNamespace(id="h1", name="box", address="10.0.0.5")
|
||
async with app.test_request_context("/monitors/new?host_id=h1"):
|
||
session["user_role"] = "operator"
|
||
page = await render_template(
|
||
"monitors/form.html", monitor=None, host=host, mtype="icmp", config={}, types=[],
|
||
values={"name": "", "target": "10.0.0.5", "host_id": "h1", "check_interval_seconds": 0})
|
||
assert '<a href="/hosts/h1">box</a>' in page # crumb and hint name the host
|
||
assert 'name="host_id" value="h1"' in page and 'value="10.0.0.5"' in page
|
||
assert 'href="/hosts/h1" class="btn btn-sm btn-ghost">Cancel' in page
|
||
|
||
|
||
async def test_the_agent_card_keeps_its_actions_under_manage(app):
|
||
"""#5278: "Remove" said nothing about what it removed."""
|
||
from datetime import datetime, timezone
|
||
from tests._templates import render_plugin_template
|
||
host = SimpleNamespace(id="h1", name="box", address="10.0.0.5")
|
||
reg = SimpleNamespace(agent_version="1.8.0", last_seen_at=datetime.now(timezone.utc))
|
||
target = SimpleNamespace(id="t1", name="box")
|
||
async with app.test_request_context("/plugins/host_agent/panel/h1"):
|
||
session["user_role"] = "admin"
|
||
page = await render_plugin_template(
|
||
app, "host_agent", "panel.html", host=host, reg=reg, reporting=True, stale=False,
|
||
target=target, ansible_available=True, managed_key_set=True)
|
||
manage = page.index(">Manage agent</summary>")
|
||
assert page.index("Full metrics →") < manage
|
||
for action in (">Update agent</button>", ">Rotate token</button>", ">Unregister agent</button>"):
|
||
assert page.index(action) > manage
|
||
assert ">Remove</button>" not in page
|
||
|
||
|
||
async def test_the_hosts_list_says_what_is_wrong_and_labels_its_figures(app):
|
||
"""#5279–#5281: labelled CPU and Memory, one uptime figure, status with how
|
||
long, no SLA tab and no per-row edit/delete."""
|
||
from datetime import datetime, timedelta, timezone
|
||
now = datetime.now(timezone.utc)
|
||
down = SimpleNamespace(id="h1", name="nas", address="10.0.0.5")
|
||
quiet = SimpleNamespace(id="h2", name="pi", address="")
|
||
async with app.test_request_context("/hosts/"):
|
||
session["user_role"] = "admin"
|
||
page = await render_template("hosts/list.html", **_list_ctx(
|
||
hosts=[down, quiet],
|
||
host_status={"h1": {"state": "down", "latency_ms": None, "count": 2,
|
||
"down_since": now - timedelta(minutes=12)}},
|
||
uptime={"h1": {"24h": 91.5, "7d": 98.0, "30d": 99.42}},
|
||
agent={"pi": {"fresh": False, "last_at": now - timedelta(minutes=7),
|
||
"cpu_pct": 3.0, "mem_used_pct": 41.0}}))
|
||
# No inventory groups: no Groups column.
|
||
assert _headers(page) == ["Name", "Address", "Status", "Latency", "Monitors", "30d uptime", "CPU", "Memory"]
|
||
assert "for 12m" in page and "agent silent" in page and "for 7m" in page
|
||
assert "99.42%" in page and 'title="24h 91.50% · 7d 98.00%"' in page
|
||
assert ">41%<" in page and "tone-dim" in page # a silent agent's last reading, greyed
|
||
assert "/hosts/uptime" not in page and ">Delete<" not in page and ">Edit<" not in page
|
||
|
||
|
||
async def test_a_host_without_an_agent_can_get_a_manual_install(app):
|
||
"""#5282: the manual install moved here from the fleet's add-host form."""
|
||
from tests._templates import render_plugin_template
|
||
host = SimpleNamespace(id="h1", name="box", address="10.0.0.5")
|
||
async with app.test_request_context("/plugins/host_agent/panel/h1"):
|
||
session["user_role"] = "admin"
|
||
page = await render_plugin_template(
|
||
app, "host_agent", "panel.html", host=host, reg=None, reporting=False, stale=False,
|
||
target=None, ansible_available=False, managed_key_set=False)
|
||
assert 'action="/plugins/host_agent/fleet/h1/register"' in page and ">Install manually</button>" in page
|
||
|
||
|
||
async def test_add_host_is_a_dialog_on_the_hosts_list(app):
|
||
"""#5291: the form was two fields on a page of its own."""
|
||
async with app.test_request_context("/hosts/"):
|
||
session["user_role"] = "operator"
|
||
closed = await render_template("hosts/list.html", **_list_ctx())
|
||
dialog = re.search(r'<dialog id="add-host".*?</dialog>', closed, re.S).group(0)
|
||
assert 'action="/hosts/"' in dialog and ">Cancel</button>" in dialog
|
||
assert re.search(r'<input[^>]*name="address"[^>]*>', dialog).group(0).count("required") == 0
|
||
assert "showModal()\">Add host</button>" in closed
|
||
assert "getElementById('add-host').showModal();</script>" not in closed
|
||
assert "/hosts/new" not in closed
|
||
|
||
async with app.test_request_context("/hosts/"):
|
||
session["user_role"] = "viewer"
|
||
viewer = await render_template("hosts/list.html", **_list_ctx(add={}))
|
||
assert 'id="add-host"' not in viewer and ">Add host<" not in viewer
|
||
|
||
|
||
async def test_add_host_sends_a_clash_back_with_what_was_typed(app):
|
||
"""#5283, #5291: the list again, the dialog open, an inline error and the typed values."""
|
||
async with app.test_request_context("/hosts/"):
|
||
session["user_role"] = "operator"
|
||
page = await render_template("hosts/list.html", **_list_ctx(
|
||
add={"values": {"name": "nas", "address": "10.0.0.9"}, "error": "A host named nas already exists."}))
|
||
assert 'role="alert">A host named nas already exists.<' in page
|
||
assert 'value="nas"' in page and 'value="10.0.0.9"' in page
|
||
assert "getElementById('add-host').showModal();</script>" in page
|
||
|
||
|
||
async def test_edit_host_keeps_its_page(app):
|
||
host = SimpleNamespace(id="h1", name="nas", address="10.0.0.5")
|
||
async with app.test_request_context("/hosts/h1/edit"):
|
||
session["user_role"] = "operator"
|
||
page = await render_template("hosts/form.html", host=host)
|
||
assert 'action="/hosts/h1"' in page and 'value="nas"' in page and 'value="10.0.0.5"' in page
|
||
assert '<a href="/hosts/h1">nas</a>' in page and ">Save</button>" in page
|
||
|
||
|
||
async def test_the_hosts_list_has_a_filter_bar_sortable_headers_and_group_chips(app):
|
||
"""#5294: search, group and status filters; headers that sort; a host's
|
||
groups as chips that filter the list."""
|
||
swarm = SimpleNamespace(id="h1", name="docker02", address="10.0.0.12")
|
||
lone = SimpleNamespace(id="h2", name="util01", address="10.0.0.9")
|
||
async with app.test_request_context("/hosts/"):
|
||
session["user_role"] = "viewer"
|
||
page = await render_template("hosts/list.html", **_list_ctx(
|
||
hosts=[swarm, lone], groups={"h1": ["Docker Swarm"]}, group_names=["Docker Swarm"],
|
||
f={"q": "", "group": "", "state": "", "sort": "cpu", "dir": "desc"}))
|
||
bar = re.search(r'<form class="filter-bar".*?</form>', page, re.S).group(0)
|
||
assert 'type="search" name="q"' in bar and 'hx-target="#hosts-table"' in bar and 'hx-push-url="true"' in bar
|
||
assert '<option value="Docker Swarm">' in bar and f'<option value="{NO_GROUP}">No group</option>' in bar
|
||
assert [o for o in re.findall(r'<option value="(\w*)"', bar.split('name="state"')[1])] == ["", "down", "silent", "up", "none"]
|
||
assert '<input type="hidden" name="sort" value="cpu">' in bar # a filter keeps the column sort
|
||
assert _headers(page) == ["Name", "Address", "Groups", "Status", "Latency", "Monitors", "30d uptime", "CPU", "Memory"]
|
||
assert 'aria-sort="descending"' in page and page.count('aria-sort="none"') == 5
|
||
assert 'href="/hosts/?sort=uptime"' in page
|
||
assert '<a class="chip" href="/hosts/?group=Docker Swarm"' in page
|
||
table = page[page.index('<div id="hosts-table">'):]
|
||
assert table.index("docker02") < table.index("util01")
|
||
|
||
|
||
def test_filter_hosts_by_search_group_and_status():
|
||
from steward.hosts.service import filter_hosts
|
||
hosts = [SimpleNamespace(id="h1", name="Docker02", address="10.0.0.12"),
|
||
SimpleNamespace(id="h2", name="util01", address="10.0.0.9"),
|
||
SimpleNamespace(id="h3", name="nas", address="")]
|
||
status = {"h1": {"state": "down"}, "h2": {"state": "up"}}
|
||
agent = {"util01": {"fresh": False}}
|
||
groups = {"h1": ["Docker Swarm", "Linux"], "h2": ["Linux"]}
|
||
|
||
def names(**kw):
|
||
return [h.name for h in filter_hosts(hosts, status=status, agent=agent, groups=groups, **kw)]
|
||
|
||
assert names() == ["Docker02", "util01", "nas"]
|
||
assert names(q="docker") == ["Docker02"] and names(q="0.0.9") == ["util01"]
|
||
assert names(group="Linux") == ["Docker02", "util01"] # membership overlaps
|
||
assert names(group=NO_GROUP) == ["nas"]
|
||
assert names(state="down") == ["Docker02"] and names(state="none") == ["nas"]
|
||
assert names(state="silent") == ["util01"] and names(state="up") == ["util01"]
|
||
assert names(group="Linux", state="up") == ["util01"]
|
||
|
||
|
||
def test_sort_hosts_puts_missing_values_last_either_way():
|
||
from steward.hosts.service import sort_hosts
|
||
hosts = [SimpleNamespace(id=i, name=n, address="") for i, n in [("a", "alpha"), ("b", "bravo"), ("c", "charlie")]]
|
||
agent = {"alpha": {"cpu_pct": 10.0, "fresh": True}, "bravo": {"cpu_pct": 70.0, "fresh": True}}
|
||
status = {"c": {"state": "down"}}
|
||
kw = dict(status=status, agent=agent, uptime={})
|
||
|
||
def names(sort, desc):
|
||
return [h.name for h in sort_hosts(hosts, sort, desc, **kw)]
|
||
|
||
assert names("cpu", True) == ["bravo", "alpha", "charlie"]
|
||
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
|