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>")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -101,3 +101,13 @@
|
||||
{%- 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 %}
|
||||
{%- 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 %}
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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 %}<span class="tone-dim">—</span>
|
||||
{%- else %}<span class="tone-{{ 'dim' if stale else status_class(v, kind) }}">{{ '%.0f'|format(v) }}%</span>{% 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 %}
|
||||
</div>
|
||||
</div>
|
||||
{% if hosts %}
|
||||
<div class="card-flush">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Address</th>
|
||||
<th title="The worst latest result across this host's monitors, and the agent if it has gone quiet">Status</th>
|
||||
<th class="num" title="Fastest latest response among this host's monitors">Latency</th>
|
||||
<th class="num" title="Monitors with a result">Monitors</th>
|
||||
<th class="num" title="Uptime over 30 days across this host's monitors; hover for 24h and 7d">30d uptime</th>
|
||||
<th class="num" title="Agent: CPU in use, latest reading">CPU</th>
|
||||
<th class="num" title="Agent: memory in use, latest reading">Memory</th>
|
||||
</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>
|
||||
<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>
|
||||
{% if total %}
|
||||
<form class="filter-bar" method="get" action="/hosts/" role="search"
|
||||
hx-target="#hosts-table" hx-push-url="true" hx-include="closest form">
|
||||
<input type="search" name="q" value="{{ f.q }}" class="input" placeholder="Search name or address"
|
||||
aria-label="Search hosts by name or address" autocomplete="off"
|
||||
hx-get="/hosts/" hx-trigger="input changed delay:250ms, search">
|
||||
<select name="group" aria-label="Group" hx-get="/hosts/" hx-trigger="change">
|
||||
<option value="">All groups</option>
|
||||
{% for g in group_names %}<option value="{{ g }}"{% if f.group == g %} selected{% endif %}>{{ g }}</option>{% endfor %}
|
||||
<option value="{{ no_group }}"{% if f.group == no_group %} selected{% endif %}>No group</option>
|
||||
</select>
|
||||
<select name="state" aria-label="Status" hx-get="/hosts/" hx-trigger="change">
|
||||
<option value="">Any status</option>
|
||||
{% for key, label in states.items() %}<option value="{{ key }}"{% if f.state == key %} selected{% endif %}>{{ label }}</option>{% endfor %}
|
||||
</select>
|
||||
{% if f.sort != 'status' %}<input type="hidden" name="sort" value="{{ f.sort }}"><input type="hidden" name="dir" value="{{ f.dir }}">{% endif %}
|
||||
<noscript><button type="submit" class="btn btn-sm">Filter</button></noscript>
|
||||
</form>
|
||||
<div id="hosts-table">{% include "hosts/_table.html" %}</div>
|
||||
{% else %}
|
||||
<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>
|
||||
|
||||
@@ -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"<th[^>]*>(.*?)</th>", 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"<th[^>]*>([^<]+)</th>", 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'<dialog id="add-host".*?</dialog>', closed, re.S).group(0)
|
||||
assert 'action="/hosts/"' in dialog and ">Cancel</button>" in dialog
|
||||
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/"):
|
||||
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
|
||||
|
||||
|
||||
@@ -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();</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)
|
||||
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
|
||||
|
||||
|
||||
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
|
||||
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user