# plugins/docker/routes.py from __future__ import annotations import json from quart import Blueprint, current_app, render_template, request from sqlalchemy import Integer, cast, func, select from steward.auth.middleware import require_role from steward.models.users import UserRole from steward.core.time_range import parse_range, DEFAULT_RANGE, bucket_seconds from .models import DockerContainer, DockerMetric docker_bp = Blueprint("docker", __name__, template_folder="templates") def _sparkline(values: list[float], width: int = 80, height: int = 20) -> str: if len(values) < 2: return f'' mn, mx = min(values), max(values) if mx == mn: mx = mn + 1.0 step = width / (len(values) - 1) pts = [] for i, v in enumerate(values): x = i * step y = height - (v - mn) / (mx - mn) * (height - 2) - 1 pts.append(f"{x:.1f},{y:.1f}") poly = " ".join(pts) return ( f'' f'' f'' ) @docker_bp.get("/") @require_role(UserRole.viewer) async def index(): poll_interval = current_app.config.get("MONITORS_POLL_INTERVAL", 60) current_range = request.args.get("range", DEFAULT_RANGE) return await render_template( "docker/index.html", poll_interval=poll_interval, current_range=current_range, ) @docker_bp.get("/rows") @require_role(UserRole.viewer) async def rows(): """HTMX fragment: container list with status and resource sparklines.""" since, range_key = parse_range(request.args.get("range")) b_secs = bucket_seconds(since) bucket_col = ( cast(func.strftime('%s', DockerMetric.scraped_at), Integer) / b_secs ).label("bucket") async with current_app.db_sessionmaker() as db: # All known containers ordered by running first, then name result = await db.execute( select(DockerContainer) .order_by( # running first (DockerContainer.status == "running").desc(), DockerContainer.name, ) ) containers = list(result.scalars()) # Build per-container sparkline histories histories: dict[str, list] = {} for c in containers: result = await db.execute( select( func.avg(DockerMetric.cpu_pct).label("cpu_pct"), func.avg(DockerMetric.mem_pct).label("mem_pct"), bucket_col, ) .where(DockerMetric.container_name == c.name) .where(DockerMetric.scraped_at >= since) .group_by(bucket_col) .order_by(bucket_col) ) histories[c.name] = result.all() running = sum(1 for c in containers if c.status == "running") stopped = len(containers) - running container_data = [] for c in containers: hist = histories.get(c.name, []) ports = json.loads(c.ports_json) if c.ports_json else [] container_data.append({ "container": c, "ports": ports, "sparkline_cpu": _sparkline([r.cpu_pct or 0 for r in hist]), "sparkline_mem": _sparkline([r.mem_pct or 0 for r in hist]), }) return await render_template( "docker/rows.html", container_data=container_data, running=running, stopped=stopped, range_key=range_key, ) @docker_bp.get("/widget") @require_role(UserRole.viewer) async def widget(): """HTMX dashboard widget: container status overview.""" show_stopped = request.args.get("show_stopped", "no") == "yes" widget_id = request.args.get("wid", "0") async with current_app.db_sessionmaker() as db: result = await db.execute( select(DockerContainer) .order_by( (DockerContainer.status == "running").desc(), DockerContainer.name, ) ) all_containers = list(result.scalars()) running = [c for c in all_containers if c.status == "running"] stopped = [c for c in all_containers if c.status != "running"] display = all_containers if show_stopped else running return await render_template( "docker/widget.html", containers=display, running_count=len(running), stopped_count=len(stopped), show_stopped=show_stopped, widget_id=widget_id, ) @docker_bp.get("/widget/resources") @require_role(UserRole.viewer) async def widget_resources(): """HTMX dashboard widget: CPU + memory usage for running containers.""" limit = max(1, min(20, int(request.args.get("limit", 10) or 10))) widget_id = request.args.get("wid", "0") async with current_app.db_sessionmaker() as db: result = await db.execute( select(DockerContainer) .where(DockerContainer.status == "running") .order_by(DockerContainer.cpu_pct.desc().nullslast()) ) containers = list(result.scalars())[:limit] return await render_template( "docker/widget_resources.html", containers=containers, widget_id=widget_id, )