feat(monitors): unify ping/dns/http into one Monitor entity + custom targets
Collapse the three former check types into a single core `Monitor` entity
with one management surface (/monitors), one result table (monitor_results),
and a single scheduled task. Every type can now watch a free-standing custom
destination (optional host_id) — not just a registered Host.
- models: Monitor + MonitorResult replace PingResult/DnsResult; Host loses its
ping/dns facet columns (now Monitor rows linked by host_id).
- checks: monitors/{ping,dns,http}.py pure probes + runner.run_monitor
dispatcher; one monitor_check scheduler with a per-monitor due-filter.
- status: single monitor_status_source replaces the three sources.
- UI: /monitors blueprint (type-aware add/edit/list/widget); host hub shows a
host's linked monitors + "add monitor for this host"; nav + widget registry
+ alert metric catalog rewired. http plugin folded into core and removed.
- migration 0022 merges the http branch, data-migrates host facets +
http_monitors + all three result histories, drops the old tables/columns.
Resolves the per-host ping/dns auto-attach issue (#275): monitors are now
explicit, never auto-added to every host.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Jg27rgypiW2efULXJDtMC
This commit is contained in:
@@ -97,10 +97,12 @@
|
||||
</summary>
|
||||
<div style="margin-top:0.75rem;display:grid;gap:0.5rem;">
|
||||
{% set descs = {
|
||||
"ping": [("up", "1.0 = reachable, 0.0 = unreachable"),
|
||||
("response_time_ms", "round-trip latency in milliseconds")],
|
||||
"dns": [("resolved", "1.0 = resolved, 0.0 = failed"),
|
||||
("ip_changed", "1.0 when resolved IP differs from expected")],
|
||||
"icmp": [("is_up", "1.0 = reachable, 0.0 = unreachable"),
|
||||
("response_ms", "round-trip latency in milliseconds")],
|
||||
"tcp": [("is_up", "1.0 = port open, 0.0 = unreachable"),
|
||||
("response_ms", "TCP connect time in milliseconds")],
|
||||
"dns": [("is_up", "1.0 = resolved (and matches expected IP), 0.0 = failed"),
|
||||
("response_ms", "resolution time in milliseconds")],
|
||||
"traefik": [("request_rate", "requests/sec for this router"),
|
||||
("error_rate", "fraction of 5xx responses (0–1)"),
|
||||
("latency_p50_ms", "median response time ms"),
|
||||
|
||||
@@ -240,8 +240,7 @@ body.dash-editing .widget-drawer { transform:translateY(0); }
|
||||
<a href="/">Dashboard</a>
|
||||
<a href="/status">Status</a>
|
||||
<a href="/hosts/">Hosts</a>
|
||||
<a href="/ping/">Ping</a>
|
||||
<a href="/dns/">DNS</a>
|
||||
<a href="/monitors/">Monitors</a>
|
||||
<a href="/alerts/">Alerts</a>
|
||||
<a href="/ansible/">Ansible</a>
|
||||
{% if session.user_role == 'admin' %}
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}DNS Monitor — Steward{% endblock %}
|
||||
{% block content %}
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.5rem;">
|
||||
<h1 class="page-title" style="margin-bottom:0;">DNS Monitor</h1>
|
||||
<span style="color:var(--text-muted);font-size:0.85rem;">polling every {{ poll_interval }}s</span>
|
||||
</div>
|
||||
<div class="card ping-card">
|
||||
<div id="dns-rows"
|
||||
hx-get="/dns/rows"
|
||||
hx-trigger="every {{ poll_interval }}s"
|
||||
hx-swap="innerHTML">
|
||||
{% include "dns/rows.html" %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,35 +0,0 @@
|
||||
{% if hosts %}
|
||||
{% for host in hosts %}
|
||||
{% set d = latest.get(host.id) %}
|
||||
<div class="ping-row">
|
||||
<div class="ping-meta">
|
||||
{# Link to the host hub for logged-in users; plain text on the public share view (no auth, no token on the href). #}
|
||||
{% if session.user_id %}
|
||||
<a class="ping-name" href="/hosts/{{ host.id }}" title="{{ host.name }}"
|
||||
style="color:inherit;text-decoration:none;">{{ host.name }}</a>
|
||||
{% else %}
|
||||
<span class="ping-name" title="{{ host.name }}">{{ host.name }}</span>
|
||||
{% endif %}
|
||||
<span class="ping-addr">{{ host.address }}</span>
|
||||
</div>
|
||||
<div style="flex:1;">
|
||||
{% if d is none %}
|
||||
<span style="color:var(--text-dim);font-size:0.85rem;">—</span>
|
||||
{% elif d.status.value == 'resolved' %}
|
||||
<span class="dot dot-up" style="margin-right:0.5rem;"></span>
|
||||
<span style="font-size:0.85rem;color:var(--green);">{{ d.resolved_ip or 'resolved' }}</span>
|
||||
{% else %}
|
||||
<span class="dot dot-down" style="margin-right:0.5rem;"></span>
|
||||
<span style="font-size:0.85rem;color:var(--red);">Failed</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="ping-cur">
|
||||
{% if d %}
|
||||
<span style="color:var(--text-muted);font-size:0.75rem;">{{ d.resolved_at.strftime('%H:%M:%S') }} UTC</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p class="empty" style="padding:0.5rem 0;">No DNS checks yet. <a href="/hosts/">Enable DNS on a host →</a></p>
|
||||
{% endif %}
|
||||
@@ -1,5 +1,6 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "_macros.html" import crumbs %}
|
||||
{% 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 %}
|
||||
@@ -22,56 +23,97 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% set inp = "width:100%;padding:0.4rem 0.65rem;background:var(--bg);border:1px solid var(--border-mid);border-radius:4px;color:var(--text);font-size:0.88rem;" %}
|
||||
{% set lbl = "font-size:0.75rem;color:var(--text-muted);display:block;margin-bottom:0.2rem;" %}
|
||||
|
||||
{# ── Monitors ─────────────────────────────────────────────────────────────── #}
|
||||
<div class="card">
|
||||
<div style="display:flex;align-items:baseline;justify-content:space-between;margin-bottom:0.75rem;">
|
||||
<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>
|
||||
<a href="/hosts/{{ host.id }}/edit" class="btn btn-ghost btn-sm" style="font-size:0.78rem;">Configure →</a>
|
||||
{% if uptime %}
|
||||
<span style="font-size:0.8rem;color:var(--text-muted);">
|
||||
uptime 24h <strong>{{ ("%.1f%%"|format(uptime['24h'])) if uptime['24h'] is not none else '—' }}</strong>
|
||||
· 7d <strong>{{ ("%.1f%%"|format(uptime['7d'])) if uptime['7d'] is not none else '—' }}</strong>
|
||||
· 30d <strong>{{ ("%.1f%%"|format(uptime['30d'])) if uptime['30d'] is not none else '—' }}</strong>
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div style="display:flex;gap:2rem;flex-wrap:wrap;">
|
||||
<div>
|
||||
<div style="font-size:0.75rem;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.04em;">Ping</div>
|
||||
{% if not host.ping_enabled %}
|
||||
<div style="color:var(--text-dim);">off</div>
|
||||
{% elif ping is none %}
|
||||
<div style="color:var(--text-dim);">no data yet</div>
|
||||
{% else %}
|
||||
<div style="color:{{ 'var(--green)' if ping.status.value == 'up' else 'var(--red)' }};font-weight:600;">
|
||||
{{ ping.status.value }}
|
||||
</div>
|
||||
{% if ping.response_time_ms is not none %}
|
||||
<div style="font-size:0.8rem;color:var(--text-muted);">{{ '%.0f'|format(ping.response_time_ms) }} ms</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if monitors %}
|
||||
{% for d in monitors %}
|
||||
{% set m = d.monitor %}
|
||||
{% set latest = d.latest %}
|
||||
<div class="ping-row" {% if not m.enabled %}style="opacity:0.5;"{% endif %}>
|
||||
<div class="ping-meta" style="min-width:150px;max-width:230px;">
|
||||
<span class="badge" style="font-size:0.62rem;">{{ m.type | upper }}</span>
|
||||
<span class="ping-name" title="{{ m.name }}">{{ m.name }}</span>
|
||||
<span class="ping-addr" title="{{ m.target }}">{{ m.target }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size:0.75rem;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.04em;">DNS</div>
|
||||
{% if not host.dns_enabled %}
|
||||
<div style="color:var(--text-dim);">off</div>
|
||||
{% elif dns is none %}
|
||||
<div style="color:var(--text-dim);">no data yet</div>
|
||||
{% else %}
|
||||
<div style="color:{{ 'var(--green)' if dns.status.value == 'resolved' else 'var(--red)' }};font-weight:600;">
|
||||
{{ dns.status.value }}
|
||||
</div>
|
||||
{% if dns.resolved_ip %}
|
||||
<div style="font-size:0.8rem;color:var(--text-muted);font-family:ui-monospace,monospace;">{{ dns.resolved_ip }}</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<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" title="{{ h.title }}"
|
||||
style="background:{{ '#1a6632' if h.state == 'up' else '#6a1515' }};"></span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size:0.75rem;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.04em;">Uptime</div>
|
||||
{% if uptime %}
|
||||
<div style="font-size:0.85rem;">
|
||||
24h <strong>{{ uptime['24h'] if uptime['24h'] is not none else '—' }}{{ '%' if uptime['24h'] is not none }}</strong>
|
||||
· 7d <strong>{{ uptime['7d'] if uptime['7d'] is not none else '—' }}{{ '%' if uptime['7d'] is not none }}</strong>
|
||||
· 30d <strong>{{ uptime['30d'] if uptime['30d'] is not none else '—' }}{{ '%' if uptime['30d'] is not none }}</strong>
|
||||
</div>
|
||||
{% else %}
|
||||
<div style="color:var(--text-dim);">—</div>
|
||||
{% endif %}
|
||||
<div class="ping-cur">
|
||||
{% if latest is none %}<span class="ping-cur-nd">—</span>
|
||||
{% elif not latest.is_up %}<span class="ping-cur-down">DOWN</span>
|
||||
{% elif latest.response_ms is not none %}
|
||||
<span style="{{ threshold_style(latest.response_ms, 'latency') or 'color:var(--green);' }}">{{ "%.0f"|format(latest.response_ms) }} ms</span>
|
||||
{% else %}<span class="ping-cur-good">UP</span>{% endif %}
|
||||
</div>
|
||||
{% if session.user_role in ['operator', 'admin'] %}
|
||||
<div style="display:flex;gap:0.35rem;flex-shrink:0;">
|
||||
<a href="/monitors/{{ m.id }}/edit" class="btn btn-sm btn-ghost" style="padding:0.2rem 0.5rem;font-size:0.72rem;">Edit</a>
|
||||
<form method="post" action="/monitors/{{ m.id }}/delete" style="display:inline;"
|
||||
onsubmit="return confirm('Delete monitor {{ m.name }}?');">
|
||||
<button type="submit" class="btn btn-sm" style="padding:0.2rem 0.5rem;font-size:0.72rem;color:var(--red);">✕</button>
|
||||
</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;">
|
||||
<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 style="{{ lbl }}">Type</label>
|
||||
<select name="type" id="mtype" onchange="mtoggle(this.value)" style="{{ inp }}">
|
||||
{% for t in monitor_types %}
|
||||
<option value="{{ t.value }}" {% if t.value == 'icmp' %}selected{% endif %}>{{ t.value | upper }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style="{{ lbl }}">Name</label>
|
||||
<input type="text" name="name" placeholder="optional" autocomplete="off" style="{{ inp }}">
|
||||
</div>
|
||||
<div>
|
||||
<label style="{{ lbl }}">Target</label>
|
||||
<input type="text" name="target" value="{{ host.address }}" autocomplete="off" required style="{{ inp }}">
|
||||
</div>
|
||||
<div>
|
||||
<label style="{{ lbl }}">Interval (s, 0=global)</label>
|
||||
<input type="number" name="check_interval_seconds" value="0" min="0" style="{{ inp }}">
|
||||
</div>
|
||||
{{ type_fields() }}
|
||||
</div>
|
||||
<div><button type="submit" class="btn btn-sm">Add Monitor</button></div>
|
||||
</form>
|
||||
</details>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# ── Agent (host_agent plugin fragment, embedded across the plugin boundary) ── #}
|
||||
@@ -168,4 +210,6 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{{ toggle_script() }}
|
||||
<script>var _mt=document.getElementById('mtype'); if(_mt) mtoggle(_mt.value);</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -15,47 +15,16 @@
|
||||
<label>Address (hostname or IP)</label>
|
||||
<input type="text" name="address" value="{{ host.address if host else '' }}" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Probe Type</label>
|
||||
<select name="probe_type">
|
||||
{% for pt in probe_types %}
|
||||
<option value="{{ pt.value }}" {% if host and host.probe_type == pt %}selected{% endif %}>
|
||||
{{ pt.value | upper }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>TCP Port (used for TCP probe; ignored for ICMP)</label>
|
||||
<input type="number" name="probe_port" value="{{ host.probe_port if host else 80 }}" min="1" max="65535">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label style="display:flex;align-items:center;gap:0.5rem;cursor:pointer;">
|
||||
<input type="checkbox" name="ping_enabled" {% if not host or host.ping_enabled %}checked{% endif %}>
|
||||
Enable ping monitor
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label style="display:flex;align-items:center;gap:0.5rem;cursor:pointer;">
|
||||
<input type="checkbox" name="dns_enabled" {% if host and host.dns_enabled %}checked{% endif %}>
|
||||
Enable DNS monitor
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Expected IP (optional — leave blank to accept any)</label>
|
||||
<input type="text" name="dns_expected_ip" value="{{ host.dns_expected_ip if host and host.dns_expected_ip else '' }}">
|
||||
</div>
|
||||
<div style="display:flex;gap:1rem;margin-top:1.5rem;">
|
||||
<button type="submit" class="btn">{% if host %}Save{% else %}Add Host{% endif %}</button>
|
||||
<a href="/hosts/" class="btn btn-ghost">Cancel</a>
|
||||
<a href="{% if host %}/hosts/{{ host.id }}{% else %}/hosts/{% endif %}" class="btn btn-ghost">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% if host %}
|
||||
<p style="color:var(--text-muted);font-size:0.82rem;margin-top:1rem;text-align:center;">
|
||||
Agent, Ansible target, and playbook runs live on the
|
||||
<a href="/hosts/{{ host.id }}">host page</a>.
|
||||
{% if host %}Monitors, agent, Ansible target, and playbook runs live on the
|
||||
<a href="/hosts/{{ host.id }}">host page</a>.{% else %}
|
||||
After adding, attach ping / DNS / HTTP monitors from the host page.{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -18,10 +18,9 @@
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Address</th>
|
||||
<th>Probe</th>
|
||||
<th style="text-align:center;">Ping</th>
|
||||
<th style="text-align:center;" title="Worst current status across this host's monitors">Status</th>
|
||||
<th style="text-align:center;">Latency</th>
|
||||
<th style="text-align:center;">DNS</th>
|
||||
<th style="text-align:center;" title="Number of monitors attached">Monitors</th>
|
||||
<th style="text-align:center;" title="Uptime last 24 hours">24h</th>
|
||||
<th style="text-align:center;" title="Uptime last 7 days">7d</th>
|
||||
<th style="text-align:center;" title="Uptime last 30 days">30d</th>
|
||||
@@ -31,65 +30,39 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for host in hosts %}
|
||||
{% set ping = latest_pings.get(host.id) %}
|
||||
{% set dns = latest_dns.get(host.id) %}
|
||||
{% set hs = host_status.get(host.id) %}
|
||||
{% set ut = uptime.get(host.id, {}) %}
|
||||
<tr>
|
||||
<td><a href="/hosts/{{ host.id }}" style="font-weight:600;">{{ host.name }}</a></td>
|
||||
<td style="color:var(--text-muted);">{{ host.address }}</td>
|
||||
<td style="color:var(--text-muted);font-size:0.85rem;">
|
||||
{{ host.probe_type.value | upper }}{% if host.probe_type.value == 'tcp' %}:{{ host.probe_port }}{% endif %}
|
||||
</td>
|
||||
<td style="text-align:center;">
|
||||
{% if not host.ping_enabled %}
|
||||
<span style="color:var(--text-dim);font-size:0.8rem;">off</span>
|
||||
{% elif ping is none %}
|
||||
<span style="color:var(--text-muted);font-size:0.8rem;">—</span>
|
||||
{% elif ping.status.value == 'up' %}
|
||||
{% if not hs %}
|
||||
<span style="color:var(--text-dim);font-size:0.8rem;" title="No monitors">—</span>
|
||||
{% elif hs.state == 'up' %}
|
||||
<span class="dot dot-up" title="Up"></span>
|
||||
{% else %}
|
||||
{% elif hs.state == 'down' %}
|
||||
<span class="dot dot-down" title="Down"></span>
|
||||
{% else %}
|
||||
<span class="dot dot-dim" title="Pending"></span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td style="text-align:center;font-size:0.9rem;">
|
||||
{% if ping and ping.response_time_ms is not none %}
|
||||
{% if ping.response_time_ms < 10 %}
|
||||
<span style="color:var(--green);">{{ ping.response_time_ms | round(1) }} ms</span>
|
||||
{% elif ping.response_time_ms < 100 %}
|
||||
<span style="color:var(--yellow);">{{ ping.response_time_ms | round(1) }} ms</span>
|
||||
{% else %}
|
||||
<span style="color:var(--orange);">{{ ping.response_time_ms | round(1) }} ms</span>
|
||||
{% endif %}
|
||||
{% if hs and hs.latency_ms is not none %}
|
||||
<span style="{{ threshold_style(hs.latency_ms, 'latency') or 'color:var(--green);' }}">{{ hs.latency_ms | round(1) }} ms</span>
|
||||
{% else %}
|
||||
<span style="color:var(--text-dim);">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td style="text-align:center;">
|
||||
{% if not host.dns_enabled %}
|
||||
<span style="color:var(--text-dim);font-size:0.8rem;">off</span>
|
||||
{% elif dns is none %}
|
||||
<span style="color:var(--text-muted);font-size:0.8rem;">—</span>
|
||||
{% elif dns.status.value == 'resolved' %}
|
||||
<span class="dot dot-up" title="{{ dns.resolved_ip }}"></span>
|
||||
{% else %}
|
||||
<span class="dot dot-down" title="Failed"></span>
|
||||
{% endif %}
|
||||
<td style="text-align:center;font-size:0.85rem;color:var(--text-muted);">
|
||||
{{ hs.count if hs else 0 }}
|
||||
</td>
|
||||
{% for window in ["24h", "7d", "30d"] %}
|
||||
{% set pct = ut.get(window) %}
|
||||
<td style="text-align:center;font-size:0.82rem;font-family:ui-monospace,monospace;">
|
||||
{% if not host.ping_enabled %}
|
||||
{% if pct is none %}
|
||||
<span style="color:var(--text-dim);">—</span>
|
||||
{% elif pct is none %}
|
||||
<span style="color:var(--text-muted);">—</span>
|
||||
{% elif pct >= 99.9 %}
|
||||
<span style="color:var(--green);">{{ "%.1f"|format(pct) }}%</span>
|
||||
{% elif pct >= 99 %}
|
||||
<span style="color:var(--yellow);">{{ "%.1f"|format(pct) }}%</span>
|
||||
{% elif pct >= 95 %}
|
||||
<span style="color:var(--orange);">{{ "%.1f"|format(pct) }}%</span>
|
||||
{% else %}
|
||||
<span style="color:var(--red);">{{ "%.1f"|format(pct) }}%</span>
|
||||
<span style="{{ threshold_style(pct, 'uptime') or 'color:var(--green);' }}">{{ "%.1f"|format(pct) }}%</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
{% endfor %}
|
||||
|
||||
@@ -4,18 +4,17 @@
|
||||
{% if hosts %}
|
||||
<div class="host-blocks">
|
||||
{% for host in hosts %}
|
||||
{% set ping = latest_pings.get(host.id) %}
|
||||
{% set dns = latest_dns.get(host.id) %}
|
||||
{% set hs = host_status.get(host.id) %}
|
||||
{% set ut = uptime.get(host.id, {}) %}
|
||||
{% set a = agent.get(host.name, {}) %}
|
||||
{% set down = host.ping_enabled and ping and ping.status.value != 'up' %}
|
||||
{% set down = hs and hs.state == 'down' %}
|
||||
{% set spark = cpu_sparks.get(host.name) %}
|
||||
<div style="padding:0.5rem 0;border-bottom:1px solid var(--border);">
|
||||
{# Line 1 — status dot + full host name (own line) + cpu trend #}
|
||||
<div style="display:flex;align-items:center;gap:0.5rem;">
|
||||
<span class="dot {% if down %}dot-down{% elif host.ping_enabled and ping %}dot-up{% else %}dot-dim{% endif %}"
|
||||
<span class="dot {% if down %}dot-down{% elif hs and hs.state == 'up' %}dot-up{% else %}dot-dim{% endif %}"
|
||||
style="flex-shrink:0;"
|
||||
title="{% if not host.ping_enabled %}ping off{% elif ping %}{{ ping.status.value }}{% else %}no data{% endif %}"></span>
|
||||
title="{% if not hs %}no monitors{% else %}{{ hs.state }}{% endif %}"></span>
|
||||
{% if session.user_id %}
|
||||
<a href="/hosts/{{ host.id }}" style="font-weight:600;font-size:0.85rem;overflow-wrap:anywhere;">{{ host.name }}</a>
|
||||
{% else %}
|
||||
@@ -25,8 +24,8 @@
|
||||
{# Line 2 — monitors + agent metrics; wraps instead of truncating #}
|
||||
<div style="display:flex;flex-wrap:wrap;gap:0.25rem 0.9rem;margin-top:0.25rem;padding-left:1.1rem;
|
||||
font-variant-numeric:tabular-nums;color:var(--text-muted);font-size:0.75rem;">
|
||||
{% if host.ping_enabled and ping and ping.response_time_ms is not none %}
|
||||
<span title="Ping latency" style="{{ threshold_style(ping.response_time_ms, 'latency') }}"><span style="color:var(--text-dim);">ping</span> {{ "%.0f"|format(ping.response_time_ms) }}ms</span>
|
||||
{% if hs and hs.latency_ms is not none %}
|
||||
<span title="Latency" style="{{ threshold_style(hs.latency_ms, 'latency') }}"><span style="color:var(--text-dim);">ping</span> {{ "%.0f"|format(hs.latency_ms) }}ms</span>
|
||||
{% endif %}
|
||||
{% if ut.get('24h') is not none %}
|
||||
<span title="Uptime 24h" style="{{ threshold_style(ut['24h'], 'uptime') }}"><span style="color:var(--text-dim);">24h</span> {{ "%.1f"|format(ut['24h']) }}%</span>
|
||||
|
||||
@@ -7,13 +7,10 @@
|
||||
</div>
|
||||
|
||||
{# ── Summary pills ──────────────────────────────────────────────────────────── #}
|
||||
{% set ping_hosts = hosts | selectattr("ping_enabled") | list %}
|
||||
{% set total = ping_hosts | length %}
|
||||
{% set total = hosts | length %}
|
||||
{% if total %}
|
||||
{% set full_30d = ping_hosts | selectattr("id", "in", uptime.keys())
|
||||
| selectattr("id", "defined") | list %}
|
||||
{% set healthy = namespace(count=0) %}
|
||||
{% for h in ping_hosts %}
|
||||
{% for h in hosts %}
|
||||
{% set pct = uptime.get(h.id, {}).get("30d") %}
|
||||
{% if pct is not none and pct >= 99 %}{% set healthy.count = healthy.count + 1 %}{% endif %}
|
||||
{% endfor %}
|
||||
@@ -50,15 +47,13 @@
|
||||
{% for host in hosts %}
|
||||
{% set ut = uptime.get(host.id, {}) %}
|
||||
<tr>
|
||||
<td style="font-weight:500;">{{ host.name }}</td>
|
||||
<td style="font-weight:500;"><a href="/hosts/{{ host.id }}" style="color:inherit;">{{ host.name }}</a></td>
|
||||
<td style="color:var(--text-muted);font-size:0.85rem;">{{ host.address }}</td>
|
||||
|
||||
{% for window in ["24h", "7d", "30d"] %}
|
||||
{% set pct = ut.get(window) %}
|
||||
<td style="text-align:center;font-size:0.88rem;font-family:ui-monospace,monospace;">
|
||||
{% if not host.ping_enabled %}
|
||||
<span style="color:var(--text-dim);">—</span>
|
||||
{% elif pct is none %}
|
||||
{% if pct is none %}
|
||||
<span style="color:var(--text-muted);" title="No data yet">—</span>
|
||||
{% elif pct >= 99.9 %}
|
||||
<span style="color:var(--green);">{{ "%.2f"|format(pct) }}%</span>
|
||||
@@ -75,9 +70,7 @@
|
||||
{# 30-day visual bar #}
|
||||
{% set pct30 = ut.get("30d") %}
|
||||
<td>
|
||||
{% if not host.ping_enabled %}
|
||||
<span style="color:var(--text-dim);font-size:0.8rem;">ping off</span>
|
||||
{% elif pct30 is none %}
|
||||
{% if pct30 is none %}
|
||||
<span style="color:var(--text-muted);font-size:0.8rem;">no data</span>
|
||||
{% else %}
|
||||
<div style="display:flex;align-items:center;gap:0.5rem;">
|
||||
@@ -99,6 +92,6 @@
|
||||
{% endif %}
|
||||
|
||||
<p style="color:var(--text-dim);font-size:0.78rem;margin-top:1rem;">
|
||||
Uptime is computed from ping probe results only. Hosts with ping disabled show —.
|
||||
Uptime aggregates every monitor attached to a host. Hosts with no monitors show —.
|
||||
</p>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{# hosts/uptime_widget.html — dashboard widget: SLA uptime summary #}
|
||||
{% if not hosts %}
|
||||
<div style="color:var(--text-muted);font-size:0.85rem;padding:0.5rem 0;">
|
||||
No ping-enabled hosts configured.
|
||||
No hosts configured.
|
||||
</div>
|
||||
{% else %}
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
{# Shared type-specific monitor form fields. Every field is rendered; a tiny
|
||||
script (mtoggle) shows only the ones relevant to the selected type. #}
|
||||
{% macro type_fields(config={}) %}
|
||||
{% set inp = "width:100%;padding:0.4rem 0.65rem;background:var(--bg);border:1px solid var(--border-mid);border-radius:4px;color:var(--text);font-size:0.88rem;" %}
|
||||
{% set lbl = "font-size:0.75rem;color:var(--text-muted);display:block;margin-bottom:0.2rem;" %}
|
||||
|
||||
{# tcp #}
|
||||
<div class="mfield" data-types="tcp">
|
||||
<label style="{{ lbl }}">Port</label>
|
||||
<input type="number" name="port" value="{{ config.get('port', 80) }}" min="1" max="65535" style="{{ inp }}">
|
||||
</div>
|
||||
|
||||
{# dns #}
|
||||
<div class="mfield" data-types="dns">
|
||||
<label style="{{ lbl }}">Expected IP <span style="color:var(--text-dim);">(optional)</span></label>
|
||||
<input type="text" name="expected_ip" value="{{ config.get('expected_ip') or '' }}"
|
||||
placeholder="e.g. 192.0.2.10" autocomplete="off" style="{{ inp }}">
|
||||
</div>
|
||||
|
||||
{# http #}
|
||||
<div class="mfield" data-types="http">
|
||||
<label style="{{ lbl }}">Method</label>
|
||||
<select name="method" style="{{ inp }}">
|
||||
{% for opt in ["GET", "HEAD", "POST"] %}
|
||||
<option value="{{ opt }}" {% if config.get('method', 'GET') == opt %}selected{% endif %}>{{ opt }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="mfield" data-types="http">
|
||||
<label style="{{ lbl }}">Expected status</label>
|
||||
<input type="number" name="expected_status" value="{{ config.get('expected_status', 200) }}"
|
||||
min="100" max="599" style="{{ inp }}">
|
||||
</div>
|
||||
<div class="mfield" data-types="http">
|
||||
<label style="{{ lbl }}">Timeout (s)</label>
|
||||
<input type="number" name="timeout_seconds" value="{{ config.get('timeout_seconds', 10) }}"
|
||||
min="1" max="60" style="{{ inp }}">
|
||||
</div>
|
||||
<div class="mfield" data-types="http">
|
||||
<label style="{{ lbl }}">Content match <span style="color:var(--text-dim);">(substring)</span></label>
|
||||
<input type="text" name="content_match" value="{{ config.get('content_match', '') }}"
|
||||
placeholder="optional" autocomplete="off" style="{{ inp }}">
|
||||
</div>
|
||||
<div class="mfield" data-types="http" style="display:flex;flex-direction:column;gap:0.3rem;justify-content:flex-end;">
|
||||
<label style="display:flex;align-items:center;gap:0.4rem;font-size:0.82rem;cursor:pointer;">
|
||||
<input type="checkbox" name="follow_redirects" {% if config.get('follow_redirects', True) %}checked{% endif %}> Follow redirects
|
||||
</label>
|
||||
<label style="display:flex;align-items:center;gap:0.4rem;font-size:0.82rem;cursor:pointer;">
|
||||
<input type="checkbox" name="verify_ssl" {% if config.get('verify_ssl', True) %}checked{% endif %}> Verify SSL
|
||||
</label>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{# Show only the fields whose data-types matches the active monitor type. #}
|
||||
{% macro toggle_script() %}
|
||||
<script>
|
||||
function mtoggle(t) {
|
||||
document.querySelectorAll('.mfield').forEach(function (el) {
|
||||
var types = (el.getAttribute('data-types') || '').split(',');
|
||||
el.style.display = types.indexOf(t) !== -1 ? '' : 'none';
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endmacro %}
|
||||
@@ -0,0 +1,48 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "monitors/_fields.html" import type_fields, toggle_script %}
|
||||
{% block title %}Edit Monitor — Steward{% endblock %}
|
||||
{% block content %}
|
||||
<div style="margin-bottom:1.25rem;">
|
||||
<a href="/monitors/" style="color:var(--text-muted);font-size:0.85rem;">← Monitors</a>
|
||||
<h1 class="page-title" style="margin:0.4rem 0 0;">Edit Monitor</h1>
|
||||
</div>
|
||||
|
||||
{% set inp = "width:100%;padding:0.4rem 0.65rem;background:var(--bg);border:1px solid var(--border-mid);border-radius:4px;color:var(--text);font-size:0.88rem;" %}
|
||||
{% set lbl = "font-size:0.75rem;color:var(--text-muted);display:block;margin-bottom:0.2rem;" %}
|
||||
|
||||
<div class="card" style="max-width:760px;">
|
||||
<form method="post" action="/monitors/{{ monitor.id }}/edit" style="display:grid;gap:0.6rem;">
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:0.6rem;align-items:end;">
|
||||
<div>
|
||||
<label style="{{ lbl }}">Type</label>
|
||||
<input type="text" value="{{ monitor.type | upper }}" disabled style="{{ inp }}opacity:0.7;">
|
||||
</div>
|
||||
<div>
|
||||
<label style="{{ lbl }}">Name</label>
|
||||
<input type="text" name="name" value="{{ monitor.name }}" autocomplete="off" style="{{ inp }}">
|
||||
</div>
|
||||
<div>
|
||||
<label style="{{ lbl }}">Target <span style="color:var(--red);">*</span></label>
|
||||
<input type="text" name="target" value="{{ monitor.target }}" autocomplete="off" required style="{{ inp }}">
|
||||
</div>
|
||||
<div>
|
||||
<label style="{{ lbl }}">Interval (s, 0=global)</label>
|
||||
<input type="number" name="check_interval_seconds" value="{{ monitor.check_interval_seconds }}" min="0" style="{{ inp }}">
|
||||
</div>
|
||||
{{ type_fields(config) }}
|
||||
</div>
|
||||
{% if monitor.host_id %}
|
||||
<p style="font-size:0.78rem;color:var(--text-dim);margin:0;">
|
||||
Linked to <a href="/hosts/{{ monitor.host_id }}" style="color:var(--accent);">its host</a>.
|
||||
</p>
|
||||
{% endif %}
|
||||
<div style="display:flex;gap:0.5rem;">
|
||||
<button type="submit" class="btn btn-sm">Save</button>
|
||||
<a href="/monitors/" class="btn btn-sm" style="background:transparent;border:1px solid var(--border-mid);">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{{ toggle_script() }}
|
||||
<script>mtoggle({{ monitor.type | tojson }});</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,60 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "monitors/_fields.html" import type_fields, toggle_script %}
|
||||
{% block title %}Monitors — Steward{% endblock %}
|
||||
{% block content %}
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.5rem;gap:1rem;flex-wrap:wrap;">
|
||||
<div style="display:flex;align-items:baseline;gap:1rem;">
|
||||
<h1 class="page-title" style="margin-bottom:0;">Monitors</h1>
|
||||
<span style="color:var(--text-muted);font-size:0.85rem;">polling every {{ poll_interval }}s</span>
|
||||
</div>
|
||||
{% include "_time_range.html" %}
|
||||
</div>
|
||||
|
||||
{% set inp = "width:100%;padding:0.4rem 0.65rem;background:var(--bg);border:1px solid var(--border-mid);border-radius:4px;color:var(--text);font-size:0.88rem;" %}
|
||||
{% set lbl = "font-size:0.75rem;color:var(--text-muted);display:block;margin-bottom:0.2rem;" %}
|
||||
|
||||
{# ── Add monitor ──────────────────────────────────────────────────────────── #}
|
||||
<div class="card" style="margin-bottom:1.25rem;">
|
||||
<div class="section-title" style="margin-bottom:0.75rem;">Add Monitor</div>
|
||||
<form method="post" action="/monitors/add" style="display:grid;gap:0.6rem;">
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:0.6rem;align-items:end;">
|
||||
<div>
|
||||
<label style="{{ lbl }}">Type</label>
|
||||
<select name="type" id="mtype" onchange="mtoggle(this.value)" style="{{ inp }}">
|
||||
{% for t in types %}
|
||||
<option value="{{ t.value }}" {% if t.value == 'icmp' %}selected{% endif %}>{{ t.value | upper }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style="{{ lbl }}">Name</label>
|
||||
<input type="text" name="name" placeholder="optional" autocomplete="off" style="{{ inp }}">
|
||||
</div>
|
||||
<div>
|
||||
<label style="{{ lbl }}">Target <span style="color:var(--red);">*</span></label>
|
||||
<input type="text" name="target" placeholder="host, IP, or URL" autocomplete="off" required style="{{ inp }}">
|
||||
</div>
|
||||
<div>
|
||||
<label style="{{ lbl }}">Interval (s, 0=global)</label>
|
||||
<input type="number" name="check_interval_seconds" value="0" min="0" style="{{ inp }}">
|
||||
</div>
|
||||
{{ type_fields() }}
|
||||
</div>
|
||||
<div><button type="submit" class="btn btn-sm">Add Monitor</button></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{# ── Live monitor rows ────────────────────────────────────────────────────── #}
|
||||
<div class="card ping-card">
|
||||
<div id="monitor-rows"
|
||||
hx-get="/monitors/rows"
|
||||
hx-trigger="load, every {{ poll_interval }}s, rangeChange from:body"
|
||||
hx-include="#time-range"
|
||||
hx-swap="innerHTML">
|
||||
<div style="color:var(--text-muted);font-size:0.9rem;padding:2rem;">Loading…</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{ toggle_script() }}
|
||||
<script>mtoggle(document.getElementById('mtype').value);</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,74 @@
|
||||
{# HTMX fragment — monitor rows (list page). #}
|
||||
{% if monitor_data %}
|
||||
{% for d in monitor_data %}
|
||||
{% set m = d.monitor %}
|
||||
{% set latest = d.latest %}
|
||||
<div class="ping-row" {% if not m.enabled %}style="opacity:0.5;"{% endif %}>
|
||||
<div class="ping-meta" style="min-width:160px;max-width:240px;">
|
||||
<span class="badge" style="font-size:0.62rem;vertical-align:middle;">{{ m.type | upper }}</span>
|
||||
{% if m.host_id %}
|
||||
<a class="ping-name" href="/hosts/{{ m.host_id }}" title="{{ m.name }}"
|
||||
style="color:inherit;text-decoration:none;">{{ m.name }}</a>
|
||||
{% else %}
|
||||
<span class="ping-name" title="{{ m.name }}">{{ m.name }}</span>
|
||||
{% endif %}
|
||||
<span class="ping-addr" title="{{ m.target }}">{{ m.target }}</span>
|
||||
</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" title="{{ h.title }}"
|
||||
style="background:{{ '#1a6632' if h.state == 'up' else '#6a1515' }};"></span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="ping-cur">
|
||||
{% if latest is none %}
|
||||
<span class="ping-cur-nd">—</span>
|
||||
{% elif not latest.is_up %}
|
||||
<span class="ping-cur-down">DOWN</span>
|
||||
{% elif latest.response_ms is not none %}
|
||||
<span style="{{ threshold_style(latest.response_ms, 'latency') or 'color:var(--green);' }}">
|
||||
{{ "%.0f"|format(latest.response_ms) }} ms</span>
|
||||
{% elif m.type == 'dns' and latest.resolved_ip %}
|
||||
<span class="ping-cur-good" style="font-size:0.72rem;">{{ latest.resolved_ip }}</span>
|
||||
{% else %}
|
||||
<span class="ping-cur-good">UP</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div style="min-width:4.5rem;text-align:right;font-size:0.78rem;font-variant-numeric:tabular-nums;{{ threshold_style(d.uptime_pct, 'uptime') if d.uptime_pct is not none else 'color:var(--text-dim);' }}">
|
||||
{% if d.uptime_pct is not none %}{{ "%.1f"|format(d.uptime_pct) }}%{% else %}—{% endif %}
|
||||
<span style="color:var(--text-dim);font-size:0.7rem;margin-left:0.15rem;">{{ range_key }}</span>
|
||||
</div>
|
||||
|
||||
{% if d.tls_days is not none %}
|
||||
<div title="TLS certificate expiry" style="min-width:4rem;text-align:right;font-size:0.72rem;{{ 'color:var(--red);' if d.tls_days < 14 else ('color:var(--yellow);' if d.tls_days < 30 else 'color:var(--text-dim);') }}">
|
||||
cert {{ d.tls_days|round|int }}d
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if session.user_role in ['operator', 'admin'] %}
|
||||
<div style="display:flex;gap:0.35rem;flex-shrink:0;">
|
||||
<a href="/monitors/{{ m.id }}/edit" class="btn btn-sm" style="padding:0.2rem 0.5rem;font-size:0.72rem;">Edit</a>
|
||||
<form method="post" action="/monitors/{{ m.id }}/toggle" style="display:inline;">
|
||||
<button type="submit" class="btn btn-sm" style="padding:0.2rem 0.5rem;font-size:0.72rem;">
|
||||
{{ "Pause" if m.enabled else "Resume" }}</button>
|
||||
</form>
|
||||
<form method="post" action="/monitors/{{ m.id }}/delete" style="display:inline;"
|
||||
onsubmit="return confirm('Delete monitor {{ m.name }}?');">
|
||||
<button type="submit" class="btn btn-sm" style="padding:0.2rem 0.5rem;font-size:0.72rem;color:var(--red);">✕</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p style="color:var(--text-muted);font-size:0.9rem;padding:1rem 0;">
|
||||
No monitors yet. Add one above, or attach checks to a host from the
|
||||
<a href="/hosts/" style="color:var(--accent);">Hosts</a> page.
|
||||
</p>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,44 @@
|
||||
{# monitors/widget.html — dashboard widget: unified monitor summary #}
|
||||
{% if not monitor_data and up == 0 and down == 0 and pending == 0 %}
|
||||
<div style="color:var(--text-muted);font-size:0.85rem;padding:0.5rem 0;">
|
||||
No monitors configured. <a href="/monitors/">Add monitors →</a>
|
||||
</div>
|
||||
{% else %}
|
||||
|
||||
<div style="display:flex;gap:1rem;margin-bottom:0.65rem;flex-wrap:wrap;">
|
||||
{% if up %}
|
||||
<div><span style="font-size:1.4rem;font-weight:700;line-height:1;color:var(--green);">{{ up }}</span>
|
||||
<span style="font-size:0.78rem;color:var(--text-muted);margin-left:0.25rem;">up</span></div>
|
||||
{% endif %}
|
||||
{% if down %}
|
||||
<div><span style="font-size:1.4rem;font-weight:700;line-height:1;color:var(--red);">{{ down }}</span>
|
||||
<span style="font-size:0.78rem;color:var(--text-muted);margin-left:0.25rem;">down</span></div>
|
||||
{% endif %}
|
||||
{% if pending %}
|
||||
<div><span style="font-size:1.4rem;font-weight:700;line-height:1;color:var(--text-dim);">{{ pending }}</span>
|
||||
<span style="font-size:0.78rem;color:var(--text-muted);margin-left:0.25rem;">pending</span></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div style="display:grid;gap:3px;">
|
||||
{% for d in monitor_data %}
|
||||
{% set latest = d.latest %}
|
||||
<div style="display:flex;align-items:center;gap:0.5rem;font-size:0.82rem;padding:0.15rem 0;">
|
||||
{% if not latest %}<span class="dot dot-dim"></span>
|
||||
{% elif latest.is_up %}<span class="dot dot-up"></span>
|
||||
{% else %}<span class="dot dot-down"></span>{% endif %}
|
||||
{% if d.monitor.host_id %}
|
||||
<a href="/hosts/{{ d.monitor.host_id }}" style="flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:inherit;text-decoration:none;">{{ d.monitor.name }}</a>
|
||||
{% else %}
|
||||
<span style="flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">{{ d.monitor.name }}</span>
|
||||
{% endif %}
|
||||
{% if latest and latest.response_ms is not none %}
|
||||
<span style="font-size:0.72rem;font-family:ui-monospace,monospace;color:var(--text-muted);flex-shrink:0;">{{ "%.0f"|format(latest.response_ms) }}ms</span>
|
||||
{% elif latest and not latest.is_up %}
|
||||
<span style="font-size:0.72rem;color:var(--red);flex-shrink:0;">{{ latest.status_code or "down" }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% endif %}
|
||||
@@ -1,47 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Ping Monitor — Steward{% endblock %}
|
||||
{% block content %}
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:0.75rem;margin-bottom:1.25rem;">
|
||||
<div style="display:flex;align-items:baseline;gap:1rem;">
|
||||
<h1 class="page-title" style="margin-bottom:0;">Ping Monitor</h1>
|
||||
<span style="color:var(--text-muted);font-size:0.85rem;">polling every {{ poll_interval }}s</span>
|
||||
</div>
|
||||
{% include "_time_range.html" %}
|
||||
</div>
|
||||
|
||||
{# ── Threshold settings ─────────────────────────────────────────────────── #}
|
||||
<div class="card" style="margin-bottom:1.25rem;">
|
||||
<h2 class="section-title" style="margin-bottom:1rem;">Latency Thresholds</h2>
|
||||
<form method="post" action="/ping/settings"
|
||||
style="display:flex;flex-wrap:wrap;gap:1rem;align-items:flex-end;">
|
||||
<div class="form-group" style="margin:0;">
|
||||
<label style="font-size:0.8rem;">
|
||||
<span style="display:inline-block;width:10px;height:10px;border-radius:2px;background:#1a6632;vertical-align:middle;margin-right:4px;"></span>
|
||||
Good below (ms)
|
||||
</label>
|
||||
<input type="number" name="good_ms" value="{{ good_ms }}" min="1" max="9999" style="width:100px;">
|
||||
</div>
|
||||
<div class="form-group" style="margin:0;">
|
||||
<label style="font-size:0.8rem;">
|
||||
<span style="display:inline-block;width:10px;height:10px;border-radius:2px;background:#6b5c00;vertical-align:middle;margin-right:4px;"></span>
|
||||
Warn above (ms)
|
||||
</label>
|
||||
<input type="number" name="warn_ms" value="{{ warn_ms }}" min="1" max="9999" style="width:100px;">
|
||||
</div>
|
||||
<button type="submit" class="btn" style="padding:0.4rem 1rem;">Save</button>
|
||||
<span style="color:#505070;font-size:0.8rem;align-self:center;">
|
||||
Above warn = <span style="color:#c06020;">■</span> orange | No response = <span style="color:#c03030;">■</span> red
|
||||
</span>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{# ── Live host rows ─────────────────────────────────────────────────────── #}
|
||||
<div class="card ping-card">
|
||||
<div id="ping-rows"
|
||||
hx-get="/ping/rows"
|
||||
hx-trigger="load, every {{ poll_interval }}s, rangeChange from:body"
|
||||
hx-include="#time-range"
|
||||
hx-swap="innerHTML">
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,67 +0,0 @@
|
||||
{# HTMX fragment — included in both /ping/ page and dashboard widget #}
|
||||
{% macro pill_bg(p) %}
|
||||
{%- if p is none or p.status.value == 'down' or p.response_time_ms is none -%}
|
||||
#6a1515
|
||||
{%- elif p.response_time_ms <= good_ms -%}
|
||||
#1a6632
|
||||
{%- elif p.response_time_ms <= warn_ms -%}
|
||||
#6b5c00
|
||||
{%- else -%}
|
||||
#7a3800
|
||||
{%- endif -%}
|
||||
{% endmacro %}
|
||||
{% macro pill_title(p) %}
|
||||
{%- if p is none -%}No data
|
||||
{%- elif p.status.value == 'down' -%}Down — {{ p.probed_at.strftime('%H:%M:%S') }} UTC
|
||||
{%- else -%}{{ "%.1f"|format(p.response_time_ms) }} ms — {{ p.probed_at.strftime('%H:%M:%S') }} UTC
|
||||
{%- endif -%}
|
||||
{% endmacro %}
|
||||
{% if hosts %}
|
||||
{% for host in hosts %}
|
||||
{% set host_pings = pings_by_host.get(host.id, []) %}
|
||||
{% set last = host_pings[-1] if host_pings else none %}
|
||||
<div class="ping-row">
|
||||
<div class="ping-meta">
|
||||
{# Link to the host hub for logged-in users; plain text on the public share view (no auth, no token on the href). #}
|
||||
{% if session.user_id %}
|
||||
<a class="ping-name" href="/hosts/{{ host.id }}" title="{{ host.name }}"
|
||||
style="color:inherit;text-decoration:none;">{{ host.name }}</a>
|
||||
{% else %}
|
||||
<span class="ping-name" title="{{ host.name }}">{{ host.name }}</span>
|
||||
{% endif %}
|
||||
<span class="ping-addr">{{ host.address }}</span>
|
||||
</div>
|
||||
<div class="ping-pills">
|
||||
{% for _ in range([30 - host_pings|length, 0]|max) %}
|
||||
<span class="pill pill-empty" title="No data"></span>
|
||||
{% endfor %}
|
||||
{% for p in host_pings %}
|
||||
<span class="pill" style="background:{{ pill_bg(p) }};" title="{{ pill_title(p) }}"></span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="ping-cur">
|
||||
{% if last is none %}
|
||||
<span class="ping-cur-nd">—</span>
|
||||
{% elif last.status.value == 'down' or last.response_time_ms is none %}
|
||||
<span class="ping-cur-down">DOWN</span>
|
||||
{% elif last.response_time_ms <= good_ms %}
|
||||
<span class="ping-cur-good">{{ "%.1f"|format(last.response_time_ms) }} ms</span>
|
||||
{% elif last.response_time_ms <= warn_ms %}
|
||||
<span class="ping-cur-warn">{{ "%.1f"|format(last.response_time_ms) }} ms</span>
|
||||
{% else %}
|
||||
<span class="ping-cur-bad">{{ "%.1f"|format(last.response_time_ms) }} ms</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if uptime_pcts is defined %}
|
||||
{% set pct = uptime_pcts.get(host.id) %}
|
||||
{% set us = threshold_style(pct, 'uptime') if pct is not none else 'color:var(--text-dim);' %}
|
||||
<div style="min-width:4.5rem;text-align:right;font-size:0.78rem;font-variant-numeric:tabular-nums;{{ us if us else 'color:var(--green);' }}">
|
||||
{% if pct is not none %}{{ "%.1f"|format(pct) }}%{% else %}—{% endif %}
|
||||
<span style="color:var(--text-dim);font-size:0.7rem;margin-left:0.15rem;">{{ range_key }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p style="color:#505070;font-size:0.85rem;padding:0.5rem 0;">No ping-enabled hosts. <a href="/hosts/" style="color:#7070c0;">Add hosts →</a></p>
|
||||
{% endif %}
|
||||
Reference in New Issue
Block a user