Files
FabledSteward/plugins/docker/routes.py
T
bvandeusen af60ca446d
CI / lint (push) Successful in 3s
CI / unit (push) Successful in 7s
CI / integration (push) Successful in 2m8s
fix(plugins): complete steward rename across bundled plugins; clear lint debt
The fabledscryer->steward rename had only ever reached host_agent. The other
five bundled plugins (http, snmp, traefik, unifi, docker) still imported
`from fabledscryer.*` (package no longer exists) and read FABLEDSCRYER_* env
vars — so every one of them was broken at import since the original rebrand.
CI stayed green only because none are enabled by default and migrations don't
import plugin modules. Now that they version in-tree, complete the rename:
- fabledscryer.* -> steward.* imports across all five plugins
- FABLEDSCRYER_* -> STEWARD_* in plugin migration env.py files
- author/repository/homepage + user-facing 'Fabled Scryer' strings -> Steward
- snmp/scheduler.py: also drop dead `now`/datetime; record_metric from steward

Adds tests/test_no_legacy_names.py — fails if 'scryer'/'roundtable' ever
reappear in shipped code (the drift bit twice; this stops a third time).

Also clears pre-existing ruff lint debt (unused imports, semicolon statements,
mid-file import) surfaced by the new lint lane.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 11:22:20 -04:00

161 lines
5.3 KiB
Python

# 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'<svg width="{width}" height="{height}"></svg>'
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'<svg width="{width}" height="{height}" viewBox="0 0 {width} {height}" '
f'style="vertical-align:middle;">'
f'<polyline points="{poly}" fill="none" stroke="#6060c0" stroke-width="1.5"/>'
f'</svg>'
)
@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,
)