From be4654fc72037e445f05661466b7d4393a4d6594 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 23 Mar 2026 17:40:36 -0400 Subject: [PATCH] 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 --- __init__.py | 1 + docker/__init__.py | 24 ++ docker/migrations/__init__.py | 0 docker/migrations/env.py | 70 ++++++ docker/migrations/versions/__init__.py | 0 .../migrations/versions/docker_001_initial.py | 47 ++++ docker/models.py | 49 ++++ docker/plugin.yaml | 17 ++ docker/routes.py | 161 ++++++++++++ docker/scheduler.py | 93 +++++++ docker/scraper.py | 140 +++++++++++ docker/templates/docker/index.html | 16 ++ docker/templates/docker/rows.html | 86 +++++++ docker/templates/docker/widget.html | 35 +++ docker/templates/docker/widget_resources.html | 36 +++ http/__init__.py | 24 ++ http/checker.py | 105 ++++++++ http/migrations/__init__.py | 0 http/migrations/env.py | 70 ++++++ http/migrations/versions/__init__.py | 0 http/migrations/versions/http_001_initial.py | 52 ++++ http/models.py | 63 +++++ http/plugin.yaml | 19 ++ http/routes.py | 229 ++++++++++++++++++ http/scheduler.py | 121 +++++++++ http/templates/http/index.html | 77 ++++++ http/templates/http/rows.html | 109 +++++++++ http/templates/http/widget.html | 56 +++++ index.yaml | 31 +++ snmp/__init__.py | 23 ++ snmp/plugin.yaml | 30 +++ snmp/poller.py | 94 +++++++ snmp/routes.py | 146 +++++++++++ snmp/templates/snmp/device.html | 84 +++++++ snmp/templates/snmp/index.html | 57 +++++ snmp/templates/snmp/widget.html | 49 ++++ traefik/routes.py | 98 +++++++- .../templates/traefik/widget_access_log.html | 40 +++ .../traefik/widget_request_chart.html | 75 ++++++ unifi/routes.py | 54 +++++ unifi/templates/unifi/widget_clients.html | 42 ++++ unifi/templates/unifi/widget_devices.html | 36 +++ ups/plugin.yaml | 13 + ups/routes.py | 47 ++++ ups/templates/ups/widget_history.html | 83 +++++++ 45 files changed, 2701 insertions(+), 1 deletion(-) create mode 100644 __init__.py create mode 100644 docker/__init__.py create mode 100644 docker/migrations/__init__.py create mode 100644 docker/migrations/env.py create mode 100644 docker/migrations/versions/__init__.py create mode 100644 docker/migrations/versions/docker_001_initial.py create mode 100644 docker/models.py create mode 100644 docker/plugin.yaml create mode 100644 docker/routes.py create mode 100644 docker/scheduler.py create mode 100644 docker/scraper.py create mode 100644 docker/templates/docker/index.html create mode 100644 docker/templates/docker/rows.html create mode 100644 docker/templates/docker/widget.html create mode 100644 docker/templates/docker/widget_resources.html create mode 100644 http/__init__.py create mode 100644 http/checker.py create mode 100644 http/migrations/__init__.py create mode 100644 http/migrations/env.py create mode 100644 http/migrations/versions/__init__.py create mode 100644 http/migrations/versions/http_001_initial.py create mode 100644 http/models.py create mode 100644 http/plugin.yaml create mode 100644 http/routes.py create mode 100644 http/scheduler.py create mode 100644 http/templates/http/index.html create mode 100644 http/templates/http/rows.html create mode 100644 http/templates/http/widget.html create mode 100644 snmp/__init__.py create mode 100644 snmp/plugin.yaml create mode 100644 snmp/poller.py create mode 100644 snmp/routes.py create mode 100644 snmp/templates/snmp/device.html create mode 100644 snmp/templates/snmp/index.html create mode 100644 snmp/templates/snmp/widget.html create mode 100644 traefik/templates/traefik/widget_access_log.html create mode 100644 traefik/templates/traefik/widget_request_chart.html create mode 100644 unifi/templates/unifi/widget_clients.html create mode 100644 unifi/templates/unifi/widget_devices.html create mode 100644 ups/templates/ups/widget_history.html 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'' + f'' + f'' + ) + + +@docker_bp.get("/") +@require_role(UserRole.viewer) +async def index(): + poll_interval = current_app.config.get("MONITORS_POLL_INTERVAL", 60) + current_range = request.args.get("range", DEFAULT_RANGE) + return await render_template( + "docker/index.html", + poll_interval=poll_interval, + current_range=current_range, + ) + + +@docker_bp.get("/rows") +@require_role(UserRole.viewer) +async def rows(): + """HTMX fragment: container list with status and resource sparklines.""" + since, range_key = parse_range(request.args.get("range")) + b_secs = bucket_seconds(since) + + bucket_col = ( + cast(func.strftime('%s', DockerMetric.scraped_at), Integer) / b_secs + ).label("bucket") + + async with current_app.db_sessionmaker() as db: + # All known containers ordered by running first, then name + result = await db.execute( + select(DockerContainer) + .order_by( + # running first + (DockerContainer.status == "running").desc(), + DockerContainer.name, + ) + ) + containers = list(result.scalars()) + + # Build per-container sparkline histories + histories: dict[str, list] = {} + for c in containers: + result = await db.execute( + select( + func.avg(DockerMetric.cpu_pct).label("cpu_pct"), + func.avg(DockerMetric.mem_pct).label("mem_pct"), + bucket_col, + ) + .where(DockerMetric.container_name == c.name) + .where(DockerMetric.scraped_at >= since) + .group_by(bucket_col) + .order_by(bucket_col) + ) + histories[c.name] = result.all() + + running = sum(1 for c in containers if c.status == "running") + stopped = len(containers) - running + + container_data = [] + for c in containers: + hist = histories.get(c.name, []) + ports = json.loads(c.ports_json) if c.ports_json else [] + container_data.append({ + "container": c, + "ports": ports, + "sparkline_cpu": _sparkline([r.cpu_pct or 0 for r in hist]), + "sparkline_mem": _sparkline([r.mem_pct or 0 for r in hist]), + }) + + return await render_template( + "docker/rows.html", + container_data=container_data, + running=running, + stopped=stopped, + range_key=range_key, + ) + + +@docker_bp.get("/widget") +@require_role(UserRole.viewer) +async def widget(): + """HTMX dashboard widget: container status overview.""" + show_stopped = request.args.get("show_stopped", "no") == "yes" + widget_id = request.args.get("wid", "0") + + async with current_app.db_sessionmaker() as db: + result = await db.execute( + select(DockerContainer) + .order_by( + (DockerContainer.status == "running").desc(), + DockerContainer.name, + ) + ) + all_containers = list(result.scalars()) + + running = [c for c in all_containers if c.status == "running"] + stopped = [c for c in all_containers if c.status != "running"] + display = all_containers if show_stopped else running + + return await render_template( + "docker/widget.html", + containers=display, + running_count=len(running), + stopped_count=len(stopped), + show_stopped=show_stopped, + widget_id=widget_id, + ) + + +@docker_bp.get("/widget/resources") +@require_role(UserRole.viewer) +async def widget_resources(): + """HTMX dashboard widget: CPU + memory usage for running containers.""" + limit = max(1, min(20, int(request.args.get("limit", 10) or 10))) + widget_id = request.args.get("wid", "0") + + async with current_app.db_sessionmaker() as db: + result = await db.execute( + select(DockerContainer) + .where(DockerContainer.status == "running") + .order_by(DockerContainer.cpu_pct.desc().nullslast()) + ) + containers = list(result.scalars())[:limit] + + return await render_template( + "docker/widget_resources.html", + containers=containers, + widget_id=widget_id, + ) 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" %} +
+ +
+
Loading...
+
+{% 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 %} +
+ + + + + + + + + + + + + + {% for item in container_data %} + {% set c = item.container %} + + + + + + + + + + {% endfor %} + +
ContainerImagePortsCPU %CPU historyMem %Mem history
+
+ +
+
{{ 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 }}
+
+{% 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'' + f'' + 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 ─────────────────────────────────────────────────────── #} +
+
Add Monitor
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+
+
+ +{# ── Monitor list ─────────────────────────────────────────────────────────── #} +
+
Loading...
+
+{% 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 ─────────────────────────────────────────────────────────────── #} +
+
+
Up
+ {{ up }} +
+
+
Down
+ {{ down }} +
+
+
Monitors
+ {{ monitor_data | length }} +
+
+ +{# ── Monitor list ─────────────────────────────────────────────────────────── #} +{% if monitor_data %} +
+ + + + + + + + + + + + + {% for item in monitor_data %} + {% set m = item.monitor %} + {% set latest = item.latest %} + + + + + + + + + {% endfor %} + +
MonitorStatusResponseHistoryTLS expiry
+
{{ 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 %} + +
+ +
+
+ +
+
+
+{% 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 %} +
+ No monitors configured. Add monitors → +
+{% 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 }} +
+ {% for h in [1, 6, 24, 168] %} + + {% if h < 24 %}{{ h }}h{% elif h == 24 %}24h{% else %}7d{% endif %} + + {% endfor %} +
+
+ +{% 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 %}