From 2990113e10c4b71596a2768ca73f9f0fde2f3bf6 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 7 Oct 2026 18:53:03 -0400 Subject: [PATCH] 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 --- steward/hosts/routes.py | 62 ++++++++++++++---- steward/hosts/service.py | 77 ++++++++++++++++++++++ steward/static/steward.css | 15 ++++- steward/templates/_macros.html | 10 +++ steward/templates/hosts/_table.html | 77 ++++++++++++++++++++++ steward/templates/hosts/list.html | 84 +++++++----------------- tests/core/test_host_page.py | 98 +++++++++++++++++++++++++--- tests/integration/test_hosts_list.py | 85 ++++++++++++++++++++++++ 8 files changed, 423 insertions(+), 85 deletions(-) create mode 100644 steward/templates/hosts/_table.html create mode 100644 tests/integration/test_hosts_list.py diff --git a/steward/hosts/routes.py b/steward/hosts/routes.py index da1d8e0..7edf59c 100644 --- a/steward/hosts/routes.py +++ b/steward/hosts/routes.py @@ -2,7 +2,7 @@ from __future__ import annotations import asyncio import uuid from datetime import datetime, timezone -from quart import Blueprint, render_template, request, redirect, url_for, current_app, session +from quart import Blueprint, make_response, render_template, request, redirect, url_for, current_app, session from sqlalchemy import select from steward.ansible import executor, sources as ansible_src from steward.alerts import service as alert_svc @@ -47,25 +47,61 @@ async def list_hosts(): async def _list_page(add: dict | None = None): """The Hosts list. `add` opens the Add host dialog: {} for an empty form, - or {"values": form, "error": msg} when a submit comes back.""" + or {"values": form, "error": msg} when a submit comes back. + + Search, group and Status filters and a column sort come from the query + string (#5294). An HTMX request from the filter bar gets the table alone.""" + args = request.args + sort = args.get("sort") if args.get("sort") in host_svc.HOST_SORTS else "status" + default_desc = host_svc.HOST_SORTS[sort] + desc = {"desc": True, "asc": False}.get(args.get("dir"), default_desc) + f = {"q": args.get("q", "").strip(), "group": args.get("group", ""), + "state": args.get("state") if args.get("state") in host_svc.HOST_STATES else "", + "sort": sort, "dir": "desc" if desc else "asc"} + async with current_app.db_sessionmaker() as db: result = await db.execute(select(Host).order_by(Host.name)) hosts = result.scalars().all() - host_status = await host_svc.host_status(db, [h.id for h in hosts]) + ids = [h.id for h in hosts] + host_status = await host_svc.host_status(db, ids) uptime = await host_svc.compute_uptime(db) agent = await host_svc.agent_overview_by_host( db, [h.name for h in hosts], host_svc.agent_stale_after_seconds(current_app.config)) + groups = await host_svc.host_groups(db, ids) + group_names = await host_svc.group_names(db) - hosts = sorted(hosts, key=lambda h: ( - host_svc.host_rank(host_status.get(h.id), agent.get(h.name)), h.name.lower())) - return await render_template( - "hosts/list.html", - hosts=hosts, - host_status=host_status, - uptime=uptime, - agent=agent, - add=add, - ) + shown = host_svc.filter_hosts(hosts, q=f["q"], group=f["group"], state=f["state"], + status=host_status, agent=agent, groups=groups) + shown = host_svc.sort_hosts(shown, sort, desc, status=host_status, agent=agent, uptime=uptime) + + def filter_url(**change) -> str: + """This list's URL with some filters changed; defaults are left out.""" + want = {**f, **change} + if want["dir"] == ("desc" if host_svc.HOST_SORTS[want["sort"]] else "asc"): + want["dir"] = "" + if want["sort"] == "status": + want["sort"] = "" + return url_for("hosts.list_hosts", **{k: v for k, v in want.items() if v}) + + def sort_url(key: str) -> str: + """A column header's link: its own default direction, or the other + way when it is already the sort.""" + if key == sort: + return filter_url(sort=key, dir="asc" if desc else "desc") + return filter_url(sort=key, dir="desc" if host_svc.HOST_SORTS[key] else "asc") + + ctx = dict(hosts=shown, total=len(hosts), host_status=host_status, uptime=uptime, agent=agent, + groups=groups, group_names=group_names, f=f, filter_url=filter_url, sort_url=sort_url, + states=host_svc.HOST_STATES, no_group=host_svc.NO_GROUP, add=add) + # The filter bar's swap gets the table alone. A history restore (Back to a + # pushed URL) is an HTMX request too but wants the page, so key on the + # target; Vary keeps the browser from caching one as the other. + partial = request.headers.get("HX-Target") == "hosts-table" \ + and not request.headers.get("HX-History-Restore-Request") + resp = await make_response(await render_template( + "hosts/_table.html" if partial else "hosts/list.html", **ctx)) + resp.headers["Vary"] = "HX-Request" + return resp @hosts_bp.get("/") diff --git a/steward/hosts/service.py b/steward/hosts/service.py index e28f650..9240838 100644 --- a/steward/hosts/service.py +++ b/steward/hosts/service.py @@ -123,6 +123,83 @@ async def host_status(db, host_ids: list[str]) -> dict[str, dict]: return out +async def host_groups(db, host_ids: list[str]) -> dict[str, list[str]]: + """{host_id: [group name, …]}, from each host's linked Ansible target. + Ansible inventory groups are Steward's grouping system (ruling on the + Hosts system, #5294); a host with no linked target has no groups.""" + from sqlalchemy.orm import selectinload + from steward.models.ansible_inventory import AnsibleTarget + if not host_ids: + return {} + targets = (await db.execute( + select(AnsibleTarget).where(AnsibleTarget.host_id.in_(host_ids)) + .options(selectinload(AnsibleTarget.groups)))).scalars().all() + return {t.host_id: sorted((g.name for g in t.groups), key=str.lower) for t in targets} + + +async def group_names(db) -> list[str]: + """Every inventory group's name, for a group filter.""" + from steward.models.ansible_inventory import AnsibleGroup + return sorted((await db.execute(select(AnsibleGroup.name))).scalars().all(), key=str.lower) + + +# The Hosts list's filters and sorts (#5294). NO_GROUP picks hosts in no group. +HOST_STATES = {"down": "Down", "silent": "Agent silent", "up": "Up", "none": "No monitors"} +NO_GROUP = "__none__" +# Each sort and whether it starts descending: the busiest, slowest or least +# reliable host first; "status" is problems-first, the list's default. +HOST_SORTS = {"status": False, "name": False, "latency": True, "uptime": False, "cpu": True, "mem": True} + + +def host_state_keys(status: dict | None, agent: dict | None) -> set[str]: + """Which Status filters a host answers to; "Agent silent" can sit beside + "Down" or "Up", since it is about the agent and they are about monitors.""" + keys = set() + if status is None: + keys.add("none") + elif status["state"] in ("down", "up"): + keys.add(status["state"]) + if agent and not agent.get("fresh"): + keys.add("silent") + return keys + + +def filter_hosts(hosts, *, q: str = "", group: str = "", state: str = "", + status: dict, agent: dict, groups: dict) -> list: + """Hosts matching a search (name or address, any case), a group (or + NO_GROUP) and a Status filter; an empty value doesn't filter.""" + q = (q or "").strip().lower() + out = [] + for h in hosts: + if q and q not in h.name.lower() and q not in (h.address or "").lower(): + continue + mine = groups.get(h.id, []) + if (group == NO_GROUP and mine) or (group and group != NO_GROUP and group not in mine): + continue + if state and state not in host_state_keys(status.get(h.id), agent.get(h.name)): + continue + out.append(h) + return out + + +def sort_hosts(hosts, sort: str, desc: bool, *, status: dict, agent: dict, uptime: dict) -> list: + """Hosts in a column's order. Ties keep name order; a host with no value + for the column goes last whichever way it runs, so "—" never leads.""" + by_name = sorted(hosts, key=lambda h: h.name.lower()) + if sort == "name": + return list(reversed(by_name)) if desc else by_name + if sort == "status": + return sorted(by_name, key=lambda h: host_rank(status.get(h.id), agent.get(h.name)), reverse=desc) + value = { + "latency": lambda h: (status.get(h.id) or {}).get("latency_ms"), + "uptime": lambda h: (uptime.get(h.id) or {}).get("30d"), + "cpu": lambda h: (agent.get(h.name) or {}).get("cpu_pct"), + "mem": lambda h: (agent.get(h.name) or {}).get("mem_used_pct"), + }[sort] + have = [h for h in by_name if value(h) is not None] + return sorted(have, key=value, reverse=desc) + [h for h in by_name if value(h) is None] + + def host_rank(status: dict | None, agent: dict | None) -> int: """Where a host sorts in the Hosts list: a down monitor first, then an agent gone quiet, then monitors not yet checked, then the healthy; a host diff --git a/steward/static/steward.css b/steward/static/steward.css index 6c26577..78dc41a 100644 --- a/steward/static/steward.css +++ b/steward/static/steward.css @@ -184,11 +184,24 @@ strong, b, th { font-weight: 500; } .table tbody tr:last-child td { border-bottom: none; } .table tbody tr:hover td { background: var(--bg-elevated); } .table .td-actions { text-align: right; white-space: nowrap; } +/* A header that sorts its column (sort_th in _macros.html): reads as the + header it is, with the arrow on the current sort. */ +.sort-link { color: inherit; text-decoration: none; } +.sort-link:hover, .sort-active { color: var(--text); } +.sort-arrow { display: inline-block; min-width: 0.9em; text-align: center; } +/* Search and filters above a list; the search box takes the spare width. */ +.filter-bar { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; margin-bottom: 0.75rem; } +.filter-bar input[type=search] { flex: 1 1 16rem; width: auto; } +.filter-bar select { width: auto; flex: 0 0 auto; } +/* A small label that links somewhere, such as a group that filters the list. */ +.chip { display: inline-block; margin: 0.1rem 0.3rem 0.1rem 0; padding: 0.05rem 0.45rem; font-size: 0.75rem; + color: var(--text-muted); border: 1px solid var(--border); border-radius: var(--fs-radius-sm); white-space: nowrap; } +.chip:hover { color: var(--text); border-color: var(--border-mid); } /* Forms */ .form-group { margin-bottom: 1rem; } label { display: block; margin-bottom: 0.3rem; font-size: 0.85rem; color: var(--text-muted); font-family: var(--fs-font-body); } -input[type=text], input[type=email], input[type=password], input[type=number], input[type=url], select, textarea { +input[type=text], input[type=search], input[type=email], input[type=password], input[type=number], input[type=url], select, textarea { width: 100%; padding: 0.5rem 0.75rem; background: var(--bg); border: 1px solid var(--border-control); border-radius: 5px; color: var(--text); font-size: 0.9rem; font-family: inherit; transition: border-color .15s; diff --git a/steward/templates/_macros.html b/steward/templates/_macros.html index c48537c..a59cce9 100644 --- a/steward/templates/_macros.html +++ b/steward/templates/_macros.html @@ -101,3 +101,13 @@ {%- if cols %}{{ text }} {%- else %}

{{ text }}

{% endif %} {%- endmacro %} + +{# A table header that sorts its column (#5294). `url` is the list with this + column as the sort (the route builds it, keeping the other filters); + `active` and `desc` say whether it is the current sort and which way. + A plain link: the whole page reloads, so the filter bar stays in step. #} +{% macro sort_th(label, url, active=false, desc=false, cls=none, title=none) -%} + + {{ label }} + +{%- endmacro %} diff --git a/steward/templates/hosts/_table.html b/steward/templates/hosts/_table.html new file mode 100644 index 0000000..4b36245 --- /dev/null +++ b/steward/templates/hosts/_table.html @@ -0,0 +1,77 @@ +{# The Hosts table: the list page includes it, and the filter bar swaps it in + alone (#5294). Problems first unless a column header picked another sort; + groups are the linked Ansible target's (the Hosts system's ruling). #} +{% from "_macros.html" import status_badge, sort_th %} +{% macro pct(v, kind, stale) -%} +{%- if v is none %}— +{%- else %}{{ '%.0f'|format(v) }}%{% endif -%} +{%- endmacro %} +{% macro th(key, label, cls=none, title=none) -%} +{{ sort_th(label, sort_url(key), active=f.sort == key, desc=f.dir == 'desc', cls=cls, title=title) }} +{%- endmacro %} +{% if hosts %} +
+ + + + {{ th("name", "Name") }} + + {% if group_names %}{% endif %} + {{ th("status", "Status", title="Problems first: a down monitor, then an agent gone quiet, then monitors not yet checked") }} + {{ th("latency", "Latency", "num", "Fastest latest response among this host's monitors") }} + + {{ th("uptime", "30d uptime", "num", "Uptime over 30 days across this host's monitors; hover a figure for 24h and 7d") }} + {{ th("cpu", "CPU", "num", "Agent: CPU in use, latest reading") }} + {{ th("mem", "Memory", "num", "Agent: memory in use, latest reading") }} + + + + {% for host in hosts %} + {% set hs = host_status.get(host.id) %} + {% set ut = uptime.get(host.id, {}) %} + {% set am = agent.get(host.name) %} + {% set silent = am and not am.fresh %} + + + + {% if group_names %} + + {% endif %} + + + + + + + + {% endfor %} + +
AddressGroupsMonitors
{{ host.name }}{{ host.address or "—" }} + {%- for g in groups.get(host.id, []) %}{{ g }}{% else %}—{% endfor -%} + + {% if hs %} + {{ status_badge(hs.state) }} + {%- if hs.state == 'down' and hs.down_since %} for {{ hs.down_since|ago|striptags|replace(" ago", "") }}{% endif %} + {% else %} + — + {% endif %} + {% if silent %} +
+ {{ status_badge('stale', label="agent silent", title="No agent report since " ~ (am.last_at.strftime('%Y-%m-%d %H:%M UTC') if am.last_at else 'it started')) }} + {%- if am.last_at %} for {{ am.last_at|ago|striptags|replace(" ago", "") }}{% endif %} +
+ {% endif %} +
+ {% if hs and hs.latency_ms is not none %} + {{ hs.latency_ms | round(1) }} ms + {% else %}—{% endif %} + {{ hs.count if hs else "—" }} + {% if ut.get('30d') is none %}— + {% else %}{{ "%.2f"|format(ut['30d']) }}%{% endif %} + {% if am %}{{ pct(am.cpu_pct, 'cpu', silent) }}{% else %}—{% endif %}{% if am %}{{ pct(am.mem_used_pct, 'mem', silent) }}{% else %}—{% endif %}
+
+{% else %} +
+

No hosts match these filters. Show every host

+
+{% endif %} diff --git a/steward/templates/hosts/list.html b/steward/templates/hosts/list.html index 9e9d8a9..25d5b9a 100644 --- a/steward/templates/hosts/list.html +++ b/steward/templates/hosts/list.html @@ -1,15 +1,13 @@ {% extends "base.html" %} -{% from "_macros.html" import status_badge %} {# Hosts, problems first (host_svc.host_rank): a down monitor, then an agent gone quiet, then monitors not yet checked, then the healthy. Status says what is wrong and for how long; editing and deleting a host happen on its own page (#5281). Uptime is one figure, 30 days, with the shorter windows on hover: Monitors is where uptime is read in full (#5280). Add host is a - dialog over this list (#5291); `add` opens it, carrying a clash's error. #} -{% macro pct(v, kind, stale) -%} -{%- if v is none %}— -{%- else %}{{ '%.0f'|format(v) }}%{% endif -%} -{%- endmacro %} + dialog over this list (#5291); `add` opens it, carrying a clash's error. + The filter bar searches name and address and filters by group and status; + it swaps the table (hosts/_table.html) alone and keeps the filters in the + URL (#5294). #} {% block title %}Hosts — Steward{% endblock %} {% block content %} {% set can_add = session.user_role in ['operator', 'admin'] %} @@ -24,61 +22,25 @@ {% endif %} -{% if hosts %} -
- - - - - - - - - - - - - - - {% for host in hosts %} - {% set hs = host_status.get(host.id) %} - {% set ut = uptime.get(host.id, {}) %} - {% set am = agent.get(host.name) %} - {% set silent = am and not am.fresh %} - - - - - - - - - - - {% endfor %} - -
NameAddressStatusLatencyMonitors30d uptimeCPUMemory
{{ host.name }}{{ host.address or "—" }} - {% if hs %} - {{ status_badge(hs.state) }} - {%- if hs.state == 'down' and hs.down_since %} for {{ hs.down_since|ago|striptags|replace(" ago", "") }}{% endif %} - {% else %} - — - {% endif %} - {% if silent %} -
- {{ status_badge('stale', label="agent silent", title="No agent report since " ~ (am.last_at.strftime('%Y-%m-%d %H:%M UTC') if am.last_at else 'it started')) }} - {%- if am.last_at %} for {{ am.last_at|ago|striptags|replace(" ago", "") }}{% endif %} -
- {% endif %} -
- {% if hs and hs.latency_ms is not none %} - {{ hs.latency_ms | round(1) }} ms - {% else %}—{% endif %} - {{ hs.count if hs else "—" }} - {% if ut.get('30d') is none %}— - {% else %}{{ "%.2f"|format(ut['30d']) }}%{% endif %} - {% if am %}{{ pct(am.cpu_pct, 'cpu', silent) }}{% else %}—{% endif %}{% if am %}{{ pct(am.mem_used_pct, 'mem', silent) }}{% else %}—{% endif %}
-
+{% if total %} + +
{% include "hosts/_table.html" %}
{% else %}

No hosts yet.{% if can_add %} Summon one to the table.{% endif %}

diff --git a/tests/core/test_host_page.py b/tests/core/test_host_page.py index f503577..a2c22d3 100644 --- a/tests/core/test_host_page.py +++ b/tests/core/test_host_page.py @@ -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"]*>(.*?)", 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"]*>([^<]+)", 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'', closed, re.S).group(0) assert 'action="/hosts/"' in dialog and ">Cancel" in dialog assert re.search(r']*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 "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();" 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 'nas' in page and ">Save" 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'
', 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 '' in bar + assert [o for o in re.findall(r'