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:
+49
-13
@@ -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>")
|
||||
|
||||
Reference in New Issue
Block a user