feat: add http, snmp, docker plugins; new widgets and routes for traefik/unifi/ups
- Add HTTP monitor plugin: endpoint checking with status history, latency tracking, time-range views - Add SNMP plugin: OID polling, device management, metric recording - Add Docker plugin: container status, resource usage, widget - Traefik: access log widget, request chart widget, expanded routes - UniFi: clients widget, devices widget, expanded poll routes - UPS: history widget, additional routes - Update plugin index with new entries Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
# plugins/docker/__init__.py
|
||||
from __future__ import annotations
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from quart import Quart
|
||||
|
||||
_app: "Quart | None" = None
|
||||
|
||||
|
||||
def setup(app: "Quart") -> None:
|
||||
global _app
|
||||
_app = app
|
||||
from .models import DockerContainer, DockerMetric # noqa: F401 — register with Base
|
||||
|
||||
|
||||
def get_scheduled_tasks() -> list:
|
||||
from .scheduler import make_task
|
||||
return [make_task(_app)]
|
||||
|
||||
|
||||
def get_blueprint():
|
||||
from .routes import docker_bp
|
||||
return docker_bp
|
||||
@@ -0,0 +1,70 @@
|
||||
# plugins/docker/migrations/env.py
|
||||
"""Alembic env.py for the Docker plugin."""
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from logging.config import fileConfig
|
||||
|
||||
from sqlalchemy import pool
|
||||
from sqlalchemy.engine import Connection
|
||||
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||
from alembic import context
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent))
|
||||
|
||||
from fabledscryer.models.base import Base
|
||||
import fabledscryer.models # noqa: F401
|
||||
from plugins.docker.models import DockerContainer, DockerMetric # noqa: F401
|
||||
|
||||
config = context.config
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def _get_url() -> str:
|
||||
import yaml
|
||||
cfg_path = os.environ.get("FABLEDSCRYER_CONFIG", "config.yaml")
|
||||
try:
|
||||
with open(cfg_path) as f:
|
||||
cfg = yaml.safe_load(f) or {}
|
||||
url = cfg.get("database", {}).get("url", "")
|
||||
except FileNotFoundError:
|
||||
url = ""
|
||||
return os.environ.get("FABLEDSCRYER_DATABASE__URL", url)
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
context.configure(
|
||||
url=_get_url(), target_metadata=target_metadata,
|
||||
literal_binds=True, dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def do_run_migrations(connection: Connection) -> None:
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
async def run_async_migrations() -> None:
|
||||
cfg = config.get_section(config.config_ini_section, {})
|
||||
cfg["sqlalchemy.url"] = _get_url()
|
||||
connectable = async_engine_from_config(cfg, prefix="sqlalchemy.", poolclass=pool.NullPool)
|
||||
async with connectable.connect() as connection:
|
||||
await connection.run_sync(do_run_migrations)
|
||||
await connectable.dispose()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
asyncio.run(run_async_migrations())
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Docker plugin initial tables
|
||||
|
||||
Revision ID: docker_001_initial
|
||||
Revises: (none — branch off core via depends_on)
|
||||
Create Date: 2026-03-22
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = "docker_001_initial"
|
||||
down_revision: Union[str, None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = "docker"
|
||||
depends_on: Union[str, Sequence[str], None] = ("0004_app_settings",)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"docker_containers",
|
||||
sa.Column("name", sa.String(255), primary_key=True),
|
||||
sa.Column("container_id", sa.String(64), nullable=False, server_default=""),
|
||||
sa.Column("image", sa.String(512), nullable=False, server_default=""),
|
||||
sa.Column("status", sa.String(32), nullable=False, server_default="unknown"),
|
||||
sa.Column("cpu_pct", sa.Float, nullable=True),
|
||||
sa.Column("mem_usage_bytes", sa.Integer, nullable=True),
|
||||
sa.Column("mem_limit_bytes", sa.Integer, nullable=True),
|
||||
sa.Column("mem_pct", sa.Float, nullable=True),
|
||||
sa.Column("restart_count", sa.Integer, nullable=False, server_default="0"),
|
||||
sa.Column("ports_json", sa.Text, nullable=False, server_default="[]"),
|
||||
sa.Column("started_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("scraped_at", sa.DateTime(timezone=True), nullable=False),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"docker_metrics",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("container_name", sa.String(255), nullable=False, index=True),
|
||||
sa.Column("scraped_at", sa.DateTime(timezone=True), nullable=False, index=True),
|
||||
sa.Column("cpu_pct", sa.Float, nullable=False, server_default="0"),
|
||||
sa.Column("mem_pct", sa.Float, nullable=False, server_default="0"),
|
||||
sa.Column("mem_usage_bytes", sa.Integer, nullable=False, server_default="0"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("docker_metrics")
|
||||
op.drop_table("docker_containers")
|
||||
@@ -0,0 +1,49 @@
|
||||
# plugins/docker/models.py
|
||||
from __future__ import annotations
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy import DateTime, Float, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from fabledscryer.models.base import Base
|
||||
|
||||
|
||||
class DockerContainer(Base):
|
||||
"""Latest known state per container — upserted by name on each scrape."""
|
||||
__tablename__ = "docker_containers"
|
||||
|
||||
# Container name is the stable natural key; container_id changes on recreation
|
||||
name: Mapped[str] = mapped_column(String(255), primary_key=True)
|
||||
container_id: Mapped[str] = mapped_column(String(64), nullable=False, default="")
|
||||
image: Mapped[str] = mapped_column(String(512), nullable=False, default="")
|
||||
status: Mapped[str] = mapped_column(String(32), nullable=False, default="unknown")
|
||||
# running | stopped | paused | exited | dead
|
||||
cpu_pct: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
mem_usage_bytes: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
mem_limit_bytes: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
mem_pct: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
restart_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
ports_json: Mapped[str] = mapped_column(Text, nullable=False, default="[]")
|
||||
# JSON: [{"host_port": 8080, "container_port": 80, "protocol": "tcp"}]
|
||||
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
scraped_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False,
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
|
||||
class DockerMetric(Base):
|
||||
"""Time-series CPU/memory per container — one row per scrape per running container."""
|
||||
__tablename__ = "docker_metrics"
|
||||
|
||||
id: Mapped[str] = mapped_column(
|
||||
String(36), primary_key=True, default=lambda: str(uuid.uuid4())
|
||||
)
|
||||
container_name: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
scraped_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False,
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
index=True,
|
||||
)
|
||||
cpu_pct: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
|
||||
mem_pct: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
|
||||
mem_usage_bytes: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
@@ -0,0 +1,17 @@
|
||||
name: docker
|
||||
version: "1.0.0"
|
||||
description: "Docker container status, resource usage, restart tracking via Docker socket"
|
||||
author: "FabledScryer"
|
||||
license: "MIT"
|
||||
min_app_version: "0.1.0"
|
||||
repository_url: "https://git.fabledsword.com/bvandeusen/FabledScryer-plugins"
|
||||
homepage: "https://git.fabledsword.com/bvandeusen/FabledScryer-plugins/src/branch/main/docker"
|
||||
tags:
|
||||
- containers
|
||||
- docker
|
||||
- infrastructure
|
||||
|
||||
config:
|
||||
socket_path: /var/run/docker.sock
|
||||
scrape_interval_seconds: 60
|
||||
include_stopped: false
|
||||
@@ -0,0 +1,161 @@
|
||||
# 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,
|
||||
)
|
||||
@@ -0,0 +1,93 @@
|
||||
# plugins/docker/scheduler.py
|
||||
from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fabledscryer.core.scheduler import ScheduledTask
|
||||
from fabledscryer.core.alerts import record_metric
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def make_task(app) -> ScheduledTask:
|
||||
interval = int(
|
||||
app.config["PLUGINS"]["docker"].get("scrape_interval_seconds", 60)
|
||||
)
|
||||
|
||||
async def scrape():
|
||||
await _do_scrape(app)
|
||||
|
||||
return ScheduledTask(
|
||||
name="docker_scrape",
|
||||
coro_factory=scrape,
|
||||
interval_seconds=interval,
|
||||
run_on_startup=True,
|
||||
)
|
||||
|
||||
|
||||
async def _do_scrape(app) -> None:
|
||||
from .scraper import scrape_docker
|
||||
from .models import DockerContainer, DockerMetric
|
||||
|
||||
cfg = app.config["PLUGINS"]["docker"]
|
||||
socket_path = cfg.get("socket_path", "/var/run/docker.sock")
|
||||
include_stopped = bool(cfg.get("include_stopped", False))
|
||||
|
||||
try:
|
||||
containers = await scrape_docker(socket_path, include_stopped)
|
||||
except ConnectionError as exc:
|
||||
logger.error("Docker scrape failed: %s", exc)
|
||||
return
|
||||
except Exception:
|
||||
logger.exception("Docker scrape error")
|
||||
return
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
async with app.db_sessionmaker() as session:
|
||||
async with session.begin():
|
||||
for c in containers:
|
||||
# Upsert container state
|
||||
existing = await session.get(DockerContainer, c["name"])
|
||||
if existing is None:
|
||||
existing = DockerContainer(name=c["name"])
|
||||
session.add(existing)
|
||||
existing.container_id = c["container_id"]
|
||||
existing.image = c["image"]
|
||||
existing.status = c["status"]
|
||||
existing.cpu_pct = c["cpu_pct"]
|
||||
existing.mem_usage_bytes = c["mem_usage_bytes"]
|
||||
existing.mem_limit_bytes = c["mem_limit_bytes"]
|
||||
existing.mem_pct = c["mem_pct"]
|
||||
existing.restart_count = c["restart_count"]
|
||||
existing.ports_json = json.dumps(c["ports"])
|
||||
existing.started_at = c["started_at"]
|
||||
existing.scraped_at = now
|
||||
|
||||
# Time-series metric (running containers only)
|
||||
if c["status"] == "running" and c["cpu_pct"] is not None:
|
||||
session.add(DockerMetric(
|
||||
container_name=c["name"],
|
||||
scraped_at=now,
|
||||
cpu_pct=c["cpu_pct"],
|
||||
mem_pct=c["mem_pct"] or 0.0,
|
||||
mem_usage_bytes=c["mem_usage_bytes"] or 0,
|
||||
))
|
||||
|
||||
# Feed alert pipeline
|
||||
await record_metric(
|
||||
session=session,
|
||||
source_module="docker",
|
||||
resource_name=c["name"],
|
||||
metric_name="cpu_pct",
|
||||
value=c["cpu_pct"],
|
||||
)
|
||||
if c["mem_pct"] is not None:
|
||||
await record_metric(
|
||||
session=session,
|
||||
source_module="docker",
|
||||
resource_name=c["name"],
|
||||
metric_name="mem_pct",
|
||||
value=c["mem_pct"],
|
||||
)
|
||||
@@ -0,0 +1,140 @@
|
||||
# plugins/docker/scraper.py
|
||||
"""Docker API client via Unix socket."""
|
||||
from __future__ import annotations
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _short_id(container_id: str) -> str:
|
||||
return container_id[:12] if container_id else ""
|
||||
|
||||
|
||||
def _calc_cpu_pct(stats: dict) -> float:
|
||||
"""Calculate CPU % from a Docker stats snapshot (one-shot)."""
|
||||
try:
|
||||
cpu = stats.get("cpu_stats", {})
|
||||
precpu = stats.get("precpu_stats", {})
|
||||
cpu_delta = (
|
||||
cpu["cpu_usage"]["total_usage"] - precpu["cpu_usage"]["total_usage"]
|
||||
)
|
||||
sys_delta = cpu.get("system_cpu_usage", 0) - precpu.get("system_cpu_usage", 0)
|
||||
num_cpus = cpu.get("online_cpus") or len(
|
||||
cpu["cpu_usage"].get("percpu_usage") or [None]
|
||||
)
|
||||
if sys_delta <= 0 or cpu_delta < 0:
|
||||
return 0.0
|
||||
return round((cpu_delta / sys_delta) * num_cpus * 100.0, 2)
|
||||
except (KeyError, TypeError, ZeroDivisionError):
|
||||
return 0.0
|
||||
|
||||
|
||||
def _calc_mem(stats: dict) -> tuple[int, int, float]:
|
||||
"""Return (usage_bytes, limit_bytes, mem_pct) from Docker stats."""
|
||||
try:
|
||||
mem = stats.get("memory_stats", {})
|
||||
usage = mem.get("usage", 0)
|
||||
limit = mem.get("limit", 0)
|
||||
# Subtract page cache for working set (cgroup v1: "cache", v2: "inactive_file")
|
||||
cache = mem.get("stats", {}).get("cache", 0) or \
|
||||
mem.get("stats", {}).get("inactive_file", 0)
|
||||
actual = max(0, usage - cache)
|
||||
pct = round(actual / limit * 100.0, 2) if limit > 0 else 0.0
|
||||
return actual, limit, pct
|
||||
except (KeyError, TypeError):
|
||||
return 0, 0, 0.0
|
||||
|
||||
|
||||
def _parse_ports(ports: list) -> list[dict]:
|
||||
"""Normalise Docker port bindings to a compact list."""
|
||||
result = []
|
||||
for p in (ports or []):
|
||||
if p.get("PublicPort"):
|
||||
result.append({
|
||||
"host_port": p["PublicPort"],
|
||||
"container_port": p["PrivatePort"],
|
||||
"protocol": p.get("Type", "tcp"),
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
async def scrape_docker(socket_path: str, include_stopped: bool) -> list[dict]:
|
||||
"""
|
||||
Fetch all containers + resource stats from the Docker daemon.
|
||||
Returns a list of normalised dicts ready for the scheduler to persist.
|
||||
Raises ConnectionError if the socket is unreachable.
|
||||
"""
|
||||
transport = httpx.AsyncHTTPTransport(uds=socket_path)
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport,
|
||||
base_url="http://docker",
|
||||
timeout=10.0,
|
||||
) as client:
|
||||
resp = await client.get(
|
||||
"/containers/json",
|
||||
params={"all": "true" if include_stopped else "false"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
containers = resp.json()
|
||||
|
||||
async def _get_stats(c: dict) -> tuple[dict, dict | None]:
|
||||
if c.get("State") != "running":
|
||||
return c, None
|
||||
try:
|
||||
r = await client.get(
|
||||
f"/containers/{c['Id']}/stats",
|
||||
params={"stream": "false", "one-shot": "true"},
|
||||
timeout=5.0,
|
||||
)
|
||||
return c, r.json()
|
||||
except Exception as exc:
|
||||
logger.debug(
|
||||
"Stats unavailable for %s: %s", _short_id(c.get("Id", "")), exc
|
||||
)
|
||||
return c, None
|
||||
|
||||
pairs = await asyncio.gather(*[_get_stats(c) for c in containers])
|
||||
|
||||
except httpx.ConnectError as exc:
|
||||
raise ConnectionError(
|
||||
f"Cannot connect to Docker socket at {socket_path}: {exc}"
|
||||
) from exc
|
||||
|
||||
results = []
|
||||
now = datetime.now(timezone.utc)
|
||||
for container, stats in pairs:
|
||||
names = container.get("Names") or []
|
||||
name = names[0].lstrip("/") if names else _short_id(container.get("Id", ""))
|
||||
|
||||
cpu_pct = mem_usage = mem_limit = mem_pct = None
|
||||
if stats is not None:
|
||||
cpu_pct = _calc_cpu_pct(stats)
|
||||
mem_usage, mem_limit, mem_pct = _calc_mem(stats)
|
||||
|
||||
# Created is a Unix epoch int from /containers/json
|
||||
created_ts = container.get("Created")
|
||||
started_at = (
|
||||
datetime.fromtimestamp(created_ts, tz=timezone.utc)
|
||||
if isinstance(created_ts, (int, float)) else None
|
||||
)
|
||||
|
||||
results.append({
|
||||
"name": name,
|
||||
"container_id": _short_id(container.get("Id", "")),
|
||||
"image": container.get("Image", ""),
|
||||
"status": container.get("State", "unknown"),
|
||||
"cpu_pct": cpu_pct,
|
||||
"mem_usage_bytes": mem_usage,
|
||||
"mem_limit_bytes": mem_limit,
|
||||
"mem_pct": mem_pct,
|
||||
"restart_count": 0, # would require /inspect; left as 0 for now
|
||||
"ports": _parse_ports(container.get("Ports", [])),
|
||||
"started_at": started_at,
|
||||
})
|
||||
|
||||
return results
|
||||
@@ -0,0 +1,16 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Docker — Fabled Scryer{% endblock %}
|
||||
{% block content %}
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.5rem;gap:1rem;flex-wrap:wrap;">
|
||||
<h1 class="page-title" style="margin-bottom:0;">Docker</h1>
|
||||
{% include "_time_range.html" %}
|
||||
</div>
|
||||
|
||||
<div id="docker-rows"
|
||||
hx-get="/plugins/docker/rows"
|
||||
hx-trigger="load, rangeChange from:body"
|
||||
hx-include="#time-range"
|
||||
hx-swap="innerHTML">
|
||||
<div style="color:var(--text-muted);font-size:0.9rem;padding:2rem;">Loading...</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,86 @@
|
||||
{# docker/rows.html — HTMX fragment for the Docker main page #}
|
||||
|
||||
{# ── Summary strip ─────────────────────────────────────────────────────────── #}
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:0.75rem;margin-bottom:1.5rem;">
|
||||
<div class="card" style="margin-bottom:0;">
|
||||
<div class="section-title" style="margin-bottom:0.35rem;">Running</div>
|
||||
<span class="stat-val" style="color:var(--green);">{{ running }}</span>
|
||||
</div>
|
||||
<div class="card" style="margin-bottom:0;">
|
||||
<div class="section-title" style="margin-bottom:0.35rem;">Stopped</div>
|
||||
<span class="stat-val" style="{% if stopped %}color:var(--text-muted){% endif %};">{{ stopped }}</span>
|
||||
</div>
|
||||
<div class="card" style="margin-bottom:0;">
|
||||
<div class="section-title" style="margin-bottom:0.35rem;">Total</div>
|
||||
<span class="stat-val">{{ container_data | length }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Container table ─────────────────────────────────────────────────────── #}
|
||||
{% if container_data %}
|
||||
<div class="card-flush">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Container</th>
|
||||
<th>Image</th>
|
||||
<th>Ports</th>
|
||||
<th>CPU %</th>
|
||||
<th style="min-width:100px;">CPU history</th>
|
||||
<th>Mem %</th>
|
||||
<th style="min-width:100px;">Mem history</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in container_data %}
|
||||
{% set c = item.container %}
|
||||
<tr>
|
||||
<td>
|
||||
<div style="display:flex;align-items:center;gap:0.5rem;">
|
||||
<span class="dot {% if c.status == 'running' %}dot-up{% elif c.status == 'paused' %}dot-warn{% else %}dot-down{% endif %}"></span>
|
||||
<div>
|
||||
<div style="font-weight:500;font-size:0.9rem;">{{ c.name }}</div>
|
||||
<div style="font-size:0.73rem;color:var(--text-muted);">{{ c.status }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td style="font-size:0.82rem;color:var(--text-muted);max-width:200px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">
|
||||
{{ c.image }}
|
||||
</td>
|
||||
<td style="font-size:0.78rem;font-family:ui-monospace,monospace;">
|
||||
{% for p in item.ports %}
|
||||
<div style="color:var(--text-muted);">{{ p.host_port }}:{{ p.container_port }}/{{ p.protocol }}</div>
|
||||
{% endfor %}
|
||||
</td>
|
||||
<td style="font-family:ui-monospace,monospace;font-size:0.88rem;">
|
||||
{% if c.cpu_pct is not none %}
|
||||
<span style="color:{% if c.cpu_pct > 80 %}var(--red){% elif c.cpu_pct > 50 %}var(--orange){% else %}var(--text){% endif %};">
|
||||
{{ "%.1f" | format(c.cpu_pct) }}%
|
||||
</span>
|
||||
{% else %}
|
||||
<span style="color:var(--text-dim);">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ item.sparkline_cpu | safe }}</td>
|
||||
<td style="font-family:ui-monospace,monospace;font-size:0.88rem;">
|
||||
{% if c.mem_pct is not none %}
|
||||
<span style="color:{% if c.mem_pct > 90 %}var(--red){% elif c.mem_pct > 70 %}var(--orange){% else %}var(--text){% endif %};">
|
||||
{{ "%.1f" | format(c.mem_pct) }}%
|
||||
</span>
|
||||
{% else %}
|
||||
<span style="color:var(--text-dim);">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ item.sparkline_mem | safe }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card" style="text-align:center;padding:3rem;">
|
||||
<div style="color:var(--text-muted);font-size:0.9rem;">
|
||||
No containers found. Make sure the Docker socket is accessible and the plugin is configured correctly.
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,35 @@
|
||||
{# docker/widget.html — dashboard widget: container status overview #}
|
||||
{% if not containers and running_count == 0 and stopped_count == 0 %}
|
||||
<div style="color:var(--text-muted);font-size:0.85rem;padding:0.5rem 0;">No containers found.</div>
|
||||
{% else %}
|
||||
|
||||
<div style="display:flex;gap:1rem;margin-bottom:0.65rem;">
|
||||
<div>
|
||||
<span style="font-size:1.4rem;font-weight:700;line-height:1;color:var(--green);">{{ running_count }}</span>
|
||||
<span style="font-size:0.78rem;color:var(--text-muted);margin-left:0.25rem;">running</span>
|
||||
</div>
|
||||
{% if stopped_count %}
|
||||
<div>
|
||||
<span style="font-size:1.4rem;font-weight:700;line-height:1;color:var(--text-muted);">{{ stopped_count }}</span>
|
||||
<span style="font-size:0.78rem;color:var(--text-muted);margin-left:0.25rem;">stopped</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div style="display:grid;gap:2px;">
|
||||
{% for c in containers %}
|
||||
<div style="display:flex;align-items:center;gap:0.5rem;font-size:0.82rem;padding:0.15rem 0;">
|
||||
<span class="dot {% if c.status == 'running' %}dot-up{% elif c.status == 'paused' %}dot-warn{% else %}dot-down{% endif %}"></span>
|
||||
<span style="flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">
|
||||
{{ c.name }}
|
||||
</span>
|
||||
{% if c.cpu_pct is not none %}
|
||||
<span style="font-size:0.72rem;color:var(--text-muted);font-family:ui-monospace,monospace;flex-shrink:0;">
|
||||
{{ "%.1f" | format(c.cpu_pct) }}%
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% endif %}
|
||||
@@ -0,0 +1,36 @@
|
||||
{# docker/widget_resources.html — dashboard widget: CPU + memory bars for running containers #}
|
||||
{% if not containers %}
|
||||
<div style="color:var(--text-muted);font-size:0.85rem;padding:0.5rem 0;">No running containers.</div>
|
||||
{% else %}
|
||||
<div style="display:grid;gap:0.5rem;">
|
||||
{% for c in containers %}
|
||||
<div>
|
||||
<div style="display:flex;justify-content:space-between;align-items:baseline;margin-bottom:0.15rem;">
|
||||
<span style="font-size:0.8rem;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:55%;">
|
||||
{{ c.name }}
|
||||
</span>
|
||||
<span style="font-size:0.72rem;color:var(--text-muted);font-family:ui-monospace,monospace;flex-shrink:0;margin-left:0.5rem;">
|
||||
{% if c.cpu_pct is not none %}CPU {{ "%.1f" | format(c.cpu_pct) }}%{% endif %}
|
||||
{% if c.mem_pct is not none %} · Mem {{ "%.1f" | format(c.mem_pct) }}%{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
{% if c.cpu_pct is not none %}
|
||||
<div style="height:3px;background:var(--bg-elevated);border-radius:2px;margin-bottom:2px;">
|
||||
<div style="height:100%;border-radius:2px;width:{{ [c.cpu_pct, 100] | min }}%;
|
||||
background:{% if c.cpu_pct > 80 %}var(--red){% elif c.cpu_pct > 50 %}var(--orange){% else %}var(--accent){% endif %};
|
||||
transition:width 0.4s;">
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if c.mem_pct is not none %}
|
||||
<div style="height:3px;background:var(--bg-elevated);border-radius:2px;">
|
||||
<div style="height:100%;border-radius:2px;width:{{ [c.mem_pct, 100] | min }}%;
|
||||
background:{% if c.mem_pct > 90 %}var(--red){% elif c.mem_pct > 70 %}var(--orange){% else %}var(--green){% endif %};
|
||||
transition:width 0.4s;">
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
Reference in New Issue
Block a user