refactor: move host, docker, alert and monitor queries into service modules
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 35s
CI / unit (push) Successful in 59s
CI / publish (push) Successful in 56s

MCP-3 of milestone 461 "MCP server: Steward as an LLM-inspectable,
LLM-operable hub". The /mcp tools run outside Quart's request pipeline, so
the queries they share with the pages now live in plain functions that take
an open session:

- steward/hosts/service.py: uptime, status, monitors, agent metrics,
  agent overview (stale threshold passed in, not read from current_app),
  CPU sparks.
- plugins/docker/service.py: bucket, container_history, host_map,
  list_containers, container_events, query_logs.
- steward/alerts/service.py: active_alerts (also used by core/reports),
  list_rules, list_maintenance_windows.
- steward/monitors/service.py: monitor_rows (was routes._rows_data).

Routes are repointed and render the same data. One behavior fix rides
along: the container-log search now escapes % and _ so they match
literally instead of acting as LIKE wildcards.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 07:29:21 -04:00
co-authored by Claude Opus 5.5
parent 970adf41e7
commit 312ea95bcf
10 changed files with 486 additions and 397 deletions
+7 -42
View File
@@ -10,13 +10,12 @@ import uuid
from datetime import datetime, timezone
from quart import Blueprint, current_app, render_template, request, redirect, url_for
from sqlalchemy import select
from steward.auth.middleware import require_role
from steward.models.users import UserRole
from steward.models.monitors import Monitor, MonitorType, MONITOR_TYPE_VALUES
from steward.core.status import _last_n_results, _uptime_by_monitor, _heartbeat_title
from steward.core.time_range import parse_range, DEFAULT_RANGE
from steward.core.time_range import DEFAULT_RANGE
from steward.monitors import service as monitor_svc
monitors_bp = Blueprint("monitors", __name__, url_prefix="/monitors")
@@ -52,41 +51,6 @@ def _normalise_target(mtype: str, target: str) -> str:
return target
async def _rows_data(range_key: str):
"""Per-monitor display data for the list / widget."""
since, range_key = parse_range(range_key)
async with current_app.db_sessionmaker() as db:
monitors = list((await db.execute(
select(Monitor).order_by(Monitor.name)
)).scalars())
ids = [m.id for m in monitors]
recent = await _last_n_results(db, ids)
uptime = await _uptime_by_monitor(db, ids)
# Map the selected range to the uptime bucket we surface in the list.
bucket = {"24h": "24h", "7d": "7d", "30d": "30d"}.get(range_key, "24h")
now = datetime.now(timezone.utc)
data = []
for m in monitors:
rows = recent.get(m.id, [])
latest = rows[-1] if rows else None
tls_days = None
if latest and latest.tls_expires_at:
tls_days = (latest.tls_expires_at - now).total_seconds() / 86400.0
data.append({
"monitor": m,
"latest": latest,
"heartbeat": [{"state": "up" if r.is_up else "down",
"title": _heartbeat_title(m.type, r)} for r in rows],
"uptime_pct": uptime.get(m.id, {}).get(bucket),
"tls_days": tls_days,
})
up = sum(1 for d in data if d["latest"] and d["latest"].is_up)
down = sum(1 for d in data if d["latest"] and not d["latest"].is_up)
pending = sum(1 for d in data if not d["latest"])
return data, up, down, pending, range_key
@monitors_bp.get("/")
@require_role(UserRole.viewer)
async def index():
@@ -102,9 +66,9 @@ async def index():
@monitors_bp.get("/rows")
@require_role(UserRole.viewer)
async def rows():
data, up, down, pending, range_key = await _rows_data(
request.args.get("range", DEFAULT_RANGE)
)
async with current_app.db_sessionmaker() as db:
data, up, down, pending, range_key = await monitor_svc.monitor_rows(
db, request.args.get("range", DEFAULT_RANGE))
return await render_template(
"monitors/rows.html",
monitor_data=data, up=up, down=down, pending=pending, range_key=range_key,
@@ -202,7 +166,8 @@ async def widget():
limit = max(1, min(50, int(request.args.get("limit", 10) or 10)))
widget_id = request.args.get("wid", "0")
data, up, down, pending, _ = await _rows_data(DEFAULT_RANGE)
async with current_app.db_sessionmaker() as db:
data, up, down, pending, _ = await monitor_svc.monitor_rows(db, DEFAULT_RANGE)
if type_filter in MONITOR_TYPE_VALUES:
data = [d for d in data if d["monitor"].type == type_filter]
if show_down_only:
+52
View File
@@ -0,0 +1,52 @@
"""Monitor queries shared by the monitors pages and the MCP tools.
Each function takes an open session, with no Quart request context, so the
routes and the /mcp tools (which run outside Quart's pipeline) call the same
code.
"""
from __future__ import annotations
from datetime import datetime, timezone
from sqlalchemy import select
from steward.core.status import _heartbeat_title, _last_n_results, _uptime_by_monitor
from steward.core.time_range import parse_range
from steward.models.monitors import Monitor
async def monitor_rows(db, range_key: str):
"""Per-monitor state: latest result, heartbeat strip, uptime and TLS days.
Returns (rows, up, down, pending, range_key). `range_key` picks the uptime
window surfaced (24h/7d/30d; anything else falls back to 24h) and comes
back normalised.
"""
_since, range_key = parse_range(range_key)
monitors = list((await db.execute(select(Monitor).order_by(Monitor.name))).scalars())
ids = [m.id for m in monitors]
recent = await _last_n_results(db, ids)
uptime = await _uptime_by_monitor(db, ids)
# Map the selected range to the uptime bucket we surface in the list.
bucket = {"24h": "24h", "7d": "7d", "30d": "30d"}.get(range_key, "24h")
now = datetime.now(timezone.utc)
data = []
for m in monitors:
rows = recent.get(m.id, [])
latest = rows[-1] if rows else None
tls_days = None
if latest and latest.tls_expires_at:
tls_days = (latest.tls_expires_at - now).total_seconds() / 86400.0
data.append({
"monitor": m,
"latest": latest,
"heartbeat": [{"state": "up" if r.is_up else "down",
"title": _heartbeat_title(m.type, r)} for r in rows],
"uptime_pct": uptime.get(m.id, {}).get(bucket),
"tls_days": tls_days,
})
up = sum(1 for d in data if d["latest"] and d["latest"].is_up)
down = sum(1 for d in data if d["latest"] and not d["latest"].is_up)
pending = sum(1 for d in data if not d["latest"])
return data, up, down, pending, range_key