from __future__ import annotations import logging from quart import Blueprint, current_app, render_template, request, redirect, url_for from sqlalchemy import select, func, case from roundtable.auth.middleware import require_role from roundtable.models.users import UserRole from roundtable.models.hosts import Host from roundtable.models.monitors import PingResult from roundtable.core.settings import get_all_settings, set_setting, DEFAULTS from roundtable.core.time_range import parse_range, DEFAULT_RANGE ping_bp = Blueprint("ping", __name__, url_prefix="/ping") logger = logging.getLogger(__name__) PILL_COUNT = 30 # pills always show the last N pings regardless of range async def _last_n_pings(db, host_ids: list[str], n: int = PILL_COUNT) -> 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 _uptime_pcts(db, host_ids: list[str], since) -> dict[str, float | None]: """Return {host_id: uptime_%} for results since the given datetime.""" if not host_ids: return {} result = await db.execute( select( PingResult.host_id, func.count().label("total"), func.sum(case((PingResult.status == "up", 1), else_=0)).label("up_count"), ) .where(PingResult.host_id.in_(host_ids)) .where(PingResult.probed_at >= since) .group_by(PingResult.host_id) ) pcts: dict[str, float | None] = {hid: None for hid in host_ids} for row in result.all(): if row.total and row.total > 0: pcts[row.host_id] = (row.up_count or 0) / row.total * 100.0 return pcts 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: good_ms, warn_ms = await _thresholds(db) poll_interval = current_app.config.get("MONITORS_POLL_INTERVAL", 60) current_range = request.args.get("range", DEFAULT_RANGE) return await render_template( "ping/index.html", good_ms=good_ms, warn_ms=warn_ms, poll_interval=poll_interval, current_range=current_range, ) @ping_bp.get("/rows") @require_role(UserRole.viewer) async def rows(): """HTMX fragment — host rows with pills + uptime % for selected range.""" since, range_key = parse_range(request.args.get("range")) 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() host_ids = [h.id for h in hosts] pings_by_host = await _last_n_pings(db, host_ids) uptime_pcts = await _uptime_pcts(db, host_ids, since) good_ms, warn_ms = await _thresholds(db) return await render_template( "ping/rows.html", hosts=hosts, pings_by_host=pings_by_host, uptime_pcts=uptime_pcts, range_key=range_key, 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"))