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
+49 -13
View File
@@ -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("/<host_id>")
+77
View File
@@ -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