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
+4 -18
View File
@@ -4,6 +4,7 @@ from quart import Blueprint, render_template, request, redirect, url_for, curren
from steward.core.audit import log_audit
from sqlalchemy import select
from steward.auth.middleware import require_role
from steward.alerts import service as alert_svc
from steward.ansible import sources as src_module
from steward.models.alerts import AlertRule, AlertState, AlertStateEnum, AlertOperator, MaintenanceWindow
from steward.models.metrics import PluginMetric
@@ -123,20 +124,8 @@ def _parse_ansible_action(form) -> tuple[dict | None, str | None]:
@require_role(UserRole.viewer)
async def list_alerts():
async with current_app.db_sessionmaker() as db:
result = await db.execute(
select(AlertState, AlertRule)
.join(AlertRule, AlertState.rule_id == AlertRule.id)
.where(AlertState.state.in_([
AlertStateEnum.firing, AlertStateEnum.acknowledged, AlertStateEnum.pending
]))
.order_by(AlertState.last_transition_at.desc())
)
active = result.all()
rules_result = await db.execute(
select(AlertRule).order_by(AlertRule.name)
)
rules = rules_result.scalars().all()
active = await alert_svc.active_alerts(db)
rules = await alert_svc.list_rules(db)
return await render_template("alerts/list.html", active=active, rules=rules, operators=_OPERATORS)
@@ -319,10 +308,7 @@ async def delete_rule(rule_id: str):
async def list_maintenance():
now = datetime.now(timezone.utc)
async with current_app.db_sessionmaker() as db:
result = await db.execute(
select(MaintenanceWindow).order_by(MaintenanceWindow.start_at.desc())
)
windows = result.scalars().all()
windows = await alert_svc.list_maintenance_windows(db)
return await render_template(
"alerts/maintenance.html",
windows=windows,
+36
View File
@@ -0,0 +1,36 @@
"""Alert queries shared by the alerts pages and the MCP tools.
Each function takes an open session and returns rows, 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 sqlalchemy import select
from steward.models.alerts import AlertRule, AlertState, AlertStateEnum, MaintenanceWindow
# States that need someone's attention: pending (breaching, not yet for long
# enough), firing, and acknowledged-but-still-breaching.
ACTIVE_STATES = (AlertStateEnum.firing, AlertStateEnum.acknowledged, AlertStateEnum.pending)
async def active_alerts(db) -> list:
"""(AlertState, AlertRule) rows not yet resolved, latest transition first."""
return list((await db.execute(
select(AlertState, AlertRule)
.join(AlertRule, AlertState.rule_id == AlertRule.id)
.where(AlertState.state.in_(ACTIVE_STATES))
.order_by(AlertState.last_transition_at.desc())
)).all())
async def list_rules(db) -> list[AlertRule]:
return list((await db.execute(select(AlertRule).order_by(AlertRule.name))).scalars())
async def list_maintenance_windows(db) -> list[MaintenanceWindow]:
"""Every window, latest start first."""
return list((await db.execute(
select(MaintenanceWindow).order_by(MaintenanceWindow.start_at.desc())
)).scalars())