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