a7a281cb11
First-party plugins (host_agent, http, snmp, traefik, unifi, docker) are now tracked under plugins/ and baked into the image, so they version atomically with core — ending the cross-repo import drift the roundtable->steward rename exposed. History for these files is preserved in the archived Roundtable-plugins repo. Plugin discovery becomes multi-root: PLUGIN_DIR (single) -> PLUGIN_DIRS (bundled first, then external) + PLUGIN_INSTALL_DIR. Bundled ships in the image; third-party plugins still mount at runtime into the external root (STEWARD_PLUGIN_DIR, default /data/plugins) and downloads/installs land there. Bundled shadows external on a name collision. - config.py: load_bootstrap returns plugin_dirs + plugin_install_dir - app.py: iterate PLUGIN_DIRS at the migration + load sites - migration_runner.py: discover_all_in() unions every plugin root - plugin_manager.py: resolve_plugin_path() (pure, first-root-wins); load / install / hot-reload span all roots; installs target the external root - settings/routes.py: _discover_plugins scans all roots, dedup bundled-first - Dockerfile: COPY plugins/ ; docker-compose: drop host bind, document external - tests/test_plugin_dirs.py: resolution, multi-root discovery, bootstrap split Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
162 lines
5.3 KiB
Python
162 lines
5.3 KiB
Python
# plugins/docker/routes.py
|
|
from __future__ import annotations
|
|
import json
|
|
from datetime import datetime, timezone
|
|
from quart import Blueprint, current_app, render_template, request
|
|
from sqlalchemy import Integer, cast, func, select
|
|
|
|
from fabledscryer.auth.middleware import require_role
|
|
from fabledscryer.models.users import UserRole
|
|
from fabledscryer.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,
|
|
)
|