Files
FabledSteward/steward/ping/routes.py
T
bvandeusen 88ab5b917e chore: rename project Roundtable → Steward
Renames the Python package directory, CLI command, env var prefix,
docker-compose service/container/image, Postgres role/db, and all
visible branding. Marketing form is "Fabled Steward".

Clean break from the previous rebrand: drops the fabledscryer→roundtable
import shim in __init__.py and the FABLEDSCRYER_* env var fallback in
config.py and migrations/env.py. Env vars are now STEWARD_* only.

Heads-up for existing deployments:
- Postgres user/db renamed fabledscryer → steward in docker-compose.yml.
  Existing volumes need the role/db renamed inside Postgres, or override
  POSTGRES_USER/POSTGRES_DB to keep the old names.
- Host-agent systemd unit is now steward-agent.service. Existing agents
  keep running under the old name; reinstall to switch.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 16:20:14 -04:00

128 lines
4.4 KiB
Python

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 steward.auth.middleware import require_role
from steward.models.users import UserRole
from steward.models.hosts import Host
from steward.models.monitors import PingResult
from steward.core.settings import get_all_settings, set_setting, DEFAULTS
from steward.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"))