Files
FabledSteward/steward/templates/hosts/detail.html
T
bvandeusenandClaude Opus 5.5 8e890c6914
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 50s
CI / unit (push) Successful in 1m20s
CI / publish (push) Successful in 1m13s
feat(ui): host page leads with its alerts; old readings say how old (#5210)
#5210 "Problems first, with since-time, across every list" (milestone 477),
second batch.

- Host page: an "Alerts on this host" strip first (rules on its agent
  series, its containers or its monitors: alert_svc.watches_host); its
  monitors problems first with "Down 12m · reason"; Docker above Ansible.
- Uptime page and widget: worst 24h uptime first, then 7d and 30d; hosts
  with no uptime yet last.
- Agent panel and fleet widget: last seen as "3m ago", not a clock time.
- SNMP: a device is "Reachable" only while it answered within the last 3
  polls; older readings say "No answer" with their age, and every device
  says when it was last read (was: reachable whenever any reading existed).
- Traefik: a router missing from 3 scrapes in a row is listed apart as no
  longer reported, by when it was last seen, instead of keeping its last
  state (a removed router stayed Failing).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 00:02:20 -04:00

254 lines
13 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% extends "base.html" %}
{% from "_macros.html" import crumbs, status_dot, status_badge, icon_button %}
{% from "monitors/_fields.html" import type_fields, toggle_script %}
{% block title %}{{ host.name }} — Hosts — Steward{% endblock %}
{% block breadcrumb %}{{ crumbs([("Hosts", "/hosts/"), (host.name, "")]) }}{% endblock %}
{% block content %}
<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:1rem;flex-wrap:wrap;margin-bottom:1.25rem;">
<div>
<h1 class="page-title" style="margin-bottom:0.2rem;">{{ host.name }}</h1>
<div style="color:var(--text-muted);font-size:0.9rem;font-family:ui-monospace,monospace;">
{{ host.address or "no address — resolves by name" }}
</div>
</div>
<div style="display:flex;gap:0.5rem;align-items:center;">
<a href="/hosts/{{ host.id }}/edit" class="btn btn-ghost btn-sm">Edit</a>
{% if session.user_role == 'admin' %}
<form method="post" action="/hosts/{{ host.id }}/delete" style="margin:0;"
data-confirm="Delete host {{ host.name }}? Monitors and history are removed.">{{ csrf_field() }}
<button type="submit" class="btn btn-danger btn-sm">Delete</button>
</form>
{% endif %}
</div>
</div>
{# ── Alerts on this host: what is breaching, before anything else ─────────── #}
{% if alerts %}
<section class="card problem-card" aria-labelledby="host-alerts" style="margin-bottom:1rem;">
<h2 id="host-alerts" class="section-title" style="margin-bottom:0.6rem;">Alerts on this host</h2>
<ul class="problem-list">
{% for row in alerts %}
<li>
{{ status_badge(row.label) }}
<span class="problem-detail">
{{- row.says.lead -}}
{%- if row.url %}<a href="{{ row.url }}">{{ row.says.resource }}</a>{% else %}<strong>{{ row.says.resource }}</strong>{% endif -%}
{{- row.says.tail -}}
{%- if row.label == 'acknowledged' and row.acked_by %} · acknowledged by {{ row.acked_by }}{% endif -%}
</span>
<a href="/alerts/history?rule={{ row.rule.id }}" class="field-hint" style="margin-left:0.4rem;">{{ row.rule.name }}</a>
</li>
{% endfor %}
</ul>
</section>
{% endif %}
{# ── Live vitals strip (host_agent fragment) — full width on top, polled ────── #}
<div id="hv-strip"
hx-get="/plugins/host_agent/vitals/{{ host.id }}"
hx-trigger="load, every 15s"
hx-swap="innerHTML"></div>
{# ── Monitors + Agent, side by side on wide screens, stacked when narrow ────── #}
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(420px,1fr));gap:1rem;align-items:start;margin-bottom:1rem;">
{# ── Monitors ─────────────────────────────────────────────────────────────── #}
<div class="card" style="margin-bottom:0;">
<div style="display:flex;align-items:baseline;justify-content:space-between;margin-bottom:0.5rem;">
<h3 class="section-title" style="margin-bottom:0;">Monitors</h3>
{% if uptime %}
<span style="font-size:0.8rem;color:var(--text-muted);">
uptime{% for w in ("24h", "7d", "30d") %}{{ " ·" if not loop.first }} {{ w }}
<strong class="tone-{{ status_class(uptime[w], 'uptime') }}">{{ ("%.1f%%"|format(uptime[w])) if uptime[w] is not none else '—' }}</strong>{% endfor %}
</span>
{% endif %}
</div>
{% if monitors %}
{% for d in monitors %}
{% set m = d.monitor %}
{% set latest = d.latest %}
<div class="ping-row{% if not m.enabled %} ping-row-paused{% endif %}">
<div class="ping-meta" style="min-width:150px;max-width:230px;">
<div style="display:flex;align-items:baseline;gap:0.4rem;">
{{ status_dot(d.state) }}
<a class="ping-name" href="/monitors/{{ m.id }}">{{ m.name }}</a>
</div>
<span class="ping-addr" title="{{ m.target }}">{{ m.type|monitor_type }} · {{ m.target }}</span>
{% if d.state == 'down' %}
<span class="tone-crit" style="font-size:0.75rem;overflow-wrap:anywhere;">
Down{% if d.down_since %} {{ d.down_since|ago|replace(' ago', '') }}{% endif %}{% if d.down_reason %} · {{ d.down_reason }}{% endif %}
</span>
{% endif %}
</div>
<div class="ping-pills">
{% for _ in range([30 - d.heartbeat|length, 0]|max) %}<span class="pill pill-empty" title="No data"></span>{% endfor %}
{% for h in d.heartbeat %}<span class="pill pill-{{ h.state }}" title="{{ h.title }}"></span>{% endfor %}
</div>
<div class="ping-cur">
{% if latest is none %}<span class="tone-dim">—</span>
{% elif not latest.is_up %}{{ status_badge('down') }}
{% elif latest.response_ms is not none %}
<span class="tone-{{ status_class(latest.response_ms, 'latency') }}">{{ "%.0f"|format(latest.response_ms) }} ms</span>
{% else %}{{ status_badge('up') }}{% endif %}
</div>
{% if session.user_role in ['operator', 'admin'] %}
<div class="row-actions">
{{ icon_button("✎", "Edit " ~ m.name, href="/monitors/" ~ m.id ~ "/edit") }}
<form method="post" action="/monitors/{{ m.id }}/delete">{{ csrf_field() }}
{{ icon_button("×", "Delete " ~ m.name, type="submit", confirm="Delete monitor " ~ m.name ~ "?") }}
</form>
</div>
{% endif %}
</div>
{% endfor %}
{% else %}
<p style="color:var(--text-muted);font-size:0.88rem;margin:0.25rem 0 0.75rem;">
No monitors yet for this host. Add one below.
</p>
{% endif %}
{% if session.user_role in ['operator', 'admin'] %}
<details style="margin-top:0.75rem;">
<summary style="cursor:pointer;font-size:0.85rem;color:var(--text-muted);">+ Add monitor for this host</summary>
<form method="post" action="/monitors/add" style="display:grid;gap:0.6rem;margin-top:0.75rem;">{{ csrf_field() }}
<input type="hidden" name="host_id" value="{{ host.id }}">
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:0.6rem;align-items:end;">
<div>
<label class="field-label">Type</label>
<select name="type" id="mtype" onchange="mtoggle(this.value)" class="input">
{% for t in monitor_types %}
<option value="{{ t.value }}" {% if t.value == 'icmp' %}selected{% endif %}>{{ t.value | upper }}</option>
{% endfor %}
</select>
</div>
<div>
<label class="field-label">Name</label>
<input type="text" name="name" placeholder="optional" autocomplete="off" class="input">
</div>
<div>
<label class="field-label">Target</label>
<input type="text" name="target" value="{{ host.address }}" autocomplete="off" required class="input">
</div>
<div>
<label class="field-label">Interval (s, 0=global)</label>
<input type="number" name="check_interval_seconds" value="0" min="0" class="input">
</div>
{{ type_fields() }}
</div>
<div><button type="submit" class="btn btn-sm">Add Monitor</button></div>
</form>
</details>
{% endif %}
</div>{# Monitors card #}
{# ── Agent (host_agent management panel) ──────────────────────────────────── #}
<div hx-get="/plugins/host_agent/panel/{{ host.id }}" hx-trigger="load" hx-swap="innerHTML">
<div class="card" style="margin-bottom:0;"><span style="color:var(--text-muted);font-size:0.85rem;">Loading agent…</span></div>
</div>
</div>{# Monitors + Agent grid #}
{# ── Docker (docker plugin fragment; renders nothing if the host has none);
above Ansible: containers are state to read, Ansible is something to do ──── #}
{% if "docker" in enabled_plugins %}
<div hx-get="/plugins/docker/host/{{ host.id }}" hx-trigger="load" hx-swap="innerHTML"></div>
{% endif %}
{# ── Ansible (full width) ─────────────────────────────────────────────────── #}
<div class="card">
<h3 class="section-title">Ansible</h3>
{% if linked_target %}
<div style="display:flex;align-items:center;gap:1rem;padding:0.6rem 0.75rem;background:var(--bg-elevated);border-radius:4px;margin-bottom:1rem;flex-wrap:wrap;">
<div style="flex:1;min-width:12rem;">
<strong>{{ linked_target.name }}</strong>
<span style="color:var(--text-muted);font-size:0.85rem;margin-left:0.5rem;">{{ linked_target.address }}</span>
{% if linked_target.groups %}
<div style="margin-top:0.25rem;">
{% for grp in linked_target.groups %}
<span style="font-size:0.78rem;background:var(--bg);border-radius:3px;padding:0.1em 0.4em;margin-right:0.2rem;color:var(--text-muted);">{{ grp.name }}</span>
{% endfor %}
</div>
{% endif %}
</div>
<a href="/ansible/inventory/targets/{{ linked_target.id }}" class="btn btn-ghost btn-sm">Edit target</a>
<form method="post" action="/hosts/{{ host.id }}/ansible-link" style="margin:0;">{{ csrf_field() }}
<input type="hidden" name="action" value="unlink">
<button type="submit" class="btn btn-ghost btn-sm">Unlink</button>
</form>
</div>
{% if ansible_sources %}
<details>
<summary style="cursor:pointer;font-size:0.85rem;color:var(--text-muted);">Run a playbook against this host</summary>
<form method="post" action="/hosts/{{ host.id }}/run-playbook" style="margin-top:0.75rem;">{{ csrf_field() }}
<div class="form-group">
<label>Source</label>
<select name="source_name" required
hx-get="/ansible/playbook-options" hx-trigger="load, change"
hx-target="#hp-playbook" hx-swap="innerHTML" hx-include="this"
hx-on::after-settle="htmx.trigger('#hp-playbook','change')">
{% for s in ansible_sources %}<option value="{{ s }}">{{ s }}</option>{% endfor %}
</select>
</div>
<div class="form-group">
<label>Playbook</label>
<select name="playbook_path" id="hp-playbook" required
hx-get="/ansible/playbook-vars" hx-trigger="change"
hx-target="#hp-vars" hx-swap="innerHTML" hx-include="closest form">
</select>
</div>
<div id="hp-vars"></div>
<details style="margin:0.25rem 0 0.75rem;">
<summary style="cursor:pointer;font-size:0.82rem;color:var(--text-muted);">Advanced</summary>
<div class="form-group" style="margin-top:0.6rem;">
<label>Extra vars <span style="color:var(--text-muted);font-weight:normal;">(one key=value per line)</span></label>
<textarea name="extra_vars" rows="2" style="width:100%;font-family:ui-monospace,monospace;font-size:0.85rem;"></textarea>
</div>
<div class="form-group">
<label>Tags <span style="color:var(--text-muted);font-weight:normal;">(--tags)</span></label>
<input type="text" name="tags" placeholder="deploy,config">
</div>
<div class="form-group">
<label style="display:flex;align-items:center;gap:0.5rem;font-weight:normal;">
<input type="checkbox" name="check"> Dry-run (--check --diff)
</label>
</div>
</details>
<button type="submit" class="btn btn-sm">Run playbook</button>
</form>
</details>
{% endif %}
{% else %}
<p style="color:var(--text-muted);font-size:0.88rem;margin-bottom:1rem;">
No Ansible target linked. A target gives this host an SSH connection so Steward can
run playbooks and provision the agent.
</p>
<div style="display:flex;gap:0.75rem;flex-wrap:wrap;align-items:flex-end;">
{% if linkable_targets %}
<form method="post" action="/hosts/{{ host.id }}/ansible-link" style="display:flex;gap:0.5rem;align-items:center;">{{ csrf_field() }}
<input type="hidden" name="action" value="link">
<select name="target_id">
{% for tgt in linkable_targets %}<option value="{{ tgt.id }}">{{ tgt.name }} ({{ tgt.address }})</option>{% endfor %}
</select>
<button type="submit" class="btn btn-sm">Link</button>
</form>
{% endif %}
<form method="post" action="/hosts/{{ host.id }}/ansible-link" style="margin:0;">{{ csrf_field() }}
<input type="hidden" name="action" value="create">
<button type="submit" class="btn btn-sm btn-ghost">Create target from this host</button>
</form>
</div>
{% endif %}
</div>{# Ansible card #}
{# ── SNMP (renders nothing unless a configured SNMP device maps to this host) ── #}
{% if "snmp" in enabled_plugins %}
<div hx-get="/plugins/snmp/host/{{ host.id }}" hx-trigger="load" hx-swap="innerHTML"></div>
{% endif %}
{{ toggle_script() }}
<script>var _mt=document.getElementById('mtype'); if(_mt) mtoggle(_mt.value);</script>
{% endblock %}