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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user