feat: live ping pill widget (dashboard + /ping/ page with threshold settings)
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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"))
|
||||
@@ -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' %}
|
||||
|
||||
@@ -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 & settings →</a>
|
||||
</div>
|
||||
<div id="ping-widget"
|
||||
hx-get="/ping/rows"
|
||||
hx-trigger="load, every {{ poll_interval }}s"
|
||||
hx-swap="innerHTML">
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -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 | 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 %}
|
||||
@@ -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 %}
|
||||
Reference in New Issue
Block a user