diff --git a/__init__.py b/__init__.py
new file mode 100644
index 0000000..1648f8e
--- /dev/null
+++ b/__init__.py
@@ -0,0 +1 @@
+# plugins/__init__.py
diff --git a/docker/__init__.py b/docker/__init__.py
new file mode 100644
index 0000000..d569556
--- /dev/null
+++ b/docker/__init__.py
@@ -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
diff --git a/docker/migrations/__init__.py b/docker/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/docker/migrations/env.py b/docker/migrations/env.py
new file mode 100644
index 0000000..5c20755
--- /dev/null
+++ b/docker/migrations/env.py
@@ -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()
diff --git a/docker/migrations/versions/__init__.py b/docker/migrations/versions/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/docker/migrations/versions/docker_001_initial.py b/docker/migrations/versions/docker_001_initial.py
new file mode 100644
index 0000000..90e5298
--- /dev/null
+++ b/docker/migrations/versions/docker_001_initial.py
@@ -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")
diff --git a/docker/models.py b/docker/models.py
new file mode 100644
index 0000000..09c1cf3
--- /dev/null
+++ b/docker/models.py
@@ -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)
diff --git a/docker/plugin.yaml b/docker/plugin.yaml
new file mode 100644
index 0000000..8745302
--- /dev/null
+++ b/docker/plugin.yaml
@@ -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
diff --git a/docker/routes.py b/docker/routes.py
new file mode 100644
index 0000000..12d0383
--- /dev/null
+++ b/docker/routes.py
@@ -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''
+ 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''
+ )
+
+
+@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,
+ )
diff --git a/docker/scheduler.py b/docker/scheduler.py
new file mode 100644
index 0000000..eef8c61
--- /dev/null
+++ b/docker/scheduler.py
@@ -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"],
+ )
diff --git a/docker/scraper.py b/docker/scraper.py
new file mode 100644
index 0000000..96f601b
--- /dev/null
+++ b/docker/scraper.py
@@ -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
diff --git a/docker/templates/docker/index.html b/docker/templates/docker/index.html
new file mode 100644
index 0000000..42c9bb5
--- /dev/null
+++ b/docker/templates/docker/index.html
@@ -0,0 +1,16 @@
+{% extends "base.html" %}
+{% block title %}Docker — Fabled Scryer{% endblock %}
+{% block content %}
+
+
Docker
+ {% include "_time_range.html" %}
+
+
+
+{% endblock %}
diff --git a/docker/templates/docker/rows.html b/docker/templates/docker/rows.html
new file mode 100644
index 0000000..3b6c84e
--- /dev/null
+++ b/docker/templates/docker/rows.html
@@ -0,0 +1,86 @@
+{# docker/rows.html — HTMX fragment for the Docker main page #}
+
+{# ── Summary strip ─────────────────────────────────────────────────────────── #}
+
+
+
Running
+
{{ running }}
+
+
+
Stopped
+
{{ stopped }}
+
+
+
Total
+
{{ container_data | length }}
+
+
+
+{# ── Container table ─────────────────────────────────────────────────────── #}
+{% if container_data %}
+
+
+
+
+ | Container |
+ Image |
+ Ports |
+ CPU % |
+ CPU history |
+ Mem % |
+ Mem history |
+
+
+
+ {% for item in container_data %}
+ {% set c = item.container %}
+
+
+
+
+
+ {{ c.name }}
+ {{ c.status }}
+
+
+ |
+
+ {{ c.image }}
+ |
+
+ {% for p in item.ports %}
+ {{ p.host_port }}:{{ p.container_port }}/{{ p.protocol }}
+ {% endfor %}
+ |
+
+ {% if c.cpu_pct is not none %}
+
+ {{ "%.1f" | format(c.cpu_pct) }}%
+
+ {% else %}
+ —
+ {% endif %}
+ |
+ {{ item.sparkline_cpu | safe }} |
+
+ {% if c.mem_pct is not none %}
+
+ {{ "%.1f" | format(c.mem_pct) }}%
+
+ {% else %}
+ —
+ {% endif %}
+ |
+ {{ item.sparkline_mem | safe }} |
+
+ {% endfor %}
+
+
+
+{% else %}
+
+
+ No containers found. Make sure the Docker socket is accessible and the plugin is configured correctly.
+
+
+{% endif %}
diff --git a/docker/templates/docker/widget.html b/docker/templates/docker/widget.html
new file mode 100644
index 0000000..427018f
--- /dev/null
+++ b/docker/templates/docker/widget.html
@@ -0,0 +1,35 @@
+{# docker/widget.html — dashboard widget: container status overview #}
+{% if not containers and running_count == 0 and stopped_count == 0 %}
+No containers found.
+{% else %}
+
+
+
+ {{ running_count }}
+ running
+
+ {% if stopped_count %}
+
+ {{ stopped_count }}
+ stopped
+
+ {% endif %}
+
+
+
+ {% for c in containers %}
+
+
+
+ {{ c.name }}
+
+ {% if c.cpu_pct is not none %}
+
+ {{ "%.1f" | format(c.cpu_pct) }}%
+
+ {% endif %}
+
+ {% endfor %}
+
+
+{% endif %}
diff --git a/docker/templates/docker/widget_resources.html b/docker/templates/docker/widget_resources.html
new file mode 100644
index 0000000..39c90dc
--- /dev/null
+++ b/docker/templates/docker/widget_resources.html
@@ -0,0 +1,36 @@
+{# docker/widget_resources.html — dashboard widget: CPU + memory bars for running containers #}
+{% if not containers %}
+No running containers.
+{% else %}
+
+ {% for c in containers %}
+
+
+
+ {{ c.name }}
+
+
+ {% 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 %}
+
+
+ {% if c.cpu_pct is not none %}
+
+ {% endif %}
+ {% if c.mem_pct is not none %}
+
+ {% endif %}
+
+ {% endfor %}
+
+{% endif %}
diff --git a/http/__init__.py b/http/__init__.py
new file mode 100644
index 0000000..cb79685
--- /dev/null
+++ b/http/__init__.py
@@ -0,0 +1,24 @@
+# plugins/http/__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 HttpMonitor, HttpResult # noqa: F401
+
+
+def get_scheduled_tasks() -> list:
+ from .scheduler import make_task
+ return [make_task(_app)]
+
+
+def get_blueprint():
+ from .routes import http_bp
+ return http_bp
diff --git a/http/checker.py b/http/checker.py
new file mode 100644
index 0000000..7eec743
--- /dev/null
+++ b/http/checker.py
@@ -0,0 +1,105 @@
+# plugins/http/checker.py
+"""Core HTTP check logic — runs a single monitor check and returns a result dict."""
+from __future__ import annotations
+import asyncio
+import json
+import logging
+import ssl
+import time
+from datetime import datetime, timezone
+from urllib.parse import urlparse
+
+import httpx
+
+logger = logging.getLogger(__name__)
+
+
+async def _get_tls_expiry(hostname: str, port: int) -> datetime | None:
+ """Attempt a bare TLS handshake to extract the certificate expiry date."""
+ ctx = ssl.create_default_context()
+ try:
+ reader, writer = await asyncio.wait_for(
+ asyncio.open_connection(hostname, port, ssl=ctx),
+ timeout=5.0,
+ )
+ cert = writer.get_extra_info("ssl_object").getpeercert()
+ writer.close()
+ try:
+ await writer.wait_closed()
+ except Exception:
+ pass
+ exp_str = cert.get("notAfter", "")
+ if not exp_str:
+ return None
+ # Format: "Mar 22 12:00:00 2027 GMT"
+ return datetime.strptime(exp_str, "%b %d %H:%M:%S %Y %Z").replace(
+ tzinfo=timezone.utc
+ )
+ except Exception as exc:
+ logger.debug("TLS check failed for %s:%d — %s", hostname, port, exc)
+ return None
+
+
+async def run_check(
+ url: str,
+ method: str = "GET",
+ expected_status: int = 200,
+ content_match: str = "",
+ headers: dict | None = None,
+ timeout_seconds: int = 10,
+ follow_redirects: bool = True,
+ verify_ssl: bool = True,
+) -> dict:
+ """
+ Perform one HTTP check. Returns a dict with:
+ is_up, status_code, response_ms, content_matched, error_msg, tls_expires_at
+ """
+ result: dict = {
+ "is_up": False,
+ "status_code": None,
+ "response_ms": None,
+ "content_matched": None,
+ "error_msg": None,
+ "tls_expires_at": None,
+ }
+
+ parsed = urlparse(url)
+ is_https = parsed.scheme.lower() == "https"
+
+ t0 = time.monotonic()
+ try:
+ async with httpx.AsyncClient(
+ follow_redirects=follow_redirects,
+ verify=verify_ssl,
+ timeout=timeout_seconds,
+ ) as client:
+ response = await client.request(
+ method,
+ url,
+ headers=headers or {},
+ )
+ result["response_ms"] = round((time.monotonic() - t0) * 1000, 1)
+ result["status_code"] = response.status_code
+
+ status_ok = response.status_code == expected_status
+ if content_match:
+ matched = content_match in response.text
+ result["content_matched"] = matched
+ result["is_up"] = status_ok and matched
+ else:
+ result["is_up"] = status_ok
+
+ except httpx.TimeoutException:
+ result["response_ms"] = round((time.monotonic() - t0) * 1000, 1)
+ result["error_msg"] = "Timeout"
+ except httpx.ConnectError as exc:
+ result["error_msg"] = f"Connection error: {exc}"
+ except Exception as exc:
+ result["error_msg"] = str(exc)[:512]
+
+ # TLS expiry — only for HTTPS, and only when the check succeeded or we got a response
+ if is_https and result["status_code"] is not None:
+ port = parsed.port or 443
+ result["tls_expires_at"] = await _get_tls_expiry(parsed.hostname, port)
+
+ return result
diff --git a/http/migrations/__init__.py b/http/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/http/migrations/env.py b/http/migrations/env.py
new file mode 100644
index 0000000..fd308a2
--- /dev/null
+++ b/http/migrations/env.py
@@ -0,0 +1,70 @@
+# plugins/http/migrations/env.py
+"""Alembic env.py for the HTTP monitoring 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.http.models import HttpMonitor, HttpResult # 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()
diff --git a/http/migrations/versions/__init__.py b/http/migrations/versions/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/http/migrations/versions/http_001_initial.py b/http/migrations/versions/http_001_initial.py
new file mode 100644
index 0000000..c546023
--- /dev/null
+++ b/http/migrations/versions/http_001_initial.py
@@ -0,0 +1,52 @@
+"""HTTP monitoring plugin initial tables
+
+Revision ID: http_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 = "http_001_initial"
+down_revision: Union[str, None] = None
+branch_labels: Union[str, Sequence[str], None] = "http"
+depends_on: Union[str, Sequence[str], None] = ("0004_app_settings",)
+
+
+def upgrade() -> None:
+ op.create_table(
+ "http_monitors",
+ sa.Column("id", sa.String(36), primary_key=True),
+ sa.Column("name", sa.String(128), nullable=False),
+ sa.Column("url", sa.String(2048), nullable=False),
+ sa.Column("method", sa.String(8), nullable=False, server_default="GET"),
+ sa.Column("expected_status", sa.Integer, nullable=False, server_default="200"),
+ sa.Column("content_match", sa.String(512), nullable=False, server_default=""),
+ sa.Column("headers_json", sa.Text, nullable=False, server_default="{}"),
+ sa.Column("timeout_seconds", sa.Integer, nullable=False, server_default="10"),
+ sa.Column("check_interval_seconds", sa.Integer, nullable=False, server_default="0"),
+ sa.Column("follow_redirects", sa.Boolean, nullable=False, server_default="1"),
+ sa.Column("verify_ssl", sa.Boolean, nullable=False, server_default="1"),
+ sa.Column("enabled", sa.Boolean, nullable=False, server_default="1"),
+ sa.Column("last_checked_at", sa.DateTime(timezone=True), nullable=True),
+ sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
+ )
+
+ op.create_table(
+ "http_results",
+ sa.Column("id", sa.String(36), primary_key=True),
+ sa.Column("monitor_id", sa.String(36), nullable=False, index=True),
+ sa.Column("checked_at", sa.DateTime(timezone=True), nullable=False, index=True),
+ sa.Column("status_code", sa.Integer, nullable=True),
+ sa.Column("response_ms", sa.Float, nullable=True),
+ sa.Column("is_up", sa.Boolean, nullable=False, server_default="0"),
+ sa.Column("content_matched", sa.Boolean, nullable=True),
+ sa.Column("error_msg", sa.String(512), nullable=True),
+ sa.Column("tls_expires_at", sa.DateTime(timezone=True), nullable=True),
+ )
+
+
+def downgrade() -> None:
+ op.drop_table("http_results")
+ op.drop_table("http_monitors")
diff --git a/http/models.py b/http/models.py
new file mode 100644
index 0000000..180effd
--- /dev/null
+++ b/http/models.py
@@ -0,0 +1,63 @@
+# plugins/http/models.py
+from __future__ import annotations
+import uuid
+from datetime import datetime, timezone
+from sqlalchemy import Boolean, DateTime, Float, Integer, String, Text
+from sqlalchemy.orm import Mapped, mapped_column
+from fabledscryer.models.base import Base
+
+
+class HttpMonitor(Base):
+ """A configured HTTP endpoint to check periodically."""
+ __tablename__ = "http_monitors"
+
+ id: Mapped[str] = mapped_column(
+ String(36), primary_key=True, default=lambda: str(uuid.uuid4())
+ )
+ name: Mapped[str] = mapped_column(String(128), nullable=False)
+ url: Mapped[str] = mapped_column(String(2048), nullable=False)
+ method: Mapped[str] = mapped_column(String(8), nullable=False, default="GET")
+ expected_status: Mapped[int] = mapped_column(Integer, nullable=False, default=200)
+ # Optional substring to find in the response body (empty = skip check)
+ content_match: Mapped[str] = mapped_column(String(512), nullable=False, default="")
+ # JSON dict of extra request headers, e.g. {"Authorization": "Bearer ..."}
+ headers_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
+ timeout_seconds: Mapped[int] = mapped_column(Integer, nullable=False, default=10)
+ # 0 = use global plugin check_interval_seconds
+ check_interval_seconds: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
+ follow_redirects: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
+ verify_ssl: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
+ enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
+ # Updated after each check so the scheduler can compute next-run time efficiently
+ last_checked_at: Mapped[datetime | None] = mapped_column(
+ DateTime(timezone=True), nullable=True
+ )
+ created_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True), nullable=False,
+ default=lambda: datetime.now(timezone.utc),
+ )
+
+
+class HttpResult(Base):
+ """One check result for an HttpMonitor."""
+ __tablename__ = "http_results"
+
+ id: Mapped[str] = mapped_column(
+ String(36), primary_key=True, default=lambda: str(uuid.uuid4())
+ )
+ monitor_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
+ checked_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True), nullable=False,
+ default=lambda: datetime.now(timezone.utc),
+ index=True,
+ )
+ status_code: Mapped[int | None] = mapped_column(Integer, nullable=True)
+ response_ms: Mapped[float | None] = mapped_column(Float, nullable=True)
+ is_up: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
+ # None when no content_match is configured; True/False when it is
+ content_matched: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
+ error_msg: Mapped[str | None] = mapped_column(String(512), nullable=True)
+ # TLS expiry if the endpoint is HTTPS; None for HTTP or on error
+ tls_expires_at: Mapped[datetime | None] = mapped_column(
+ DateTime(timezone=True), nullable=True
+ )
diff --git a/http/plugin.yaml b/http/plugin.yaml
new file mode 100644
index 0000000..9aa1475
--- /dev/null
+++ b/http/plugin.yaml
@@ -0,0 +1,19 @@
+name: http
+version: "1.0.0"
+description: "Synthetic HTTP endpoint monitoring — status code, response time, content match, TLS expiry"
+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/http"
+tags:
+ - monitoring
+ - http
+ - synthetic
+ - uptime
+
+config:
+ check_interval_seconds: 60
+ default_timeout_seconds: 10
+ follow_redirects: true
+ verify_ssl: true
diff --git a/http/routes.py b/http/routes.py
new file mode 100644
index 0000000..eea5ce7
--- /dev/null
+++ b/http/routes.py
@@ -0,0 +1,229 @@
+# plugins/http/routes.py
+from __future__ import annotations
+import json
+import uuid
+from datetime import datetime, timezone
+from quart import Blueprint, current_app, render_template, request, redirect, url_for
+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 HttpMonitor, HttpResult
+
+http_bp = Blueprint("http", __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''
+ )
+
+
+async def _render_rows(range_key: str = DEFAULT_RANGE):
+ since, range_key = parse_range(range_key)
+ b_secs = bucket_seconds(since)
+ bucket_col = (
+ cast(func.extract('epoch', HttpResult.checked_at), Integer) / b_secs
+ ).label("bucket")
+
+ async with current_app.db_sessionmaker() as db:
+ result = await db.execute(
+ select(HttpMonitor).order_by(HttpMonitor.created_at)
+ )
+ monitors = list(result.scalars())
+
+ # Latest result per monitor
+ latest_map: dict[str, HttpResult] = {}
+ histories: dict[str, list] = {}
+ for m in monitors:
+ r = await db.execute(
+ select(HttpResult)
+ .where(HttpResult.monitor_id == m.id)
+ .order_by(HttpResult.checked_at.desc())
+ .limit(1)
+ )
+ latest = r.scalar_one_or_none()
+ if latest:
+ latest_map[m.id] = latest
+
+ r2 = await db.execute(
+ select(
+ func.avg(HttpResult.response_ms).label("response_ms"),
+ func.min(HttpResult.is_up.cast(Integer)).label("had_down"),
+ bucket_col,
+ )
+ .where(HttpResult.monitor_id == m.id)
+ .where(HttpResult.checked_at >= since)
+ .group_by(bucket_col)
+ .order_by(bucket_col)
+ )
+ histories[m.id] = r2.all()
+
+ monitor_data = []
+ for m in monitors:
+ hist = histories.get(m.id, [])
+ latest = latest_map.get(m.id)
+ now = datetime.now(timezone.utc)
+ tls_days = None
+ if latest and latest.tls_expires_at:
+ tls_days = (latest.tls_expires_at - now).total_seconds() / 86400.0
+ monitor_data.append({
+ "monitor": m,
+ "latest": latest,
+ "tls_days": tls_days,
+ "sparkline_ms": _sparkline([r.response_ms or 0 for r in hist]),
+ })
+
+ up = sum(1 for d in monitor_data if d["latest"] and d["latest"].is_up)
+ down = sum(1 for d in monitor_data if d["latest"] and not d["latest"].is_up)
+ return monitor_data, up, down, range_key
+
+
+@http_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(
+ "http/index.html",
+ poll_interval=poll_interval,
+ current_range=current_range,
+ )
+
+
+@http_bp.get("/rows")
+@require_role(UserRole.viewer)
+async def rows():
+ """HTMX fragment: monitor list with status + sparklines."""
+ monitor_data, up, down, range_key = await _render_rows(
+ request.args.get("range", DEFAULT_RANGE)
+ )
+ return await render_template(
+ "http/rows.html",
+ monitor_data=monitor_data,
+ up=up,
+ down=down,
+ range_key=range_key,
+ )
+
+
+@http_bp.post("/add")
+@require_role(UserRole.operator)
+async def add_monitor():
+ """Add a new HTTP monitor."""
+ form = await request.form
+ url = form.get("url", "").strip()
+ if not url:
+ return redirect(url_for("http.index"))
+ if not url.startswith(("http://", "https://")):
+ url = "https://" + url
+
+ monitor = HttpMonitor(
+ id=str(uuid.uuid4()),
+ name=form.get("name", "").strip() or url,
+ url=url,
+ method=form.get("method", "GET").upper(),
+ expected_status=int(form.get("expected_status", 200) or 200),
+ content_match=form.get("content_match", "").strip(),
+ headers_json="{}",
+ timeout_seconds=int(form.get("timeout_seconds", 10) or 10),
+ check_interval_seconds=int(form.get("check_interval_seconds", 0) or 0),
+ follow_redirects="follow_redirects" in form,
+ verify_ssl="verify_ssl" in form,
+ enabled=True,
+ created_at=datetime.now(timezone.utc),
+ )
+ async with current_app.db_sessionmaker() as db:
+ async with db.begin():
+ db.add(monitor)
+
+ return redirect(url_for("http.index"))
+
+
+@http_bp.post("//delete")
+@require_role(UserRole.operator)
+async def delete_monitor(monitor_id: str):
+ async with current_app.db_sessionmaker() as db:
+ async with db.begin():
+ m = await db.get(HttpMonitor, monitor_id)
+ if m:
+ await db.delete(m)
+ return redirect(url_for("http.index"))
+
+
+@http_bp.post("//toggle")
+@require_role(UserRole.operator)
+async def toggle_monitor(monitor_id: str):
+ async with current_app.db_sessionmaker() as db:
+ async with db.begin():
+ m = await db.get(HttpMonitor, monitor_id)
+ if m:
+ m.enabled = not m.enabled
+ return redirect(url_for("http.index"))
+
+
+@http_bp.get("/widget")
+@require_role(UserRole.viewer)
+async def widget():
+ """HTMX dashboard widget: up/down counts + monitor list."""
+ show_down_only = request.args.get("show_down_only", "no") == "yes"
+ 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(HttpMonitor)
+ .where(HttpMonitor.enabled == True) # noqa: E712
+ .order_by(HttpMonitor.created_at)
+ )
+ monitors = list(result.scalars())
+
+ latest_map: dict[str, HttpResult] = {}
+ for m in monitors:
+ r = await db.execute(
+ select(HttpResult)
+ .where(HttpResult.monitor_id == m.id)
+ .order_by(HttpResult.checked_at.desc())
+ .limit(1)
+ )
+ latest = r.scalar_one_or_none()
+ if latest:
+ latest_map[m.id] = latest
+
+ monitor_data = [
+ {"monitor": m, "latest": latest_map.get(m.id)}
+ for m in monitors
+ ]
+
+ up = sum(1 for d in monitor_data if d["latest"] and d["latest"].is_up)
+ down = sum(1 for d in monitor_data if d["latest"] and not d["latest"].is_up)
+ pending = sum(1 for d in monitor_data if not d["latest"])
+
+ if show_down_only:
+ monitor_data = [d for d in monitor_data if not (d["latest"] and d["latest"].is_up)]
+
+ return await render_template(
+ "http/widget.html",
+ monitor_data=monitor_data[:limit],
+ up=up,
+ down=down,
+ pending=pending,
+ show_down_only=show_down_only,
+ widget_id=widget_id,
+ )
diff --git a/http/scheduler.py b/http/scheduler.py
new file mode 100644
index 0000000..f9edd67
--- /dev/null
+++ b/http/scheduler.py
@@ -0,0 +1,121 @@
+# plugins/http/scheduler.py
+from __future__ import annotations
+import asyncio
+import json
+import logging
+from datetime import datetime, timezone, timedelta
+
+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"]["http"].get("check_interval_seconds", 60)
+ )
+
+ async def check():
+ await _do_checks(app)
+
+ return ScheduledTask(
+ name="http_check",
+ coro_factory=check,
+ interval_seconds=interval,
+ run_on_startup=True,
+ )
+
+
+async def _do_checks(app) -> None:
+ from sqlalchemy import select
+ from .models import HttpMonitor, HttpResult
+ from .checker import run_check
+
+ cfg = app.config["PLUGINS"]["http"]
+ global_interval = int(cfg.get("check_interval_seconds", 60))
+ global_timeout = int(cfg.get("default_timeout_seconds", 10))
+ global_follow = bool(cfg.get("follow_redirects", True))
+ global_verify = bool(cfg.get("verify_ssl", True))
+
+ now = datetime.now(timezone.utc)
+
+ async with app.db_sessionmaker() as session:
+ result = await session.execute(
+ select(HttpMonitor).where(HttpMonitor.enabled == True) # noqa: E712
+ )
+ monitors = list(result.scalars())
+
+ # Filter to monitors whose next check time has passed
+ due: list[HttpMonitor] = []
+ for m in monitors:
+ effective_interval = m.check_interval_seconds or global_interval
+ if m.last_checked_at is None:
+ due.append(m)
+ elif (now - m.last_checked_at).total_seconds() >= effective_interval:
+ due.append(m)
+
+ if not due:
+ return
+
+ async def _check_one(monitor: HttpMonitor) -> tuple[HttpMonitor, dict]:
+ try:
+ headers = json.loads(monitor.headers_json or "{}")
+ except (ValueError, TypeError):
+ headers = {}
+ result = await run_check(
+ url=monitor.url,
+ method=monitor.method,
+ expected_status=monitor.expected_status,
+ content_match=monitor.content_match,
+ headers=headers,
+ timeout_seconds=monitor.timeout_seconds or global_timeout,
+ follow_redirects=monitor.follow_redirects if monitor.follow_redirects is not None else global_follow,
+ verify_ssl=monitor.verify_ssl if monitor.verify_ssl is not None else global_verify,
+ )
+ return monitor, result
+
+ pairs = await asyncio.gather(*[_check_one(m) for m in due], return_exceptions=True)
+
+ check_time = datetime.now(timezone.utc)
+
+ async with app.db_sessionmaker() as session:
+ async with session.begin():
+ for item in pairs:
+ if isinstance(item, Exception):
+ logger.error("HTTP check task error: %s", item)
+ continue
+ monitor, res = item
+
+ session.add(HttpResult(
+ monitor_id=monitor.id,
+ checked_at=check_time,
+ status_code=res["status_code"],
+ response_ms=res["response_ms"],
+ is_up=res["is_up"],
+ content_matched=res["content_matched"],
+ error_msg=res["error_msg"],
+ tls_expires_at=res["tls_expires_at"],
+ ))
+
+ # Update monitor's last_checked_at for next-run scheduling
+ db_monitor = await session.get(HttpMonitor, monitor.id)
+ if db_monitor:
+ db_monitor.last_checked_at = check_time
+
+ # Alert pipeline
+ if res["response_ms"] is not None:
+ await record_metric(
+ session=session,
+ source_module="http",
+ resource_name=monitor.name,
+ metric_name="response_ms",
+ value=res["response_ms"],
+ )
+ await record_metric(
+ session=session,
+ source_module="http",
+ resource_name=monitor.name,
+ metric_name="is_up",
+ value=1.0 if res["is_up"] else 0.0,
+ )
diff --git a/http/templates/http/index.html b/http/templates/http/index.html
new file mode 100644
index 0000000..6d1ffd0
--- /dev/null
+++ b/http/templates/http/index.html
@@ -0,0 +1,77 @@
+{% extends "base.html" %}
+{% block title %}HTTP Monitors — Fabled Scryer{% endblock %}
+{% block content %}
+
+
HTTP Monitors
+ {% include "_time_range.html" %}
+
+
+{# ── Add monitor form ─────────────────────────────────────────────────────── #}
+
+
+{# ── Monitor list ─────────────────────────────────────────────────────────── #}
+
+{% endblock %}
diff --git a/http/templates/http/rows.html b/http/templates/http/rows.html
new file mode 100644
index 0000000..4666fa3
--- /dev/null
+++ b/http/templates/http/rows.html
@@ -0,0 +1,109 @@
+{# http/rows.html — HTMX fragment for HTTP monitor status list #}
+
+{# ── Summary ─────────────────────────────────────────────────────────────── #}
+
+
+
+
+
Monitors
+
{{ monitor_data | length }}
+
+
+
+{# ── Monitor list ─────────────────────────────────────────────────────────── #}
+{% if monitor_data %}
+
+
+
+
+ | Monitor |
+ Status |
+ Response |
+ History |
+ TLS expiry |
+ |
+
+
+
+ {% for item in monitor_data %}
+ {% set m = item.monitor %}
+ {% set latest = item.latest %}
+
+ |
+ {{ m.name }}
+
+ {{ m.method }} {{ m.url }}
+
+ {% if not m.enabled %}
+ paused
+ {% endif %}
+ |
+
+ {% if not latest %}
+ pending
+ {% elif latest.is_up %}
+
+
+ {{ latest.status_code }}
+
+ {% else %}
+
+
+
+ {{ latest.status_code or latest.error_msg or "error" }}
+
+
+ {% if latest.content_matched == false %}
+ content mismatch
+ {% endif %}
+ {% endif %}
+ |
+
+ {% if latest and latest.response_ms is not none %}
+
+ {{ "%.0f" | format(latest.response_ms) }}ms
+
+ {% else %}
+ —
+ {% endif %}
+ |
+ {{ item.sparkline_ms | safe }} |
+
+ {% if item.tls_days is not none %}
+
+ {{ item.tls_days | int }}d
+
+ {% elif m.url.startswith('https://') %}
+ —
+ {% endif %}
+ |
+
+
+
+ |
+
+ {% endfor %}
+
+
+
+{% else %}
+
+
+ No monitors configured yet. Add one using the form above.
+
+
+{% endif %}
diff --git a/http/templates/http/widget.html b/http/templates/http/widget.html
new file mode 100644
index 0000000..891f40b
--- /dev/null
+++ b/http/templates/http/widget.html
@@ -0,0 +1,56 @@
+{# http/widget.html — dashboard widget: HTTP monitor summary #}
+{% if not monitor_data and up == 0 and down == 0 and pending == 0 %}
+
+{% else %}
+
+
+ {% if up %}
+
+ {{ up }}
+ up
+
+ {% endif %}
+ {% if down %}
+
+ {{ down }}
+ down
+
+ {% endif %}
+ {% if pending %}
+
+ {{ pending }}
+ pending
+
+ {% endif %}
+
+
+
+ {% for item in monitor_data %}
+ {% set latest = item.latest %}
+
+ {% if not latest %}
+
+ {% elif latest.is_up %}
+
+ {% else %}
+
+ {% endif %}
+
+ {{ item.monitor.name }}
+
+ {% if latest and latest.response_ms is not none %}
+
+ {{ "%.0f" | format(latest.response_ms) }}ms
+
+ {% elif latest and not latest.is_up %}
+
+ {{ latest.status_code or "err" }}
+
+ {% endif %}
+
+ {% endfor %}
+
+
+{% endif %}
diff --git a/index.yaml b/index.yaml
index 1d59b6f..0192afe 100644
--- a/index.yaml
+++ b/index.yaml
@@ -13,6 +13,37 @@ updated: "2026-03-22"
plugins:
+ - name: http
+ version: "1.0.0"
+ description: "Synthetic HTTP endpoint monitoring — status code, response time, content match, TLS expiry"
+ 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/http"
+ download_url: "https://git.fabledsword.com/bvandeusen/FabledScryer-plugins/releases/download/http-v1.0.0/http.zip"
+ checksum_sha256: ""
+ tags:
+ - monitoring
+ - http
+ - synthetic
+ - uptime
+
+ - name: docker
+ version: "1.0.0"
+ description: "Docker container status, resource usage, and 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"
+ download_url: "https://git.fabledsword.com/bvandeusen/FabledScryer-plugins/releases/download/docker-v1.0.0/docker.zip"
+ checksum_sha256: ""
+ tags:
+ - containers
+ - docker
+ - infrastructure
+
- name: traefik
version: "1.0.0"
description: "Traefik reverse proxy metrics and access log integration"
diff --git a/snmp/__init__.py b/snmp/__init__.py
new file mode 100644
index 0000000..8655884
--- /dev/null
+++ b/snmp/__init__.py
@@ -0,0 +1,23 @@
+# plugins/snmp/__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
+
+
+def get_scheduled_tasks() -> list:
+ from .scheduler import make_poll_task
+ return [make_poll_task(_app)]
+
+
+def get_blueprint():
+ from .routes import snmp_bp
+ return snmp_bp
diff --git a/snmp/plugin.yaml b/snmp/plugin.yaml
new file mode 100644
index 0000000..c947bbd
--- /dev/null
+++ b/snmp/plugin.yaml
@@ -0,0 +1,30 @@
+# plugins/snmp/plugin.yaml
+name: snmp
+version: "1.0.0"
+description: "SNMP polling for network devices — switches, routers, printers, UPSes, etc."
+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/snmp"
+tags:
+ - snmp
+ - network
+ - monitoring
+
+config:
+ poll_interval_seconds: 60
+ # Each device is polled independently. OIDs must resolve to numeric values.
+ devices:
+ - name: "core-switch"
+ host: "192.168.1.1"
+ port: 161
+ community: "public"
+ version: "2c" # "1", "2c", or "3"
+ oids:
+ - oid: "1.3.6.1.2.1.1.3.0"
+ label: "uptime_centisecs"
+ - oid: "1.3.6.1.2.1.2.2.1.10.1"
+ label: "if1_in_octets"
+ - oid: "1.3.6.1.2.1.2.2.1.16.1"
+ label: "if1_out_octets"
diff --git a/snmp/poller.py b/snmp/poller.py
new file mode 100644
index 0000000..2f7e86f
--- /dev/null
+++ b/snmp/poller.py
@@ -0,0 +1,94 @@
+# plugins/snmp/poller.py
+"""
+Synchronous SNMP GET helper, run via executor.
+
+Requires pysnmp-lextudio (maintained pysnmp fork):
+ pip install 'fabledscryer[snmp]'
+
+If pysnmp is not installed, poll_device() returns an empty dict and logs a warning.
+"""
+from __future__ import annotations
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+def _pysnmp_available() -> bool:
+ try:
+ import pysnmp # noqa: F401
+ return True
+ except ImportError:
+ return False
+
+
+def _mp_model(version: str) -> int:
+ """Map version string to pysnmp mpModel integer."""
+ return 0 if version == "1" else 1
+
+
+def poll_device_sync(
+ host: str,
+ port: int,
+ community: str,
+ version: str,
+ oids: list[dict],
+) -> dict[str, float]:
+ """
+ Perform SNMP GET for each OID and return {label: float_value}.
+ Non-numeric OIDs (strings, etc.) are skipped.
+ Returns empty dict on any error.
+ """
+ if not _pysnmp_available():
+ logger.warning("pysnmp not installed — SNMP polling disabled. "
+ "Install with: pip install 'fabledscryer[snmp]'")
+ return {}
+
+ from pysnmp.hlapi import (
+ CommunityData,
+ ContextData,
+ ObjectIdentity,
+ ObjectType,
+ SnmpEngine,
+ UdpTransportTarget,
+ getCmd,
+ )
+
+ results: dict[str, float] = {}
+ engine = SnmpEngine()
+
+ for oid_cfg in oids:
+ oid = oid_cfg["oid"]
+ label = oid_cfg.get("label") or oid
+ scale = float(oid_cfg.get("scale", 1.0))
+
+ try:
+ error_indication, error_status, error_index, var_binds = next(
+ getCmd(
+ engine,
+ CommunityData(community, mpModel=_mp_model(version)),
+ UdpTransportTarget((host, port), timeout=5, retries=1),
+ ContextData(),
+ ObjectType(ObjectIdentity(oid)),
+ )
+ )
+ except Exception as exc:
+ logger.debug("SNMP GET %s@%s OID %s failed: %s", host, port, oid, exc)
+ continue
+
+ if error_indication:
+ logger.debug("SNMP error %s@%s OID %s: %s", host, port, oid, error_indication)
+ continue
+ if error_status:
+ logger.debug("SNMP status %s@%s OID %s: %s at %s",
+ host, port, oid, error_status.prettyPrint(),
+ error_index and var_binds[int(error_index) - 1][0] or "?")
+ continue
+
+ for _, val in var_binds:
+ try:
+ results[label] = float(val) * scale
+ except (TypeError, ValueError):
+ # Non-numeric type (e.g. OctetString description) — skip
+ logger.debug("SNMP non-numeric value for %s label=%s: %r", oid, label, val)
+
+ return results
diff --git a/snmp/routes.py b/snmp/routes.py
new file mode 100644
index 0000000..cca9697
--- /dev/null
+++ b/snmp/routes.py
@@ -0,0 +1,146 @@
+# plugins/snmp/routes.py
+from __future__ import annotations
+from datetime import datetime, timedelta, timezone
+
+from quart import Blueprint, current_app, render_template, request
+from sqlalchemy import func, select
+
+from fabledscryer.auth.middleware import require_role
+from fabledscryer.models.users import UserRole
+from fabledscryer.models.metrics import PluginMetric
+
+snmp_bp = Blueprint("snmp", __name__, template_folder="templates")
+
+
+async def _latest_readings(db, device_names: list[str]) -> dict[str, dict[str, float]]:
+ """Return {device_name: {label: latest_value}} for all configured devices."""
+ if not device_names:
+ return {}
+
+ # Latest value per (resource_name, metric_name) pair
+ subq = (
+ select(
+ PluginMetric.resource_name,
+ PluginMetric.metric_name,
+ func.max(PluginMetric.recorded_at).label("latest_at"),
+ )
+ .where(PluginMetric.source_module == "snmp")
+ .where(PluginMetric.resource_name.in_(device_names))
+ .group_by(PluginMetric.resource_name, PluginMetric.metric_name)
+ .subquery()
+ )
+ result = await db.execute(
+ select(PluginMetric).join(
+ subq,
+ (PluginMetric.resource_name == subq.c.resource_name)
+ & (PluginMetric.metric_name == subq.c.metric_name)
+ & (PluginMetric.recorded_at == subq.c.latest_at),
+ ).where(PluginMetric.source_module == "snmp")
+ )
+ rows = result.scalars().all()
+
+ out: dict[str, dict[str, float]] = {}
+ for row in rows:
+ out.setdefault(row.resource_name, {})[row.metric_name] = row.value
+ return out
+
+
+async def _history(db, device_name: str, hours: int = 24) -> dict[str, list]:
+ """Return {label: [(recorded_at, value), ...]} for a single device."""
+ since = datetime.now(timezone.utc) - timedelta(hours=hours)
+ result = await db.execute(
+ select(PluginMetric)
+ .where(
+ PluginMetric.source_module == "snmp",
+ PluginMetric.resource_name == device_name,
+ PluginMetric.recorded_at >= since,
+ )
+ .order_by(PluginMetric.recorded_at)
+ )
+ rows = result.scalars().all()
+
+ out: dict[str, list] = {}
+ for row in rows:
+ out.setdefault(row.metric_name, []).append(
+ (row.recorded_at.isoformat(), row.value)
+ )
+ return out
+
+
+@snmp_bp.get("/")
+@require_role(UserRole.viewer)
+async def index():
+ devices_cfg: list[dict] = current_app.config["PLUGINS"]["snmp"].get("devices", [])
+ device_names = [d.get("name") or d.get("host", "?") for d in devices_cfg]
+
+ async with current_app.db_sessionmaker() as db:
+ latest = await _latest_readings(db, device_names)
+
+ # Merge config + latest readings for the template
+ devices = []
+ for cfg in devices_cfg:
+ name = cfg.get("name") or cfg.get("host", "?")
+ devices.append({
+ "name": name,
+ "host": cfg.get("host", ""),
+ "oids": cfg.get("oids", []),
+ "readings": latest.get(name, {}),
+ })
+
+ poll_interval = current_app.config.get("MONITORS_POLL_INTERVAL", 60)
+ return await render_template("snmp/index.html",
+ devices=devices,
+ poll_interval=poll_interval)
+
+
+@snmp_bp.get("/widget")
+@require_role(UserRole.viewer)
+async def widget():
+ devices_cfg: list[dict] = current_app.config["PLUGINS"]["snmp"].get("devices", [])
+ device_names = [d.get("name") or d.get("host", "?") for d in devices_cfg]
+
+ async with current_app.db_sessionmaker() as db:
+ latest = await _latest_readings(db, device_names[:10])
+
+ devices = []
+ for cfg in devices_cfg[:10]:
+ name = cfg.get("name") or cfg.get("host", "?")
+ devices.append({
+ "name": name,
+ "oids": cfg.get("oids", []),
+ "readings": latest.get(name, {}),
+ })
+
+ return await render_template("snmp/widget.html",
+ devices=devices,
+ total=len(devices_cfg))
+
+
+@snmp_bp.get("/device/")
+@require_role(UserRole.viewer)
+async def device_detail(device_name: str):
+ devices_cfg: list[dict] = current_app.config["PLUGINS"]["snmp"].get("devices", [])
+ device_cfg = next(
+ (d for d in devices_cfg if (d.get("name") or d.get("host")) == device_name),
+ None,
+ )
+ if device_cfg is None:
+ from quart import abort
+ abort(404)
+
+ hours = int(request.args.get("hours", 24))
+ async with current_app.db_sessionmaker() as db:
+ hist = await _history(db, device_name, hours=hours)
+
+ import json
+ history_json = json.dumps(hist)
+ oid_labels = [o.get("label") or o.get("oid") for o in device_cfg.get("oids", [])]
+
+ return await render_template(
+ "snmp/device.html",
+ device=device_cfg,
+ device_name=device_name,
+ oid_labels=oid_labels,
+ history_json=history_json,
+ hours=hours,
+ )
diff --git a/snmp/templates/snmp/device.html b/snmp/templates/snmp/device.html
new file mode 100644
index 0000000..82022a9
--- /dev/null
+++ b/snmp/templates/snmp/device.html
@@ -0,0 +1,84 @@
+{# plugins/snmp/templates/snmp/device.html #}
+{% extends "base.html" %}
+{% block title %}SNMP — {{ device_name }} — Fabled Scryer{% endblock %}
+{% block content %}
+
+
← SNMP
+
{{ device_name }}
+
{{ device.host }}
+
+
+
+{% if oid_labels %}
+
+
+
+
+
+{% else %}
+
+ No OIDs configured for this device.
+
+{% endif %}
+{% endblock %}
diff --git a/snmp/templates/snmp/index.html b/snmp/templates/snmp/index.html
new file mode 100644
index 0000000..86a1485
--- /dev/null
+++ b/snmp/templates/snmp/index.html
@@ -0,0 +1,57 @@
+{# plugins/snmp/templates/snmp/index.html #}
+{% extends "base.html" %}
+{% block title %}SNMP — Fabled Scryer{% endblock %}
+{% block content %}
+
+
SNMP Devices
+ {{ devices|length }} device(s) configured
+
+
+{% if not devices %}
+
+ No SNMP devices configured. Add devices to plugin.yaml.
+
+{% else %}
+
+{% for device in devices %}
+
+
+
{{ device.name }}
+
{{ device.host }}
+ {% if device.readings %}
+
● reachable
+ {% else %}
+
○ no data yet
+ {% endif %}
+
History
+
+
+ {% if device.readings %}
+
+ {% for oid_cfg in device.oids %}
+ {% set label = oid_cfg.label if oid_cfg.label else oid_cfg.oid %}
+ {% set val = device.readings.get(label) %}
+
+
{{ label }}
+ {% if val is not none %}
+
+ {% if val >= 1000000 %}{{ "%.2f"|format(val / 1000000) }}M
+ {% elif val >= 1000 %}{{ "%.1f"|format(val / 1000) }}K
+ {% else %}{{ "%.4g"|format(val) }}{% endif %}
+
+ {% else %}
+
—
+ {% endif %}
+
+ {% endfor %}
+
+ {% else %}
+
+ No readings yet — waiting for next poll cycle.
+
+ {% endif %}
+
+{% endfor %}
+
+{% endif %}
+{% endblock %}
diff --git a/snmp/templates/snmp/widget.html b/snmp/templates/snmp/widget.html
new file mode 100644
index 0000000..fc4d593
--- /dev/null
+++ b/snmp/templates/snmp/widget.html
@@ -0,0 +1,49 @@
+{# plugins/snmp/templates/snmp/widget.html #}
+{% if devices %}
+{% for device in devices %}
+
+
+
+ {{ device.name }}
+
+ {% if device.readings %}
+
●
+ {% else %}
+
○
+ {% endif %}
+
detail →
+
+ {% if device.readings %}
+
+ {% for oid_cfg in device.oids[:4] %}
+ {% set label = oid_cfg.label if oid_cfg.label else oid_cfg.oid %}
+ {% set val = device.readings.get(label) %}
+ {% if val is not none %}
+
+ {{ label }}
+
+ {% if val >= 1000000 %}{{ "%.1f"|format(val / 1000000) }}M
+ {% elif val >= 1000 %}{{ "%.0f"|format(val / 1000) }}K
+ {% else %}{{ "%.4g"|format(val) }}{% endif %}
+
+
+ {% endif %}
+ {% endfor %}
+ {% if device.oids|length > 4 %}
+ +{{ device.oids|length - 4 }} more
+ {% endif %}
+
+ {% endif %}
+
+{% if not loop.last %}
+
+{% endif %}
+{% endfor %}
+{% if total > devices|length %}
+
+ +{{ total - devices|length }} more —
view all →
+
+{% endif %}
+{% else %}
+No SNMP devices configured.
+{% endif %}
diff --git a/traefik/routes.py b/traefik/routes.py
index 6371e35..f7fe3ce 100644
--- a/traefik/routes.py
+++ b/traefik/routes.py
@@ -71,7 +71,7 @@ async def rows():
b_secs = bucket_seconds(since)
bucket_col = (
- cast(func.strftime('%s', TraefikMetric.scraped_at), Integer) / b_secs
+ cast(func.extract('epoch', TraefikMetric.scraped_at), Integer) / b_secs
).label("bucket")
router_data = []
@@ -195,6 +195,102 @@ async def widget():
)
+@traefik_bp.get("/widget/access_log")
+@require_role(UserRole.viewer)
+async def widget_access_log():
+ """HTMX dashboard widget: traffic origin summary with IP filter."""
+ from collections import defaultdict
+ ip_filter = request.args.get("ip_filter", "all")
+ limit = max(1, min(50, 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(TraefikAccessSummary)
+ .order_by(TraefikAccessSummary.period_end.desc())
+ .limit(12) # last ~1 hour of 5-min windows
+ )
+ rows = result.scalars().all()
+
+ if not rows:
+ return await render_template("traefik/widget_access_log.html",
+ summary=None, widget_id=widget_id)
+
+ total = sum(r.total_requests for r in rows)
+ internal = sum(r.internal_requests for r in rows)
+ external = sum(r.external_requests for r in rows)
+
+ ip_counts: dict = defaultdict(lambda: {"count": 0, "internal": True})
+ for row in rows:
+ for entry in json.loads(row.top_ips_json):
+ k = entry["ip"]
+ ip_counts[k]["count"] += entry["count"]
+ ip_counts[k]["internal"] = entry.get("internal", True)
+
+ all_ips = sorted(
+ [{"ip": k, **v} for k, v in ip_counts.items()],
+ key=lambda x: x["count"], reverse=True,
+ )
+ if ip_filter == "internal":
+ all_ips = [e for e in all_ips if e["internal"]]
+ elif ip_filter == "external":
+ all_ips = [e for e in all_ips if not e["internal"]]
+
+ summary = {
+ "total": total,
+ "internal": internal,
+ "external": external,
+ "external_pct": (external / total * 100.0) if total else 0.0,
+ "top_ips": all_ips[:limit],
+ "ip_filter": ip_filter,
+ }
+ return await render_template("traefik/widget_access_log.html",
+ summary=summary, widget_id=widget_id)
+
+
+@traefik_bp.get("/widget/request_chart")
+@require_role(UserRole.viewer)
+async def widget_request_chart():
+ """HTMX dashboard widget: Chart.js request rate + error % over time."""
+ from datetime import timedelta
+ hours = max(1, min(24, int(request.args.get("hours", 6) or 6)))
+ widget_id = request.args.get("wid", "0")
+
+ since = datetime.now(timezone.utc) - timedelta(hours=hours)
+ b_secs = max(60, (hours * 3600) // 80) # ~80 buckets
+
+ bucket_col = (
+ cast(func.extract('epoch', TraefikMetric.scraped_at), Integer) / b_secs
+ ).label("bucket")
+
+ async with current_app.db_sessionmaker() as db:
+ result = await db.execute(
+ select(
+ func.avg(TraefikMetric.request_rate).label("req_rate"),
+ func.avg(TraefikMetric.error_rate_5xx_pct).label("err_pct"),
+ func.min(TraefikMetric.scraped_at).label("scraped_at"),
+ bucket_col,
+ )
+ .where(TraefikMetric.scraped_at >= since)
+ .group_by(bucket_col)
+ .order_by(bucket_col)
+ )
+ history = result.all()
+
+ labels = list(range(len(history)))
+ req_rates = [round(r.req_rate or 0, 3) for r in history]
+ err_pcts = [round(r.err_pct or 0, 2) for r in history]
+
+ return await render_template(
+ "traefik/widget_request_chart.html",
+ labels_json=json.dumps(labels),
+ req_rate_json=json.dumps(req_rates),
+ error_rate_json=json.dumps(err_pcts),
+ widget_id=widget_id,
+ hours=hours,
+ )
+
+
@traefik_bp.get("/traffic")
@require_role(UserRole.viewer)
async def traffic():
diff --git a/traefik/templates/traefik/widget_access_log.html b/traefik/templates/traefik/widget_access_log.html
new file mode 100644
index 0000000..899e09c
--- /dev/null
+++ b/traefik/templates/traefik/widget_access_log.html
@@ -0,0 +1,40 @@
+{# traefik/widget_access_log.html — dashboard widget: traffic origin summary #}
+{% if not summary %}
+No access log data yet.
+{% else %}
+
+
+
+ {{ summary.total | int }}
+ total req
+
+
+
+ {{ "%.1f" | format(summary.external_pct) }}%
+
+ external
+
+
+
+{% if summary.top_ips %}
+
+ Top IPs
+ {% if summary.ip_filter != "all" %}
+ {{ summary.ip_filter }}
+ {% endif %}
+
+
+ {% for ip in summary.top_ips %}
+
+
+
+ {{ ip.ip }}
+
+ {{ ip.count }}
+
+ {% endfor %}
+
+{% endif %}
+
+{% endif %}
diff --git a/traefik/templates/traefik/widget_request_chart.html b/traefik/templates/traefik/widget_request_chart.html
new file mode 100644
index 0000000..272ff39
--- /dev/null
+++ b/traefik/templates/traefik/widget_request_chart.html
@@ -0,0 +1,75 @@
+{# traefik/widget_request_chart.html — Chart.js request rate + error % over time #}
+{% if not labels_json or labels_json == '[]' %}
+No data for last {{ hours }}h.
+{% else %}
+
+
+
+
+
+ last {{ hours }}h
+
+{% endif %}
diff --git a/unifi/routes.py b/unifi/routes.py
index 38e7489..2a57330 100644
--- a/unifi/routes.py
+++ b/unifi/routes.py
@@ -301,3 +301,57 @@ async def widget():
active_alarms=active_alarms,
top_clients=top_clients,
)
+
+
+@unifi_bp.get("/widget/clients")
+@require_role(UserRole.viewer)
+async def widget_clients():
+ """HTMX dashboard widget: top active clients."""
+ limit = max(1, min(20, int(request.args.get("limit", 5) or 5)))
+ widget_id = request.args.get("wid", "0")
+
+ async with current_app.db_sessionmaker() as db:
+ result = await db.execute(
+ select(UnifiClientSnapshot)
+ .order_by(UnifiClientSnapshot.scraped_at.desc())
+ .limit(1)
+ )
+ snapshot = result.scalar_one_or_none()
+
+ clients = json.loads(snapshot.top_clients_json)[:limit] if snapshot else []
+ return await render_template(
+ "unifi/widget_clients.html",
+ clients=clients,
+ snapshot=snapshot,
+ widget_id=widget_id,
+ )
+
+
+@unifi_bp.get("/widget/devices")
+@require_role(UserRole.viewer)
+async def widget_devices():
+ """HTMX dashboard widget: infrastructure devices with optional type filter."""
+ type_filter = request.args.get("type_filter", "all")
+ widget_id = request.args.get("wid", "0")
+
+ async with current_app.db_sessionmaker() as db:
+ result = await db.execute(
+ select(UnifiDevice).order_by(UnifiDevice.state.desc(), UnifiDevice.name)
+ )
+ all_devices = result.scalars().all()
+
+ if type_filter == "wireless":
+ devices = [d for d in all_devices if d.device_type == "uap"]
+ elif type_filter == "wired":
+ devices = [d for d in all_devices if d.device_type != "uap"]
+ elif type_filter == "offline":
+ devices = [d for d in all_devices if d.state != 1]
+ else:
+ devices = list(all_devices)
+
+ return await render_template(
+ "unifi/widget_devices.html",
+ devices=devices,
+ type_filter=type_filter,
+ widget_id=widget_id,
+ )
diff --git a/unifi/templates/unifi/widget_clients.html b/unifi/templates/unifi/widget_clients.html
new file mode 100644
index 0000000..99a5892
--- /dev/null
+++ b/unifi/templates/unifi/widget_clients.html
@@ -0,0 +1,42 @@
+{# unifi/widget_clients.html — dashboard widget: top active clients #}
+{% if not snapshot %}
+No client data yet.
+{% else %}
+
+
+
+ {{ snapshot.total_clients }}
+ total
+
+
+ {{ snapshot.wireless_clients }}
+ wireless
+
+
+ {{ snapshot.wired_clients }}
+ wired
+
+
+
+{% if clients %}
+
+ {% for c in clients %}
+
+
+
+ {{ c.hostname or c.ip or c.mac }}
+
+ {% if c.tx_rate or c.rx_rate %}
+
+ {% if c.tx_rate %}↑{{ "%.0f" | format(c.tx_rate / 1000) }}k{% endif %}
+ {% if c.rx_rate %} ↓{{ "%.0f" | format(c.rx_rate / 1000) }}k{% endif %}
+
+ {% endif %}
+
+ {% endfor %}
+
+{% else %}
+No clients connected.
+{% endif %}
+
+{% endif %}
diff --git a/unifi/templates/unifi/widget_devices.html b/unifi/templates/unifi/widget_devices.html
new file mode 100644
index 0000000..90994ed
--- /dev/null
+++ b/unifi/templates/unifi/widget_devices.html
@@ -0,0 +1,36 @@
+{# unifi/widget_devices.html — dashboard widget: infrastructure devices #}
+{% if not devices %}
+
+ {% if type_filter != "all" %}No {{ type_filter }} devices found.{% else %}No devices found.{% endif %}
+
+{% else %}
+
+{% set online = devices | selectattr("state", "equalto", 1) | list %}
+{% set offline = devices | rejectattr("state", "equalto", 1) | list %}
+
+
+
+ {{ online | length }}
+ online
+
+ {% if offline %}
+
+ {{ offline | length }}
+ offline
+
+ {% endif %}
+
+
+
+ {% for d in devices %}
+
+
+
+ {{ d.name or d.mac }}
+
+ {{ d.model or d.device_type or "" }}
+
+ {% endfor %}
+
+
+{% endif %}
diff --git a/ups/plugin.yaml b/ups/plugin.yaml
index 0230dda..89a6a38 100644
--- a/ups/plugin.yaml
+++ b/ups/plugin.yaml
@@ -12,12 +12,25 @@ tags:
- nut
config:
+ # ── Managed NUT (run NUT inside this container) ────────────────────────────
+ # Enable to have FabledScryer manage the NUT daemon for you — no separate NUT
+ # install required. Connects to your UPS over the network (SNMP or XML/HTTP).
+ # Changes take effect on the next container restart.
+ nut_managed: false
+ nut_ups_host: "" # IP/hostname of UPS management card (required when nut_managed=true)
+ nut_driver: "snmp-ups" # snmp-ups (most UPS brands) or netxml-ups (Eaton XML/HTTP)
+ nut_snmp_community: "public" # SNMP community string (snmp-ups only)
+ nut_snmp_version: "v1" # v1 or v2c (snmp-ups only)
+ # ── NUT connection ─────────────────────────────────────────────────────────
+ # When nut_managed=true these default to localhost (the managed instance).
+ # Point at an external NUT server if nut_managed=false.
nut_host: "localhost"
nut_port: 3493
ups_name: "ups"
nut_username: "" # leave blank if NUT is configured without auth
nut_password: ""
poll_interval_seconds: 30
+ # ── Ansible shutdown automation ────────────────────────────────────────────
shutdown_after_seconds: 300 # seconds on battery before triggering shutdown (0 = disabled)
shutdown_source: "" # Ansible source name containing the shutdown playbook
shutdown_playbook: "" # relative path within source, e.g. "shutdown.yml"
diff --git a/ups/routes.py b/ups/routes.py
index c2d1735..c66b8a6 100644
--- a/ups/routes.py
+++ b/ups/routes.py
@@ -123,3 +123,50 @@ async def widget():
on_battery_since=_on_battery_since,
shutdown_triggered=_shutdown_triggered,
)
+
+
+@ups_bp.get("/widget/history")
+@require_role(UserRole.viewer)
+async def widget_history():
+ """HTMX dashboard widget: Chart.js multi-line history (battery%, load%, input voltage)."""
+ from datetime import timedelta
+ import json as _json
+ hours = max(1, min(24, int(request.args.get("hours", 6) or 6)))
+ widget_id = request.args.get("wid", "0")
+
+ since = datetime.now(timezone.utc) - timedelta(hours=hours)
+ b_secs = max(60, (hours * 3600) // 80) # ~80 buckets
+
+ bucket_col = (
+ cast(func.strftime('%s', UpsStatus.scraped_at), Integer) / b_secs
+ ).label("bucket")
+
+ async with current_app.db_sessionmaker() as db:
+ result = await db.execute(
+ select(
+ func.avg(UpsStatus.battery_charge_pct).label("battery"),
+ func.avg(UpsStatus.load_pct).label("load"),
+ func.avg(UpsStatus.input_voltage).label("voltage"),
+ func.min(UpsStatus.scraped_at).label("scraped_at"),
+ bucket_col,
+ )
+ .where(UpsStatus.scraped_at >= since)
+ .group_by(bucket_col)
+ .order_by(bucket_col)
+ )
+ history = result.all()
+
+ labels = list(range(len(history)))
+ battery = [round(r.battery or 0, 1) for r in history]
+ load = [round(r.load or 0, 1) for r in history]
+ voltage = [round(r.voltage or 0, 1) for r in history]
+
+ return await render_template(
+ "ups/widget_history.html",
+ labels_json=_json.dumps(labels),
+ battery_json=_json.dumps(battery),
+ load_json=_json.dumps(load),
+ voltage_json=_json.dumps(voltage),
+ widget_id=widget_id,
+ hours=hours,
+ )
diff --git a/ups/templates/ups/widget_history.html b/ups/templates/ups/widget_history.html
new file mode 100644
index 0000000..2c122dc
--- /dev/null
+++ b/ups/templates/ups/widget_history.html
@@ -0,0 +1,83 @@
+{# ups/widget_history.html — Chart.js multi-line: battery%, load%, input voltage #}
+{% if not labels_json or labels_json == '[]' %}
+No UPS history for last {{ hours }}h.
+{% else %}
+
+
+
+
+
+ last {{ hours }}h
+
+{% endif %}