#5740 "A metric that departs from its normal is recorded as an anomaly to review, and is findable after it ends", milestone 513. - Every minute each learned key's last 15 minutes is judged against its hour's normal: above the usual hold x1.25 and by the metric's floor opens an occurrence, which ends when the level comes back. - metric_anomalies holds one row per item to review; a repeat while it still waits joins it as an occurrence (metric_anomaly_spans). - An ended row nobody reviews within anomalies.review_days (14, Settings -> Thresholds) lapses; an ongoing one never does. - Learning skips every hour an occurrence covers unless its anomaly was marked normal: no normal is learned without review. - Alerts -> Anomalies lists those to review first, then the reviewed and lapsed under day dividers; marking is operator/admin and audited. - The Overview widget and get_overview warn of open ones and count those to review; the host page shows its own; the weekly report lists the week's and the lapsed; MCP list_anomalies and review_anomaly. - Reviewed and lapsed rows are pruned at data.retention_days, and kept in backups like alert history. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
218 lines
11 KiB
HTML
218 lines
11 KiB
HTML
{% extends "base.html" %}
|
|
{% from "_macros.html" import crumbs, status_dot, status_badge %}
|
|
{% 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 %}
|
|
|
|
{# ── Metric anomalies: out of what is normal for this host at that hour ───── #}
|
|
{% with back="/hosts/" ~ host.id, base_resource=host.name %}{% include "_anomaly_card.html" %}{% endwith %}
|
|
|
|
{# ── 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 class="host-pair">
|
|
|
|
{# ── Monitors ─────────────────────────────────────────────────────────────── #}
|
|
<div class="card">
|
|
<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 session.user_role in ['operator', 'admin'] %}
|
|
<a href="/monitors/new?host_id={{ host.id }}" class="btn btn-ghost btn-sm" style="margin-left:auto;margin-right:0.75rem;">Add monitor</a>
|
|
{% endif %}
|
|
{% 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>
|
|
</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.
|
|
</p>
|
|
{% 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"><span style="color:var(--text-muted);font-size:0.85rem;">Loading agent…</span></div>
|
|
</div>
|
|
</div>{# Monitors + Agent grid #}
|
|
|
|
{# ── Ansible (full width), before Docker ───────────────────────────────────── #}
|
|
<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 #}
|
|
|
|
{# ── Docker (docker plugin fragment; renders nothing if the host has none).
|
|
After Ansible: the container list can run long, and Ansible beneath it was
|
|
easy to miss (#5214). ─────────────────────────────────────────────────────── #}
|
|
{% if "docker" in enabled_plugins %}
|
|
<div hx-get="/plugins/docker/host/{{ host.id }}" hx-trigger="load" hx-swap="innerHTML"></div>
|
|
{% endif %}
|
|
|
|
{# ── 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 %}
|
|
|
|
{% endblock %}
|