feat(hosts): the Hosts list searches, filters by group and status, sorts by column, and shows each host's groups (#5294)
CI / lint (push) Successful in 2s
CI / integration (push) Failing after 52s
CI / unit (push) Successful in 1m17s
CI / publish (push) Skipped

#5294 "Hosts list: search, group and status filters; sortable columns; a Groups column".
Ansible inventory groups are Steward's grouping system (operator's ruling): a host's
groups are its linked target's, and membership overlaps, so the list filters by group
rather than sectioning by it.

- Filter bar: search on name or address, a group (or "No group"), a status (Down,
  Agent silent, Up, No monitors). The table swaps in alone and the URL keeps the filters.
- Name, Status, Latency, 30d uptime, CPU and Memory headers sort (sort_th in _macros);
  problems-first stays the default, and a host with no value goes last either way.
- A Groups column of chips, each filtering to its group; shown when any group exists.
- A swap is keyed on HX-Target, so Back to a pushed URL still gets the page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 18:53:03 -04:00
co-authored by Claude Opus 5.5
parent a92eb8001b
commit 2990113e10
8 changed files with 423 additions and 85 deletions
+88 -10
View File
@@ -4,6 +4,25 @@ 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
@@ -66,15 +85,15 @@ async def test_the_hosts_list_says_what_is_wrong_and_labels_its_figures(app):
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", hosts=[down, quiet],
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}})
assert re.findall(r"<th[^>]*>([^<]+)</th>", page) == [
"Name", "Address", "Status", "Latency", "Monitors", "30d uptime", "CPU", "Memory"]
"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
@@ -97,7 +116,7 @@ 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", hosts=[], host_status={}, uptime={}, agent={}, add=None)
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
@@ -107,7 +126,7 @@ async def test_add_host_is_a_dialog_on_the_hosts_list(app):
async with app.test_request_context("/hosts/"):
session["user_role"] = "viewer"
viewer = await render_template("hosts/list.html", hosts=[], host_status={}, uptime={}, agent={}, add={})
viewer = await render_template("hosts/list.html", **_list_ctx(add={}))
assert "<dialog" not in viewer and ">Add host<" not in viewer
@@ -115,9 +134,8 @@ 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", hosts=[], host_status={}, uptime={}, agent={},
add={"values": {"name": "nas", "address": "10.0.0.9"}, "error": "A host named nas already exists."})
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
@@ -130,3 +148,63 @@ async def test_edit_host_keeps_its_page(app):
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
+85
View File
@@ -0,0 +1,85 @@
"""Integration: the Hosts list's search, group and status filters and its
sort (#5294) against real Postgres. Groups are the linked Ansible target's;
the filter bar's swap (HX-Target: hosts-table) gets the table alone, and a
history restore gets the whole page."""
from __future__ import annotations
import asyncio
import os
import time
import uuid
import pytest
pytestmark = pytest.mark.integration
@pytest.fixture
def app():
if not os.environ.get("STEWARD_DATABASE_URL"):
pytest.skip("needs Postgres")
from steward.app import create_app
return create_app(testing=False)
def test_the_hosts_list_filters_by_search_group_and_status(app):
from steward.models.ansible_inventory import AnsibleGroup, AnsibleTarget
from steward.models.hosts import Host
from steward.models.users import User, UserRole
async def _go():
tag = uuid.uuid4().hex[:8]
uid = str(uuid.uuid4())
async with app.db_sessionmaker() as s:
async with s.begin():
s.add(User(id=uid, username=f"hl-{tag}", email=f"hl-{tag}@test",
password_hash="x", role=UserRole.viewer, is_active=True))
swarm = Host(name=f"swarm-{tag}", address="10.9.0.1")
lone = Host(name=f"lone-{tag}", address="10.9.0.2")
s.add_all([swarm, lone])
await s.flush()
group = AnsibleGroup(name=f"Swarm {tag}")
target = AnsibleTarget(name=f"t-{tag}", address="10.9.0.1", host_id=swarm.id)
target.groups = [group]
s.add_all([group, target])
client = app.test_client()
async with client.session_transaction() as sess:
sess.update(user_id=uid, user_role="viewer", username=f"hl-{tag}", login_at=time.time())
page = await (await client.get(f"/hosts/?q={tag}")).get_data(as_text=True)
assert 'class="filter-bar"' in page and f"swarm-{tag}" in page and f"lone-{tag}" in page
assert f'<option value="Swarm {tag}"' in page # the group filter lists it
assert f'>Swarm {tag}</a>' in page # and swarm's row shows it as a chip
swap = {"HX-Request": "true", "HX-Target": "hosts-table"}
resp = await client.get("/hosts/", query_string={"q": tag, "group": f"Swarm {tag}"}, headers=swap)
table = await resp.get_data(as_text=True)
assert resp.headers["Vary"] == "HX-Request"
assert 'class="filter-bar"' not in table and "<html" not in table
assert f"swarm-{tag}" in table and f"lone-{tag}" not in table
table = await (await client.get("/hosts/", query_string={"q": tag, "group": "__none__"},
headers=swap)).get_data(as_text=True)
assert f"lone-{tag}" in table and f"swarm-{tag}" not in table
# Neither host has a monitor: "No monitors" has both, "Down" neither.
table = await (await client.get("/hosts/", query_string={"q": tag, "state": "none"},
headers=swap)).get_data(as_text=True)
assert f"swarm-{tag}" in table and f"lone-{tag}" in table
table = await (await client.get("/hosts/", query_string={"q": tag, "state": "down"},
headers=swap)).get_data(as_text=True)
assert "No hosts match these filters." in table
# Sorted by name, descending: lone after swarm.
page = await (await client.get("/hosts/", query_string={"q": tag, "sort": "name", "dir": "desc"})
).get_data(as_text=True)
assert page.index(f"swarm-{tag}") < page.index(f"lone-{tag}")
assert 'aria-sort="descending"' in page
# Back to a pushed URL: HTMX asks again, and gets the page.
restore = {"HX-Request": "true", "HX-History-Restore-Request": "true"}
page = await (await client.get(f"/hosts/?q={tag}", headers=restore)).get_data(as_text=True)
assert 'class="filter-bar"' in page
asyncio.run(_go())