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())
+4 -10
View File
@@ -11,7 +11,8 @@ from email.message import EmailMessage
from sqlalchemy import and_, case, func, select
from steward.models.alerts import AlertRule, AlertState, AlertStateEnum, AlertEvent
from steward.alerts import service as alert_svc
from steward.models.alerts import AlertEvent
from steward.models.metrics import PluginMetric
from steward.models.monitors import Monitor, MonitorResult
@@ -51,14 +52,7 @@ async def build_report_data(app) -> dict:
]
# ── Active alerts ─────────────────────────────────────────────────────
active_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_rows = await alert_svc.active_alerts(db)
active_alerts = [
{
"state": state.state.value.upper(),
@@ -68,7 +62,7 @@ async def build_report_data(app) -> dict:
"operator": rule.operator.value,
"threshold": rule.threshold,
}
for state, rule in active_result.all()
for state, rule in active_rows
]
# ── Alert event counts (7d) ───────────────────────────────────────────
+14 -233
View File
@@ -1,17 +1,15 @@
from __future__ import annotations
import asyncio
import uuid
from datetime import datetime, timedelta, timezone
from quart import Blueprint, render_template, request, redirect, url_for, current_app, session
from sqlalchemy import and_, case, select, func
from sqlalchemy import select
from steward.ansible import executor, sources as ansible_src
from steward.auth.middleware import require_role
from steward.core.audit import log_audit
from steward.models.ansible import AnsibleRun, AnsibleRunStatus
from steward.models.hosts import Host
from steward.models.monitors import Monitor, MonitorResult
from steward.models.users import UserRole
from steward.core.status import _last_n_results, _uptime_by_monitor, _heartbeat_title
from steward.hosts import service as host_svc
hosts_bp = Blueprint("hosts", __name__, url_prefix="/hosts")
@@ -23,235 +21,18 @@ def _ansible_source_names() -> list[str]:
return []
async def _compute_uptime(db) -> dict[str, dict]:
"""Per-host uptime % for 24h/7d/30d, aggregated over the host's monitors.
Returns {host_id: {"24h": float|None, "7d": float|None, "30d": float|None}}.
"""
now = datetime.now(timezone.utc)
cutoff_30d = now - timedelta(days=30)
cutoff_7d = now - timedelta(days=7)
cutoff_24h = now - timedelta(hours=24)
result = await db.execute(
select(
Monitor.host_id,
func.count(MonitorResult.id).label("total_30d"),
func.sum(case((MonitorResult.is_up.is_(True), 1), else_=0)).label("up_30d"),
func.sum(case((MonitorResult.checked_at >= cutoff_7d, 1), else_=0)).label("total_7d"),
func.sum(case(
(and_(MonitorResult.checked_at >= cutoff_7d, MonitorResult.is_up.is_(True)), 1),
else_=0,
)).label("up_7d"),
func.sum(case((MonitorResult.checked_at >= cutoff_24h, 1), else_=0)).label("total_24h"),
func.sum(case(
(and_(MonitorResult.checked_at >= cutoff_24h, MonitorResult.is_up.is_(True)), 1),
else_=0,
)).label("up_24h"),
)
.join(Monitor, Monitor.id == MonitorResult.monitor_id)
.where(Monitor.host_id.isnot(None), MonitorResult.checked_at >= cutoff_30d)
.group_by(Monitor.host_id)
)
def _pct(up, total):
return round(float(up) / float(total) * 100, 2) if total else None
stats: dict[str, dict] = {}
for row in result:
stats[row.host_id] = {
"24h": _pct(row.up_24h, row.total_24h),
"7d": _pct(row.up_7d, row.total_7d),
"30d": _pct(row.up_30d, row.total_30d),
}
return stats
async def _host_status(db, host_ids: list[str]) -> dict[str, dict]:
"""Per-host status rollup from the latest result of each linked monitor.
{host_id: {"state": "up"|"down"|"pending", "latency_ms": float|None,
"count": int}}. State is down if ANY linked monitor's latest
result is down (worst-case), up if at least one is up, else pending.
"""
if not host_ids:
return {}
rn = func.row_number().over(
partition_by=MonitorResult.monitor_id, order_by=MonitorResult.checked_at.desc()
).label("rn")
subq = (
select(MonitorResult.id, rn)
.join(Monitor, Monitor.id == MonitorResult.monitor_id)
.where(Monitor.host_id.in_(host_ids), Monitor.enabled.is_(True))
.subquery()
)
rows = (await db.execute(
select(MonitorResult, Monitor.host_id)
.join(subq, MonitorResult.id == subq.c.id)
.join(Monitor, Monitor.id == MonitorResult.monitor_id)
.where(subq.c.rn == 1)
)).all()
out: dict[str, dict] = {}
for res, host_id in rows:
d = out.setdefault(host_id, {"state": "pending", "latency_ms": None, "count": 0})
d["count"] += 1
if not res.is_up:
d["state"] = "down"
elif d["state"] != "down":
d["state"] = "up"
if res.is_up and res.response_ms is not None:
d["latency_ms"] = (res.response_ms if d["latency_ms"] is None
else min(d["latency_ms"], res.response_ms))
return out
async def _host_monitors(db, host_id: str) -> list[dict]:
"""Display rows for the monitors linked to one host (host hub section)."""
monitors = (await db.execute(
select(Monitor).where(Monitor.host_id == host_id).order_by(Monitor.name)
)).scalars().all()
ids = [m.id for m in monitors]
recent = await _last_n_results(db, ids)
uptime = await _uptime_by_monitor(db, ids)
data = []
for m in monitors:
mrows = recent.get(m.id, [])
latest = mrows[-1] if mrows else None
data.append({
"monitor": m, "latest": latest,
"heartbeat": [{"state": "up" if r.is_up else "down",
"title": _heartbeat_title(m.type, r)} for r in mrows],
"uptime_pct": uptime.get(m.id, {}).get("24h"),
})
return data
async def _agent_metrics_by_host(db, host_names: list[str]) -> dict[str, dict]:
"""Latest agent cpu/mem per host name from the generic PluginMetric table.
Read directly (PluginMetric is a core model) so the Hosts list can show an
at-a-glance agent column without importing the host_agent plugin.
"""
from steward.models.metrics import PluginMetric
if not host_names:
return {}
wanted = ("cpu_pct", "mem_used_pct")
subq = (
select(
PluginMetric.resource_name, PluginMetric.metric_name,
func.max(PluginMetric.recorded_at).label("m"),
)
.where(
PluginMetric.source_module == "host_agent",
PluginMetric.metric_name.in_(wanted),
PluginMetric.resource_name.in_(host_names),
)
.group_by(PluginMetric.resource_name, PluginMetric.metric_name)
).subquery()
rows = (await db.execute(
select(PluginMetric).join(
subq,
(PluginMetric.resource_name == subq.c.resource_name)
& (PluginMetric.metric_name == subq.c.metric_name)
& (PluginMetric.recorded_at == subq.c.m),
)
)).scalars().all()
out: dict[str, dict] = {}
for r in rows:
out.setdefault(r.resource_name, {})[r.metric_name] = r.value
return out
async def _agent_overview_by_host(db, host_names: list[str]) -> dict[str, dict]:
"""Per-host agent glance (cpu/mem/root-disk + freshness) from PluginMetric.
Core-safe (no host_agent import). Freshness compares the latest host-level
sample against the plugin's stale window (config), defaulting to 180s.
"""
from steward.models.metrics import PluginMetric
if not host_names:
return {}
now = datetime.now(timezone.utc)
stale_after = int(
(current_app.config.get("PLUGINS", {}).get("host_agent", {}) or {})
.get("stale_after_seconds", 180))
resources = list(host_names) + [h + ":/" for h in host_names]
subq = (
select(
PluginMetric.resource_name, PluginMetric.metric_name,
func.max(PluginMetric.recorded_at).label("m"),
)
.where(
PluginMetric.source_module == "host_agent",
PluginMetric.resource_name.in_(resources),
PluginMetric.metric_name.in_(("cpu_pct", "mem_used_pct", "disk_used_pct")),
)
.group_by(PluginMetric.resource_name, PluginMetric.metric_name)
).subquery()
rows = (await db.execute(
select(PluginMetric).join(
subq,
(PluginMetric.resource_name == subq.c.resource_name)
& (PluginMetric.metric_name == subq.c.metric_name)
& (PluginMetric.recorded_at == subq.c.m),
).where(PluginMetric.source_module == "host_agent")
)).scalars().all()
out: dict[str, dict] = {}
for r in rows:
if r.resource_name.endswith(":/") and r.metric_name == "disk_used_pct":
out.setdefault(r.resource_name[:-2], {})["disk_root"] = r.value
elif r.resource_name in host_names:
d = out.setdefault(r.resource_name, {})
d[r.metric_name] = r.value
if d.get("_ts") is None or r.recorded_at > d["_ts"]:
d["_ts"] = r.recorded_at
for d in out.values():
ts = d.pop("_ts", None)
d["fresh"] = bool(ts and (now - ts).total_seconds() <= stale_after)
return out
async def _agent_cpu_sparks_by_host(db, host_names: list[str], hours: int = 1) -> dict[str, str]:
"""{host_name: inline-SVG cpu sparkline} over the last `hours` (core-safe).
A tiny at-a-glance trend in each row — the host-view sparkline, on the widget.
"""
from steward.models.metrics import PluginMetric
from steward.core.status import sparkline_svg
if not host_names:
return {}
cutoff = datetime.now(timezone.utc) - timedelta(hours=hours)
rows = (await db.execute(
select(PluginMetric.resource_name, PluginMetric.value, PluginMetric.recorded_at)
.where(
PluginMetric.source_module == "host_agent",
PluginMetric.metric_name == "cpu_pct",
PluginMetric.resource_name.in_(host_names),
PluginMetric.recorded_at >= cutoff,
)
.order_by(PluginMetric.recorded_at)
)).all()
by_host: dict[str, list[float]] = {}
for r in rows:
by_host.setdefault(r.resource_name, []).append(r.value)
# Amber line to read as "CPU"; only draw when there are a couple of points.
return {
name: sparkline_svg(vals[-40:], width=64, height=18, stroke="#c8a840")
for name, vals in by_host.items() if len(vals) >= 2
}
@hosts_bp.get("/overview/widget")
@require_role(UserRole.viewer)
async def overview_widget():
"""Dashboard widget: unified per-host monitor + agent glance, linking to the hub."""
async with current_app.db_sessionmaker() as db:
hosts = (await db.execute(select(Host).order_by(Host.name))).scalars().all()
host_status = await _host_status(db, [h.id for h in hosts])
uptime = await _compute_uptime(db)
host_status = await host_svc.host_status(db, [h.id for h in hosts])
uptime = await host_svc.compute_uptime(db)
host_names = [h.name for h in hosts]
agent = await _agent_overview_by_host(db, host_names)
cpu_sparks = await _agent_cpu_sparks_by_host(db, host_names)
agent = await host_svc.agent_overview_by_host(
db, host_names, host_svc.agent_stale_after_seconds(current_app.config))
cpu_sparks = await host_svc.agent_cpu_sparks_by_host(db, host_names)
return await render_template(
"hosts/overview_widget.html",
@@ -266,9 +47,9 @@ async def list_hosts():
async with current_app.db_sessionmaker() as db:
result = await db.execute(select(Host).order_by(Host.name))
hosts = result.scalars().all()
host_status = await _host_status(db, [h.id for h in hosts])
uptime = await _compute_uptime(db)
agent_metrics = await _agent_metrics_by_host(db, [h.name for h in hosts])
host_status = await host_svc.host_status(db, [h.id for h in hosts])
uptime = await host_svc.compute_uptime(db)
agent_metrics = await host_svc.agent_metrics_by_host(db, [h.name for h in hosts])
return await render_template(
"hosts/list.html",
@@ -291,8 +72,8 @@ async def host_detail(host_id: str):
select(Host).where(Host.id == host_id))).scalar_one_or_none()
if host is None:
return "Not found", 404
monitors = await _host_monitors(db, host_id)
uptime = (await _compute_uptime(db)).get(host_id)
monitors = await host_svc.host_monitors(db, host_id)
uptime = (await host_svc.compute_uptime(db)).get(host_id)
linked_target = (await db.execute(
select(AnsibleTarget).where(AnsibleTarget.host_id == host_id)
.options(selectinload(AnsibleTarget.groups)))).scalar_one_or_none()
@@ -500,7 +281,7 @@ async def uptime_page():
async with current_app.db_sessionmaker() as db:
result = await db.execute(select(Host).order_by(Host.name))
hosts = result.scalars().all()
uptime = await _compute_uptime(db)
uptime = await host_svc.compute_uptime(db)
return await render_template("hosts/uptime.html", hosts=hosts, uptime=uptime)
@@ -511,7 +292,7 @@ async def uptime_widget():
async with current_app.db_sessionmaker() as db:
result = await db.execute(select(Host).order_by(Host.name))
hosts = result.scalars().all()
uptime = await _compute_uptime(db)
uptime = await host_svc.compute_uptime(db)
return await render_template(
"hosts/uptime_widget.html",
hosts=hosts,
+238
View File
@@ -0,0 +1,238 @@
"""Host queries shared by the Hosts pages, dashboard widgets and MCP tools.
Every function takes an open session and returns plain data; none reads
Quart's request context, so the web routes and the /mcp tools (which run
outside Quart's pipeline) call the same code.
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from sqlalchemy import and_, case, func, select
from steward.core.status import _heartbeat_title, _last_n_results, _uptime_by_monitor
from steward.models.monitors import Monitor, MonitorResult
def agent_stale_after_seconds(config) -> int:
"""The host_agent plugin's freshness window from app config (default 180s)."""
return int(((config.get("PLUGINS") or {}).get("host_agent") or {})
.get("stale_after_seconds", 180))
async def compute_uptime(db) -> dict[str, dict]:
"""Per-host uptime % for 24h/7d/30d, aggregated over the host's monitors.
Returns {host_id: {"24h": float|None, "7d": float|None, "30d": float|None}}.
"""
now = datetime.now(timezone.utc)
cutoff_30d = now - timedelta(days=30)
cutoff_7d = now - timedelta(days=7)
cutoff_24h = now - timedelta(hours=24)
result = await db.execute(
select(
Monitor.host_id,
func.count(MonitorResult.id).label("total_30d"),
func.sum(case((MonitorResult.is_up.is_(True), 1), else_=0)).label("up_30d"),
func.sum(case((MonitorResult.checked_at >= cutoff_7d, 1), else_=0)).label("total_7d"),
func.sum(case(
(and_(MonitorResult.checked_at >= cutoff_7d, MonitorResult.is_up.is_(True)), 1),
else_=0,
)).label("up_7d"),
func.sum(case((MonitorResult.checked_at >= cutoff_24h, 1), else_=0)).label("total_24h"),
func.sum(case(
(and_(MonitorResult.checked_at >= cutoff_24h, MonitorResult.is_up.is_(True)), 1),
else_=0,
)).label("up_24h"),
)
.join(Monitor, Monitor.id == MonitorResult.monitor_id)
.where(Monitor.host_id.isnot(None), MonitorResult.checked_at >= cutoff_30d)
.group_by(Monitor.host_id)
)
def _pct(up, total):
return round(float(up) / float(total) * 100, 2) if total else None
stats: dict[str, dict] = {}
for row in result:
stats[row.host_id] = {
"24h": _pct(row.up_24h, row.total_24h),
"7d": _pct(row.up_7d, row.total_7d),
"30d": _pct(row.up_30d, row.total_30d),
}
return stats
async def host_status(db, host_ids: list[str]) -> dict[str, dict]:
"""Per-host status rollup from the latest result of each linked monitor.
{host_id: {"state": "up"|"down"|"pending", "latency_ms": float|None,
"count": int}}. State is down if ANY linked monitor's latest
result is down (worst-case), up if at least one is up, else pending.
"""
if not host_ids:
return {}
rn = func.row_number().over(
partition_by=MonitorResult.monitor_id, order_by=MonitorResult.checked_at.desc()
).label("rn")
subq = (
select(MonitorResult.id, rn)
.join(Monitor, Monitor.id == MonitorResult.monitor_id)
.where(Monitor.host_id.in_(host_ids), Monitor.enabled.is_(True))
.subquery()
)
rows = (await db.execute(
select(MonitorResult, Monitor.host_id)
.join(subq, MonitorResult.id == subq.c.id)
.join(Monitor, Monitor.id == MonitorResult.monitor_id)
.where(subq.c.rn == 1)
)).all()
out: dict[str, dict] = {}
for res, host_id in rows:
d = out.setdefault(host_id, {"state": "pending", "latency_ms": None, "count": 0})
d["count"] += 1
if not res.is_up:
d["state"] = "down"
elif d["state"] != "down":
d["state"] = "up"
if res.is_up and res.response_ms is not None:
d["latency_ms"] = (res.response_ms if d["latency_ms"] is None
else min(d["latency_ms"], res.response_ms))
return out
async def host_monitors(db, host_id: str) -> list[dict]:
"""Display rows for the monitors linked to one host (host hub section)."""
monitors = (await db.execute(
select(Monitor).where(Monitor.host_id == host_id).order_by(Monitor.name)
)).scalars().all()
ids = [m.id for m in monitors]
recent = await _last_n_results(db, ids)
uptime = await _uptime_by_monitor(db, ids)
data = []
for m in monitors:
mrows = recent.get(m.id, [])
latest = mrows[-1] if mrows else None
data.append({
"monitor": m, "latest": latest,
"heartbeat": [{"state": "up" if r.is_up else "down",
"title": _heartbeat_title(m.type, r)} for r in mrows],
"uptime_pct": uptime.get(m.id, {}).get("24h"),
})
return data
async def agent_metrics_by_host(db, host_names: list[str]) -> dict[str, dict]:
"""Latest agent cpu/mem per host name from the generic PluginMetric table.
Read directly (PluginMetric is a core model) so the Hosts list can show an
at-a-glance agent column without importing the host_agent plugin.
"""
from steward.models.metrics import PluginMetric
if not host_names:
return {}
wanted = ("cpu_pct", "mem_used_pct")
subq = (
select(
PluginMetric.resource_name, PluginMetric.metric_name,
func.max(PluginMetric.recorded_at).label("m"),
)
.where(
PluginMetric.source_module == "host_agent",
PluginMetric.metric_name.in_(wanted),
PluginMetric.resource_name.in_(host_names),
)
.group_by(PluginMetric.resource_name, PluginMetric.metric_name)
).subquery()
rows = (await db.execute(
select(PluginMetric).join(
subq,
(PluginMetric.resource_name == subq.c.resource_name)
& (PluginMetric.metric_name == subq.c.metric_name)
& (PluginMetric.recorded_at == subq.c.m),
)
)).scalars().all()
out: dict[str, dict] = {}
for r in rows:
out.setdefault(r.resource_name, {})[r.metric_name] = r.value
return out
async def agent_overview_by_host(
db, host_names: list[str], stale_after_seconds: int = 180,
) -> dict[str, dict]:
"""Per-host agent glance (cpu/mem/root-disk + freshness) from PluginMetric.
Core-safe (no host_agent import). Freshness compares the latest host-level
sample against the host_agent plugin's stale window (stale_after_seconds;
callers pass agent_stale_after_seconds(app.config)).
"""
from steward.models.metrics import PluginMetric
if not host_names:
return {}
now = datetime.now(timezone.utc)
stale_after = int(stale_after_seconds)
resources = list(host_names) + [h + ":/" for h in host_names]
subq = (
select(
PluginMetric.resource_name, PluginMetric.metric_name,
func.max(PluginMetric.recorded_at).label("m"),
)
.where(
PluginMetric.source_module == "host_agent",
PluginMetric.resource_name.in_(resources),
PluginMetric.metric_name.in_(("cpu_pct", "mem_used_pct", "disk_used_pct")),
)
.group_by(PluginMetric.resource_name, PluginMetric.metric_name)
).subquery()
rows = (await db.execute(
select(PluginMetric).join(
subq,
(PluginMetric.resource_name == subq.c.resource_name)
& (PluginMetric.metric_name == subq.c.metric_name)
& (PluginMetric.recorded_at == subq.c.m),
).where(PluginMetric.source_module == "host_agent")
)).scalars().all()
out: dict[str, dict] = {}
for r in rows:
if r.resource_name.endswith(":/") and r.metric_name == "disk_used_pct":
out.setdefault(r.resource_name[:-2], {})["disk_root"] = r.value
elif r.resource_name in host_names:
d = out.setdefault(r.resource_name, {})
d[r.metric_name] = r.value
if d.get("_ts") is None or r.recorded_at > d["_ts"]:
d["_ts"] = r.recorded_at
for d in out.values():
ts = d.pop("_ts", None)
d["fresh"] = bool(ts and (now - ts).total_seconds() <= stale_after)
return out
async def agent_cpu_sparks_by_host(db, host_names: list[str], hours: int = 1) -> dict[str, str]:
"""{host_name: inline-SVG cpu sparkline} over the last `hours` (core-safe).
A tiny at-a-glance trend in each row — the host-view sparkline, on the widget.
"""
from steward.models.metrics import PluginMetric
from steward.core.status import sparkline_svg
if not host_names:
return {}
cutoff = datetime.now(timezone.utc) - timedelta(hours=hours)
rows = (await db.execute(
select(PluginMetric.resource_name, PluginMetric.value, PluginMetric.recorded_at)
.where(
PluginMetric.source_module == "host_agent",
PluginMetric.metric_name == "cpu_pct",
PluginMetric.resource_name.in_(host_names),
PluginMetric.recorded_at >= cutoff,
)
.order_by(PluginMetric.recorded_at)
)).all()
by_host: dict[str, list[float]] = {}
for r in rows:
by_host.setdefault(r.resource_name, []).append(r.value)
# Amber line to read as "CPU"; only draw when there are a couple of points.
return {
name: sparkline_svg(vals[-40:], width=64, height=18, stroke="#c8a840")
for name, vals in by_host.items() if len(vals) >= 2
}
+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