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>
100 lines
3.8 KiB
Python
100 lines
3.8 KiB
Python
"""Docker queries shared by the plugin's pages and its MCP tools.
|
|
|
|
Each function takes an open session and returns rows or plain data, 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.hosts import Host
|
|
|
|
from .models import DockerContainer, DockerEvent, DockerLog, DockerMetric
|
|
|
|
|
|
def bucket(values: list[float], target: int = 40) -> list[float]:
|
|
"""Bucket-average a series down to ~target points (DB-agnostic, in Python).
|
|
|
|
Replaces the old SQL strftime() bucketing, which was SQLite-only and broke
|
|
on Postgres. Agent cadence is dense, so a multi-hour window is hundreds of
|
|
rows — averaging into a readable point count keeps the sparkline's shape.
|
|
"""
|
|
n = len(values)
|
|
if n <= target:
|
|
return values
|
|
size = (n + target - 1) // target
|
|
out: list[float] = []
|
|
for i in range(0, n, size):
|
|
chunk = values[i:i + size]
|
|
out.append(sum(chunk) / len(chunk))
|
|
return out
|
|
|
|
|
|
async def container_history(db, host_id: str, name: str, since) -> tuple[list, list]:
|
|
"""Return (cpu_series, mem_series) sparkline-ready for one host's container."""
|
|
rows = (await db.execute(
|
|
select(DockerMetric.cpu_pct, DockerMetric.mem_pct)
|
|
.where(DockerMetric.host_id == host_id)
|
|
.where(DockerMetric.container_name == name)
|
|
.where(DockerMetric.scraped_at >= since)
|
|
.order_by(DockerMetric.scraped_at)
|
|
)).all()
|
|
cpu = bucket([r.cpu_pct or 0.0 for r in rows])
|
|
mem = bucket([r.mem_pct or 0.0 for r in rows])
|
|
return cpu, mem
|
|
|
|
|
|
async def host_map(db, host_ids: set[str]) -> dict[str, Host]:
|
|
if not host_ids:
|
|
return {}
|
|
return {
|
|
h.id: h for h in (await db.execute(
|
|
select(Host).where(Host.id.in_(host_ids)))).scalars().all()
|
|
}
|
|
|
|
|
|
def escape_like(text: str) -> str:
|
|
"""Make `text` match literally inside a LIKE/ILIKE pattern (escape char \\)."""
|
|
return text.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
|
|
|
|
|
async def list_containers(db, host_id: str | None = None) -> list[DockerContainer]:
|
|
"""Containers, running first then by name; one host's when host_id is given."""
|
|
stmt = select(DockerContainer)
|
|
if host_id is not None:
|
|
stmt = stmt.where(DockerContainer.host_id == host_id)
|
|
stmt = stmt.order_by((DockerContainer.status == "running").desc(), DockerContainer.name)
|
|
return list((await db.execute(stmt)).scalars())
|
|
|
|
|
|
async def container_events(db, host_id: str, name: str, limit: int = 50) -> list[DockerEvent]:
|
|
"""A container's lifecycle events (start/stop/die/oom/health), newest first."""
|
|
return list((await db.execute(
|
|
select(DockerEvent)
|
|
.where(DockerEvent.host_id == host_id)
|
|
.where(DockerEvent.container_name == name)
|
|
.order_by(DockerEvent.at.desc())
|
|
.limit(limit)
|
|
)).scalars())
|
|
|
|
|
|
async def query_logs(db, host_id: str, name: str, stream: str, query: str,
|
|
limit: int) -> list:
|
|
"""The recent retained lines for one container, newest-first, optionally
|
|
filtered by stream and a case-insensitive substring.
|
|
|
|
Newest-first is deliberate: the viewer replaces the list on each poll, which
|
|
resets scroll to the top — so the latest lines stay visible without any
|
|
scroll handling, and older lines are a scroll away.
|
|
"""
|
|
stmt = (select(DockerLog.ts, DockerLog.stream, DockerLog.line)
|
|
.where(DockerLog.host_id == host_id)
|
|
.where(DockerLog.container_name == name))
|
|
if stream in ("stdout", "stderr"):
|
|
stmt = stmt.where(DockerLog.stream == stream)
|
|
if query:
|
|
stmt = stmt.where(DockerLog.line.ilike(f"%{escape_like(query)}%", escape="\\"))
|
|
stmt = stmt.order_by(DockerLog.ts.desc(), DockerLog.id.desc()).limit(limit)
|
|
return (await db.execute(stmt)).all()
|