feat(hosts): the Hosts list searches, filters by group and status, sorts by column, and shows each host's groups (#5294)
#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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user