feat: live ping pill widget (dashboard + /ping/ page with threshold settings)

This commit is contained in:
2026-03-18 23:18:26 -04:00
parent 791af2d58f
commit 7a7ed2185a
8 changed files with 270 additions and 51 deletions
+2
View File
@@ -86,6 +86,7 @@ def create_app(
from .auth.routes import auth_bp
from .dashboard.routes import dashboard_bp
from .hosts.routes import hosts_bp
from .ping.routes import ping_bp
from .alerts.routes import alerts_bp
from .ansible.routes import ansible_bp
from .settings.routes import settings_bp
@@ -93,6 +94,7 @@ def create_app(
app.register_blueprint(auth_bp)
app.register_blueprint(dashboard_bp)
app.register_blueprint(hosts_bp)
app.register_blueprint(ping_bp)
app.register_blueprint(alerts_bp)
app.register_blueprint(ansible_bp)
app.register_blueprint(settings_bp)
+2
View File
@@ -48,6 +48,8 @@ DEFAULTS: dict[str, Any] = {
"webhook.url": "",
"webhook.template": _DEFAULT_WEBHOOK_TEMPLATE,
"ansible.sources": [],
"ping.threshold.good_ms": 50,
"ping.threshold.warn_ms": 200,
}
View File
+103
View File
@@ -0,0 +1,103 @@
from __future__ import annotations
import logging
from quart import Blueprint, current_app, render_template, request, redirect, url_for
from sqlalchemy import select, func
from fablednetmon.auth.middleware import require_role
from fablednetmon.models.users import UserRole
from fablednetmon.models.hosts import Host
from fablednetmon.models.monitors import PingResult
from fablednetmon.core.settings import get_all_settings, set_setting, DEFAULTS
ping_bp = Blueprint("ping", __name__, url_prefix="/ping")
logger = logging.getLogger(__name__)
async def _last_n_pings(db, host_ids: list[str], n: int = 30) -> dict[str, list]:
"""Return {host_id: [PingResult]} for last n pings per host, oldest first."""
if not host_ids:
return {}
rn = func.row_number().over(
partition_by=PingResult.host_id,
order_by=PingResult.probed_at.desc(),
).label("rn")
subq = (
select(PingResult.id, rn)
.where(PingResult.host_id.in_(host_ids))
.subquery()
)
pr = await db.execute(
select(PingResult)
.join(subq, PingResult.id == subq.c.id)
.where(subq.c.rn <= n)
.order_by(PingResult.host_id, PingResult.probed_at.asc())
)
result: dict[str, list] = {hid: [] for hid in host_ids}
for row in pr.scalars():
result[row.host_id].append(row)
return result
async def _thresholds(db) -> tuple[int, int]:
settings = await get_all_settings(db)
good = int(settings.get("ping.threshold.good_ms", DEFAULTS["ping.threshold.good_ms"]))
warn = int(settings.get("ping.threshold.warn_ms", DEFAULTS["ping.threshold.warn_ms"]))
return good, warn
@ping_bp.get("/")
@require_role(UserRole.viewer)
async def index():
async with current_app.db_sessionmaker() as db:
result = await db.execute(
select(Host).where(Host.ping_enabled.is_(True)).order_by(Host.name)
)
hosts = result.scalars().all()
pings_by_host = await _last_n_pings(db, [h.id for h in hosts])
good_ms, warn_ms = await _thresholds(db)
poll_interval = current_app.config.get("MONITORS_POLL_INTERVAL", 60)
return await render_template(
"ping/index.html",
hosts=hosts,
pings_by_host=pings_by_host,
good_ms=good_ms,
warn_ms=warn_ms,
poll_interval=poll_interval,
)
@ping_bp.get("/rows")
@require_role(UserRole.viewer)
async def rows():
"""HTMX fragment — host rows with pills, no page shell."""
async with current_app.db_sessionmaker() as db:
result = await db.execute(
select(Host).where(Host.ping_enabled.is_(True)).order_by(Host.name)
)
hosts = result.scalars().all()
pings_by_host = await _last_n_pings(db, [h.id for h in hosts])
good_ms, warn_ms = await _thresholds(db)
return await render_template(
"ping/rows.html",
hosts=hosts,
pings_by_host=pings_by_host,
good_ms=good_ms,
warn_ms=warn_ms,
)
@ping_bp.post("/settings")
@require_role(UserRole.admin)
async def save_settings():
form = await request.form
try:
good = max(1, int(form.get("good_ms", 50)))
warn = max(good + 1, int(form.get("warn_ms", 200)))
except (ValueError, TypeError):
good, warn = 50, 200
async with current_app.db_sessionmaker() as db:
async with db.begin():
await set_setting(db, "ping.threshold.good_ms", good)
await set_setting(db, "ping.threshold.warn_ms", warn)
return redirect(url_for("ping.index"))
+18
View File
@@ -26,6 +26,23 @@
border-radius: 4px; cursor: pointer; font-size: 0.95rem;
}
button[type=submit]:hover, .btn:hover { background: #5050c0; }
/* Ping pills */
.ping-card { padding: 0.75rem 1.25rem; }
.ping-row { display:flex; align-items:center; gap:1rem; padding:0.45rem 0; border-bottom:1px solid #161626; }
.ping-row:last-child { border-bottom:none; }
.ping-meta { min-width:160px; flex-shrink:0; }
.ping-name { font-size:0.9rem; }
.ping-addr { display:block; color:#505070; font-size:0.75rem; }
.ping-pills { display:flex; gap:2px; flex:1; align-items:center; }
.pill { display:inline-block; width:7px; height:22px; border-radius:2px; cursor:default; transition:opacity .1s; }
.pill:hover { opacity:.75; }
.pill-empty { background:#1a1a28; }
.ping-cur { min-width:72px; text-align:right; font-size:0.85rem; font-variant-numeric:tabular-nums; }
.ping-cur-good { color:#22aa44; }
.ping-cur-warn { color:#ccaa20; }
.ping-cur-bad { color:#dd6020; }
.ping-cur-down { color:#cc2020; font-weight:bold; }
.ping-cur-nd { color:#404060; }
</style>
{% block head %}{% endblock %}
</head>
@@ -35,6 +52,7 @@
<span class="brand">FabledNetMon</span>
<a href="/">Dashboard</a>
<a href="/hosts/">Hosts</a>
<a href="/ping/">Ping</a>
<a href="/alerts/">Alerts</a>
<a href="/ansible/">Ansible</a>
{% if session.user_role == 'admin' %}
+46 -51
View File
@@ -2,60 +2,55 @@
{% block title %}Dashboard — FabledNetMon{% endblock %}
{% block content %}
<h1 style="margin-bottom:1.5rem;color:#c0c0ff;">Dashboard</h1>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:1rem;">
<div class="card">
<h3 style="color:#8080ff;margin-bottom:0.75rem;font-size:0.9rem;text-transform:uppercase;letter-spacing:0.05em;">Ping</h3>
{% if ping_up + ping_down == 0 %}
<p style="color:#606080;font-size:0.9rem;"><a href="/hosts/" style="color:#a0a0ff;">Add hosts</a> to begin monitoring.</p>
{% else %}
<div style="display:flex;gap:1.5rem;align-items:baseline;">
<div>
<span style="font-size:2rem;font-weight:bold;color:#40c040;">{{ ping_up }}</span>
<span style="color:#606080;font-size:0.85rem;margin-left:0.25rem;">up</span>
</div>
{% if ping_down > 0 %}
<div>
<span style="font-size:2rem;font-weight:bold;color:#c04040;">{{ ping_down }}</span>
<span style="color:#606080;font-size:0.85rem;margin-left:0.25rem;">down</span>
</div>
{% endif %}
</div>
<p style="color:#404060;font-size:0.8rem;margin-top:0.5rem;">every {{ poll_interval }}s — <a href="/hosts/" style="color:#6060a0;">view all</a></p>
{% endif %}
{# ── Summary row ──────────────────────────────────────────────────────────── #}
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:1rem;margin-bottom:1.5rem;">
<div class="card">
<h3 style="color:#8080ff;margin-bottom:0.5rem;font-size:0.85rem;text-transform:uppercase;letter-spacing:.05em;">Hosts</h3>
<span style="font-size:1.8rem;font-weight:bold;color:#c0c0e0;">{{ total_hosts }}</span>
<span style="color:#505070;font-size:0.8rem;margin-left:0.3rem;">configured</span>
<p style="margin-top:0.4rem;"><a href="/hosts/" style="color:#6060a0;font-size:0.8rem;">Manage →</a></p>
</div>
<div class="card">
<h3 style="color:#8080ff;margin-bottom:0.5rem;font-size:0.85rem;text-transform:uppercase;letter-spacing:.05em;">Ping</h3>
{% if ping_up + ping_down == 0 %}
<p style="color:#505070;font-size:0.85rem;"><a href="/hosts/" style="color:#a0a0ff;">Enable ping</a> on a host.</p>
{% else %}
<div style="display:flex;gap:1rem;align-items:baseline;">
<div><span style="font-size:1.8rem;font-weight:bold;color:#40c040;">{{ ping_up }}</span><span style="color:#505070;font-size:0.8rem;margin-left:0.2rem;">up</span></div>
{% if ping_down %}<div><span style="font-size:1.8rem;font-weight:bold;color:#c04040;">{{ ping_down }}</span><span style="color:#505070;font-size:0.8rem;margin-left:0.2rem;">down</span></div>{% endif %}
</div>
<div class="card">
<h3 style="color:#8080ff;margin-bottom:0.75rem;font-size:0.9rem;text-transform:uppercase;letter-spacing:0.05em;">DNS</h3>
{% if dns_ok + dns_fail == 0 %}
<p style="color:#606080;font-size:0.9rem;">Enable DNS monitoring on a <a href="/hosts/" style="color:#a0a0ff;">host</a>.</p>
{% else %}
<div style="display:flex;gap:1.5rem;align-items:baseline;">
<div>
<span style="font-size:2rem;font-weight:bold;color:#40c040;">{{ dns_ok }}</span>
<span style="color:#606080;font-size:0.85rem;margin-left:0.25rem;">ok</span>
</div>
{% if dns_fail > 0 %}
<div>
<span style="font-size:2rem;font-weight:bold;color:#c04040;">{{ dns_fail }}</span>
<span style="color:#606080;font-size:0.85rem;margin-left:0.25rem;">failed</span>
</div>
{% endif %}
</div>
{% endif %}
</div>
<div class="card">
<h3 style="color:#8080ff;margin-bottom:0.75rem;font-size:0.9rem;text-transform:uppercase;letter-spacing:0.05em;">Hosts</h3>
<span style="font-size:2rem;font-weight:bold;color:#c0c0e0;">{{ total_hosts }}</span>
<span style="color:#606080;font-size:0.85rem;margin-left:0.25rem;">configured</span>
<p style="margin-top:0.5rem;"><a href="/hosts/" style="color:#6060a0;font-size:0.85rem;">Manage →</a></p>
</div>
<div class="card">
<h3 style="color:#8080ff;margin-bottom:0.75rem;font-size:0.9rem;text-transform:uppercase;letter-spacing:0.05em;">Alerts</h3>
<p style="color:#606080;font-size:0.9rem;"><a href="/alerts/" style="color:#a0a0ff;">Configure alert rules</a> against any metric.</p>
{% endif %}
</div>
<div class="card">
<h3 style="color:#8080ff;margin-bottom:0.5rem;font-size:0.85rem;text-transform:uppercase;letter-spacing:.05em;">DNS</h3>
{% if dns_ok + dns_fail == 0 %}
<p style="color:#505070;font-size:0.85rem;"><a href="/hosts/" style="color:#a0a0ff;">Enable DNS</a> on a host.</p>
{% else %}
<div style="display:flex;gap:1rem;align-items:baseline;">
<div><span style="font-size:1.8rem;font-weight:bold;color:#40c040;">{{ dns_ok }}</span><span style="color:#505070;font-size:0.8rem;margin-left:0.2rem;">ok</span></div>
{% if dns_fail %}<div><span style="font-size:1.8rem;font-weight:bold;color:#c04040;">{{ dns_fail }}</span><span style="color:#505070;font-size:0.8rem;margin-left:0.2rem;">failed</span></div>{% endif %}
</div>
{% endif %}
</div>
<div class="card">
<h3 style="color:#8080ff;margin-bottom:0.5rem;font-size:0.85rem;text-transform:uppercase;letter-spacing:.05em;">Alerts</h3>
<p style="color:#505070;font-size:0.85rem;"><a href="/alerts/" style="color:#a0a0ff;">Configure rules →</a></p>
</div>
</div>
{# ── Live ping widget ─────────────────────────────────────────────────────── #}
<div class="card ping-card">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.75rem;">
<h2 style="color:#8080ff;font-size:0.9rem;text-transform:uppercase;letter-spacing:.05em;">
Ping — last 30 probes
</h2>
<a href="/ping/" style="color:#6060a0;font-size:0.8rem;">Details &amp; settings →</a>
</div>
<div id="ping-widget"
hx-get="/ping/rows"
hx-trigger="load, every {{ poll_interval }}s"
hx-swap="innerHTML">
</div>
</div>
{% endblock %}
+46
View File
@@ -0,0 +1,46 @@
{% extends "base.html" %}
{% block title %}Ping Monitor — FabledNetMon{% endblock %}
{% block content %}
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.5rem;">
<h1 style="color:#c0c0ff;">Ping Monitor</h1>
<span style="color:#505070;font-size:0.85rem;">polling every {{ poll_interval }}s</span>
</div>
{# ── Threshold settings ─────────────────────────────────────────────────── #}
<div class="card" style="margin-bottom:1.25rem;">
<h2 style="color:#8080ff;font-size:0.9rem;text-transform:uppercase;letter-spacing:0.05em;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;padding:0.4rem 0.6rem;background:#0f0f1e;border:1px solid #3a3a5a;border-radius:4px;color:#e0e0e0;">
</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;padding:0.4rem 0.6rem;background:#0f0f1e;border:1px solid #3a3a5a;border-radius:4px;color:#e0e0e0;">
</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&nbsp;&nbsp;|&nbsp;&nbsp;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="every {{ poll_interval }}s"
hx-swap="innerHTML">
{% include "ping/rows.html" %}
</div>
</div>
{% endblock %}
+53
View File
@@ -0,0 +1,53 @@
{# 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">
<span class="ping-name">{{ host.name }}</span>
<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>
</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 %}