From 7a7ed2185a94d30e59dfdd8d4d0e285f79af8983 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 18 Mar 2026 23:18:26 -0400 Subject: [PATCH] feat: live ping pill widget (dashboard + /ping/ page with threshold settings) --- fablednetmon/app.py | 2 + fablednetmon/core/settings.py | 2 + fablednetmon/ping/__init__.py | 0 fablednetmon/ping/routes.py | 103 ++++++++++++++++++++ fablednetmon/templates/base.html | 18 ++++ fablednetmon/templates/dashboard/index.html | 97 +++++++++--------- fablednetmon/templates/ping/index.html | 46 +++++++++ fablednetmon/templates/ping/rows.html | 53 ++++++++++ 8 files changed, 270 insertions(+), 51 deletions(-) create mode 100644 fablednetmon/ping/__init__.py create mode 100644 fablednetmon/ping/routes.py create mode 100644 fablednetmon/templates/ping/index.html create mode 100644 fablednetmon/templates/ping/rows.html diff --git a/fablednetmon/app.py b/fablednetmon/app.py index fea13ba..ca2770b 100644 --- a/fablednetmon/app.py +++ b/fablednetmon/app.py @@ -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) diff --git a/fablednetmon/core/settings.py b/fablednetmon/core/settings.py index 6a67e3d..6940325 100644 --- a/fablednetmon/core/settings.py +++ b/fablednetmon/core/settings.py @@ -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, } diff --git a/fablednetmon/ping/__init__.py b/fablednetmon/ping/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/fablednetmon/ping/routes.py b/fablednetmon/ping/routes.py new file mode 100644 index 0000000..63dc6bc --- /dev/null +++ b/fablednetmon/ping/routes.py @@ -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")) diff --git a/fablednetmon/templates/base.html b/fablednetmon/templates/base.html index 093574d..f25617a 100644 --- a/fablednetmon/templates/base.html +++ b/fablednetmon/templates/base.html @@ -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; } {% block head %}{% endblock %} @@ -35,6 +52,7 @@ FabledNetMon Dashboard Hosts + Ping Alerts Ansible {% if session.user_role == 'admin' %} diff --git a/fablednetmon/templates/dashboard/index.html b/fablednetmon/templates/dashboard/index.html index 3987105..6371e06 100644 --- a/fablednetmon/templates/dashboard/index.html +++ b/fablednetmon/templates/dashboard/index.html @@ -2,60 +2,55 @@ {% block title %}Dashboard — FabledNetMon{% endblock %} {% block content %}

Dashboard

-
-
-

Ping

- {% if ping_up + ping_down == 0 %} -

Add hosts to begin monitoring.

- {% else %} -
-
- {{ ping_up }} - up -
- {% if ping_down > 0 %} -
- {{ ping_down }} - down -
- {% endif %} -
-

every {{ poll_interval }}s — view all

- {% endif %} +{# ── Summary row ──────────────────────────────────────────────────────────── #} +
+
+

Hosts

+ {{ total_hosts }} + configured +

Manage →

+
+
+

Ping

+ {% if ping_up + ping_down == 0 %} +

Enable ping on a host.

+ {% else %} +
+
{{ ping_up }}up
+ {% if ping_down %}
{{ ping_down }}down
{% endif %}
- -
-

DNS

- {% if dns_ok + dns_fail == 0 %} -

Enable DNS monitoring on a host.

- {% else %} -
-
- {{ dns_ok }} - ok -
- {% if dns_fail > 0 %} -
- {{ dns_fail }} - failed -
- {% endif %} -
- {% endif %} -
- -
-

Hosts

- {{ total_hosts }} - configured -

Manage →

-
- -
-

Alerts

-

Configure alert rules against any metric.

+ {% endif %} +
+
+

DNS

+ {% if dns_ok + dns_fail == 0 %} +

Enable DNS on a host.

+ {% else %} +
+
{{ dns_ok }}ok
+ {% if dns_fail %}
{{ dns_fail }}failed
{% endif %}
+ {% endif %} +
+
+

Alerts

+

Configure rules →

+
+
+{# ── Live ping widget ─────────────────────────────────────────────────────── #} +
+
+

+ Ping — last 30 probes +

+ Details & settings → +
+
+
{% endblock %} diff --git a/fablednetmon/templates/ping/index.html b/fablednetmon/templates/ping/index.html new file mode 100644 index 0000000..ae4e88e --- /dev/null +++ b/fablednetmon/templates/ping/index.html @@ -0,0 +1,46 @@ +{% extends "base.html" %} +{% block title %}Ping Monitor — FabledNetMon{% endblock %} +{% block content %} +
+

Ping Monitor

+ polling every {{ poll_interval }}s +
+ +{# ── Threshold settings ─────────────────────────────────────────────────── #} +
+

Latency Thresholds

+
+
+ + +
+
+ + +
+ + + Above warn = orange  |  No response = red + +
+
+ +{# ── Live host rows ─────────────────────────────────────────────────────── #} +
+
+ {% include "ping/rows.html" %} +
+
+{% endblock %} diff --git a/fablednetmon/templates/ping/rows.html b/fablednetmon/templates/ping/rows.html new file mode 100644 index 0000000..a1ab405 --- /dev/null +++ b/fablednetmon/templates/ping/rows.html @@ -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 %} +
+
+ {{ host.name }} + {{ host.address }} +
+
+ {% for _ in range([30 - host_pings|length, 0]|max) %} + + {% endfor %} + {% for p in host_pings %} + + {% endfor %} +
+
+ {% if last is none %} + + {% elif last.status.value == 'down' or last.response_time_ms is none %} + DOWN + {% elif last.response_time_ms <= good_ms %} + {{ "%.1f"|format(last.response_time_ms) }} ms + {% elif last.response_time_ms <= warn_ms %} + {{ "%.1f"|format(last.response_time_ms) }} ms + {% else %} + {{ "%.1f"|format(last.response_time_ms) }} ms + {% endif %} +
+
+{% endfor %} +{% else %} +

No ping-enabled hosts. Add hosts →

+{% endif %}