"""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()