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 asyncio
import uuid import uuid
from datetime import datetime, timezone 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 sqlalchemy import select
from steward.ansible import executor, sources as ansible_src from steward.ansible import executor, sources as ansible_src
from steward.alerts import service as alert_svc from steward.alerts import service as alert_svc
@@ -47,25 +47,61 @@ async def list_hosts():
async def _list_page(add: dict | None = None): async def _list_page(add: dict | None = None):
"""The Hosts list. `add` opens the Add host dialog: {} for an empty form, """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: async with current_app.db_sessionmaker() as db:
result = await db.execute(select(Host).order_by(Host.name)) result = await db.execute(select(Host).order_by(Host.name))
hosts = result.scalars().all() 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) uptime = await host_svc.compute_uptime(db)
agent = await host_svc.agent_overview_by_host( agent = await host_svc.agent_overview_by_host(
db, [h.name for h in hosts], host_svc.agent_stale_after_seconds(current_app.config)) 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: ( shown = host_svc.filter_hosts(hosts, q=f["q"], group=f["group"], state=f["state"],
host_svc.host_rank(host_status.get(h.id), agent.get(h.name)), h.name.lower())) status=host_status, agent=agent, groups=groups)
return await render_template( shown = host_svc.sort_hosts(shown, sort, desc, status=host_status, agent=agent, uptime=uptime)
"hosts/list.html",
hosts=hosts, def filter_url(**change) -> str:
host_status=host_status, """This list's URL with some filters changed; defaults are left out."""
uptime=uptime, want = {**f, **change}
agent=agent, if want["dir"] == ("desc" if host_svc.HOST_SORTS[want["sort"]] else "asc"):
add=add, 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>") @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 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: def host_rank(status: dict | None, agent: dict | None) -> int:
"""Where a host sorts in the Hosts list: a down monitor first, then an """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 agent gone quiet, then monitors not yet checked, then the healthy; a host
+14 -1
View File
@@ -184,11 +184,24 @@ strong, b, th { font-weight: 500; }
.table tbody tr:last-child td { border-bottom: none; } .table tbody tr:last-child td { border-bottom: none; }
.table tbody tr:hover td { background: var(--bg-elevated); } .table tbody tr:hover td { background: var(--bg-elevated); }
.table .td-actions { text-align: right; white-space: nowrap; } .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 */ /* Forms */
.form-group { margin-bottom: 1rem; } .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); } 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); 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; border-radius: 5px; color: var(--text); font-size: 0.9rem; font-family: inherit;
transition: border-color .15s; transition: border-color .15s;
+10
View File
@@ -101,3 +101,13 @@
{%- if cols %}<tr><td colspan="{{ cols }}"><span class="field-hint" role="status">{{ text }}</span></td></tr> {%- if cols %}<tr><td colspan="{{ cols }}"><span class="field-hint" role="status">{{ text }}</span></td></tr>
{%- else %}<p class="field-hint" role="status">{{ text }}</p>{% endif %} {%- else %}<p class="field-hint" role="status">{{ text }}</p>{% endif %}
{%- endmacro %} {%- 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) -%}
<th{% if cls %} class="{{ cls }}"{% endif %}{% if title %} title="{{ title }}"{% endif %} aria-sort="{{ ('descending' if desc else 'ascending') if active else 'none' }}">
<a class="sort-link{% if active %} sort-active{% endif %}" href="{{ url }}">{{ label }}<span class="sort-arrow" aria-hidden="true">{% if active %}{{ '↓' if desc else '↑' }}{% endif %}</span></a>
</th>
{%- endmacro %}
+77
View File
@@ -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 %}<span class="tone-dim">—</span>
{%- else %}<span class="tone-{{ 'dim' if stale else status_class(v, kind) }}">{{ '%.0f'|format(v) }}%</span>{% 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 %}
<div class="card-flush">
<table class="table">
<thead>
<tr>
{{ th("name", "Name") }}
<th>Address</th>
{% if group_names %}<th title="The groups of this host's linked Ansible target">Groups</th>{% 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 class="num" title="Monitors with a result">Monitors</th>
{{ 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") }}
</tr>
</thead>
<tbody>
{% 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 %}
<tr>
<td><a href="/hosts/{{ host.id }}" style="font-weight:500;">{{ host.name }}</a></td>
<td class="tone-muted">{{ host.address or "—" }}</td>
{% if group_names %}
<td>
{%- for g in groups.get(host.id, []) %}<a class="chip" href="{{ filter_url(group=g) }}" title="Show the hosts in {{ g }}">{{ g }}</a>{% else %}<span class="tone-dim">—</span>{% endfor -%}
</td>
{% endif %}
<td>
{% if hs %}
{{ status_badge(hs.state) }}
{%- if hs.state == 'down' and hs.down_since %} <span class="tone-dim">for {{ hs.down_since|ago|striptags|replace(" ago", "") }}</span>{% endif %}
{% else %}
<span class="tone-dim" title="No monitors with a result">—</span>
{% endif %}
{% if silent %}
<div style="margin-top:0.15rem;">
{{ 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 %} <span class="tone-dim">for {{ am.last_at|ago|striptags|replace(" ago", "") }}</span>{% endif %}
</div>
{% endif %}
</td>
<td class="num">
{% if hs and hs.latency_ms is not none %}
<span class="tone-{{ status_class(hs.latency_ms, 'latency') }}">{{ hs.latency_ms | round(1) }} ms</span>
{% else %}<span class="tone-dim">—</span>{% endif %}
</td>
<td class="num tone-muted">{{ hs.count if hs else "—" }}</td>
<td class="num" title="24h {{ ('%.2f%%'|format(ut['24h'])) if ut.get('24h') is not none else '—' }} · 7d {{ ('%.2f%%'|format(ut['7d'])) if ut.get('7d') is not none else '—' }}">
{% if ut.get('30d') is none %}<span class="tone-dim">—</span>
{% else %}<span class="tone-{{ status_class(ut['30d'], 'uptime') }}">{{ "%.2f"|format(ut['30d']) }}%</span>{% endif %}
</td>
<td class="num"{% if silent %} title="Last reading before the agent went quiet"{% endif %}>{% if am %}{{ pct(am.cpu_pct, 'cpu', silent) }}{% else %}<span class="tone-dim" title="No agent on this host">—</span>{% endif %}</td>
<td class="num"{% if silent %} title="Last reading before the agent went quiet"{% endif %}>{% if am %}{{ pct(am.mem_used_pct, 'mem', silent) }}{% else %}<span class="tone-dim" title="No agent on this host">—</span>{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="card">
<p style="color:var(--text-muted);margin:0;">No hosts match these filters. <a href="/hosts/">Show every host</a></p>
</div>
{% endif %}
+23 -61
View File
@@ -1,15 +1,13 @@
{% extends "base.html" %} {% extends "base.html" %}
{% from "_macros.html" import status_badge %}
{# Hosts, problems first (host_svc.host_rank): a down monitor, then an agent {# 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 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 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 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 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. #} dialog over this list (#5291); `add` opens it, carrying a clash's error.
{% macro pct(v, kind, stale) -%} The filter bar searches name and address and filters by group and status;
{%- if v is none %}<span class="tone-dim">—</span> it swaps the table (hosts/_table.html) alone and keeps the filters in the
{%- else %}<span class="tone-{{ 'dim' if stale else status_class(v, kind) }}">{{ '%.0f'|format(v) }}%</span>{% endif -%} URL (#5294). #}
{%- endmacro %}
{% block title %}Hosts — Steward{% endblock %} {% block title %}Hosts — Steward{% endblock %}
{% block content %} {% block content %}
{% set can_add = session.user_role in ['operator', 'admin'] %} {% set can_add = session.user_role in ['operator', 'admin'] %}
@@ -24,61 +22,25 @@
{% endif %} {% endif %}
</div> </div>
</div> </div>
{% if hosts %} {% if total %}
<div class="card-flush"> <form class="filter-bar" method="get" action="/hosts/" role="search"
<table class="table"> hx-target="#hosts-table" hx-push-url="true" hx-include="closest form">
<thead> <input type="search" name="q" value="{{ f.q }}" class="input" placeholder="Search name or address"
<tr> aria-label="Search hosts by name or address" autocomplete="off"
<th>Name</th> hx-get="/hosts/" hx-trigger="input changed delay:250ms, search">
<th>Address</th> <select name="group" aria-label="Group" hx-get="/hosts/" hx-trigger="change">
<th title="The worst latest result across this host's monitors, and the agent if it has gone quiet">Status</th> <option value="">All groups</option>
<th class="num" title="Fastest latest response among this host's monitors">Latency</th> {% for g in group_names %}<option value="{{ g }}"{% if f.group == g %} selected{% endif %}>{{ g }}</option>{% endfor %}
<th class="num" title="Monitors with a result">Monitors</th> <option value="{{ no_group }}"{% if f.group == no_group %} selected{% endif %}>No group</option>
<th class="num" title="Uptime over 30 days across this host's monitors; hover for 24h and 7d">30d uptime</th> </select>
<th class="num" title="Agent: CPU in use, latest reading">CPU</th> <select name="state" aria-label="Status" hx-get="/hosts/" hx-trigger="change">
<th class="num" title="Agent: memory in use, latest reading">Memory</th> <option value="">Any status</option>
</tr> {% for key, label in states.items() %}<option value="{{ key }}"{% if f.state == key %} selected{% endif %}>{{ label }}</option>{% endfor %}
</thead> </select>
<tbody> {% if f.sort != 'status' %}<input type="hidden" name="sort" value="{{ f.sort }}"><input type="hidden" name="dir" value="{{ f.dir }}">{% endif %}
{% for host in hosts %} <noscript><button type="submit" class="btn btn-sm">Filter</button></noscript>
{% set hs = host_status.get(host.id) %} </form>
{% set ut = uptime.get(host.id, {}) %} <div id="hosts-table">{% include "hosts/_table.html" %}</div>
{% set am = agent.get(host.name) %}
{% set silent = am and not am.fresh %}
<tr>
<td><a href="/hosts/{{ host.id }}" style="font-weight:500;">{{ host.name }}</a></td>
<td class="tone-muted">{{ host.address or "—" }}</td>
<td>
{% if hs %}
{{ status_badge(hs.state) }}
{%- if hs.state == 'down' and hs.down_since %} <span class="tone-dim">for {{ hs.down_since|ago|striptags|replace(" ago", "") }}</span>{% endif %}
{% else %}
<span class="tone-dim" title="No monitors with a result">—</span>
{% endif %}
{% if silent %}
<div style="margin-top:0.15rem;">
{{ 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 %} <span class="tone-dim">for {{ am.last_at|ago|striptags|replace(" ago", "") }}</span>{% endif %}
</div>
{% endif %}
</td>
<td class="num">
{% if hs and hs.latency_ms is not none %}
<span class="tone-{{ status_class(hs.latency_ms, 'latency') }}">{{ hs.latency_ms | round(1) }} ms</span>
{% else %}<span class="tone-dim">—</span>{% endif %}
</td>
<td class="num tone-muted">{{ hs.count if hs else "—" }}</td>
<td class="num" title="24h {{ ('%.2f%%'|format(ut['24h'])) if ut.get('24h') is not none else '—' }} · 7d {{ ('%.2f%%'|format(ut['7d'])) if ut.get('7d') is not none else '—' }}">
{% if ut.get('30d') is none %}<span class="tone-dim">—</span>
{% else %}<span class="tone-{{ status_class(ut['30d'], 'uptime') }}">{{ "%.2f"|format(ut['30d']) }}%</span>{% endif %}
</td>
<td class="num"{% if silent %} title="Last reading before the agent went quiet"{% endif %}>{% if am %}{{ pct(am.cpu_pct, 'cpu', silent) }}{% else %}<span class="tone-dim" title="No agent on this host">—</span>{% endif %}</td>
<td class="num"{% if silent %} title="Last reading before the agent went quiet"{% endif %}>{% if am %}{{ pct(am.mem_used_pct, 'mem', silent) }}{% else %}<span class="tone-dim" title="No agent on this host">—</span>{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %} {% else %}
<div class="card"> <div class="card">
<p style="color:var(--text-muted);">No hosts yet.{% if can_add %} <a href="/hosts/?add=1" onclick="document.getElementById('add-host').showModal();return false;">Summon one to the table.</a>{% endif %}</p> <p style="color:var(--text-muted);">No hosts yet.{% if can_add %} <a href="/hosts/?add=1" onclick="document.getElementById('add-host').showModal();return false;">Summon one to the table.</a>{% endif %}</p>
+88 -10
View File
@@ -4,6 +4,25 @@ from types import SimpleNamespace
from quart import render_template, session 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): async def test_ansible_comes_before_the_docker_list(app, monkeypatch):
# The Docker list can run to dozens of containers; Ansible under it was # 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="") quiet = SimpleNamespace(id="h2", name="pi", address="")
async with app.test_request_context("/hosts/"): async with app.test_request_context("/hosts/"):
session["user_role"] = "admin" session["user_role"] = "admin"
page = await render_template( page = await render_template("hosts/list.html", **_list_ctx(
"hosts/list.html", hosts=[down, quiet], hosts=[down, quiet],
host_status={"h1": {"state": "down", "latency_ms": None, "count": 2, host_status={"h1": {"state": "down", "latency_ms": None, "count": 2,
"down_since": now - timedelta(minutes=12)}}, "down_since": now - timedelta(minutes=12)}},
uptime={"h1": {"24h": 91.5, "7d": 98.0, "30d": 99.42}}, uptime={"h1": {"24h": 91.5, "7d": 98.0, "30d": 99.42}},
agent={"pi": {"fresh": False, "last_at": now - timedelta(minutes=7), agent={"pi": {"fresh": False, "last_at": now - timedelta(minutes=7),
"cpu_pct": 3.0, "mem_used_pct": 41.0}}) "cpu_pct": 3.0, "mem_used_pct": 41.0}}))
assert re.findall(r"<th[^>]*>([^<]+)</th>", page) == [ # No inventory groups: no Groups column.
"Name", "Address", "Status", "Latency", "Monitors", "30d uptime", "CPU", "Memory"] 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 "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 "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 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.""" """#5291: the form was two fields on a page of its own."""
async with app.test_request_context("/hosts/"): async with app.test_request_context("/hosts/"):
session["user_role"] = "operator" 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) 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 'action="/hosts/"' in dialog and ">Cancel</button>" in dialog
assert re.search(r'<input[^>]*name="address"[^>]*>', dialog).group(0).count("required") == 0 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/"): async with app.test_request_context("/hosts/"):
session["user_role"] = "viewer" 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 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.""" """#5283, #5291: the list again, the dialog open, an inline error and the typed values."""
async with app.test_request_context("/hosts/"): async with app.test_request_context("/hosts/"):
session["user_role"] = "operator" session["user_role"] = "operator"
page = await render_template( page = await render_template("hosts/list.html", **_list_ctx(
"hosts/list.html", hosts=[], host_status={}, uptime={}, agent={}, add={"values": {"name": "nas", "address": "10.0.0.9"}, "error": "A host named nas already exists."}))
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 'role="alert">A host named nas already exists.<' in page
assert 'value="nas"' in page and 'value="10.0.0.9"' in page assert 'value="nas"' in page and 'value="10.0.0.9"' in page
assert "getElementById('add-host').showModal();</script>" 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) 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 '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 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
+85
View File
@@ -0,0 +1,85 @@
"""Integration: the Hosts list's search, group and status filters and its
sort (#5294) against real Postgres. Groups are the linked Ansible target's;
the filter bar's swap (HX-Target: hosts-table) gets the table alone, and a
history restore gets the whole page."""
from __future__ import annotations
import asyncio
import os
import time
import uuid
import pytest
pytestmark = pytest.mark.integration
@pytest.fixture
def app():
if not os.environ.get("STEWARD_DATABASE_URL"):
pytest.skip("needs Postgres")
from steward.app import create_app
return create_app(testing=False)
def test_the_hosts_list_filters_by_search_group_and_status(app):
from steward.models.ansible_inventory import AnsibleGroup, AnsibleTarget
from steward.models.hosts import Host
from steward.models.users import User, UserRole
async def _go():
tag = uuid.uuid4().hex[:8]
uid = str(uuid.uuid4())
async with app.db_sessionmaker() as s:
async with s.begin():
s.add(User(id=uid, username=f"hl-{tag}", email=f"hl-{tag}@test",
password_hash="x", role=UserRole.viewer, is_active=True))
swarm = Host(name=f"swarm-{tag}", address="10.9.0.1")
lone = Host(name=f"lone-{tag}", address="10.9.0.2")
s.add_all([swarm, lone])
await s.flush()
group = AnsibleGroup(name=f"Swarm {tag}")
target = AnsibleTarget(name=f"t-{tag}", address="10.9.0.1", host_id=swarm.id)
target.groups = [group]
s.add_all([group, target])
client = app.test_client()
async with client.session_transaction() as sess:
sess.update(user_id=uid, user_role="viewer", username=f"hl-{tag}", login_at=time.time())
page = await (await client.get(f"/hosts/?q={tag}")).get_data(as_text=True)
assert 'class="filter-bar"' in page and f"swarm-{tag}" in page and f"lone-{tag}" in page
assert f'<option value="Swarm {tag}"' in page # the group filter lists it
assert f'>Swarm {tag}</a>' in page # and swarm's row shows it as a chip
swap = {"HX-Request": "true", "HX-Target": "hosts-table"}
resp = await client.get("/hosts/", query_string={"q": tag, "group": f"Swarm {tag}"}, headers=swap)
table = await resp.get_data(as_text=True)
assert resp.headers["Vary"] == "HX-Request"
assert 'class="filter-bar"' not in table and "<html" not in table
assert f"swarm-{tag}" in table and f"lone-{tag}" not in table
table = await (await client.get("/hosts/", query_string={"q": tag, "group": "__none__"},
headers=swap)).get_data(as_text=True)
assert f"lone-{tag}" in table and f"swarm-{tag}" not in table
# Neither host has a monitor: "No monitors" has both, "Down" neither.
table = await (await client.get("/hosts/", query_string={"q": tag, "state": "none"},
headers=swap)).get_data(as_text=True)
assert f"swarm-{tag}" in table and f"lone-{tag}" in table
table = await (await client.get("/hosts/", query_string={"q": tag, "state": "down"},
headers=swap)).get_data(as_text=True)
assert "No hosts match these filters." in table
# Sorted by name, descending: lone after swarm.
page = await (await client.get("/hosts/", query_string={"q": tag, "sort": "name", "dir": "desc"})
).get_data(as_text=True)
assert page.index(f"swarm-{tag}") < page.index(f"lone-{tag}")
assert 'aria-sort="descending"' in page
# Back to a pushed URL: HTMX asks again, and gets the page.
restore = {"HX-Request": "true", "HX-History-Restore-Request": "true"}
page = await (await client.get(f"/hosts/?q={tag}", headers=restore)).get_data(as_text=True)
assert 'class="filter-bar"' in page
asyncio.run(_go())