feat: add http, snmp, docker plugins; new widgets and routes for traefik/unifi/ups
- Add HTTP monitor plugin: endpoint checking with status history, latency tracking, time-range views - Add SNMP plugin: OID polling, device management, metric recording - Add Docker plugin: container status, resource usage, widget - Traefik: access log widget, request chart widget, expanded routes - UniFi: clients widget, devices widget, expanded poll routes - UPS: history widget, additional routes - Update plugin index with new entries Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1 @@
|
||||
# plugins/__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
|
||||
@@ -0,0 +1,70 @@
|
||||
# plugins/docker/migrations/env.py
|
||||
"""Alembic env.py for the Docker plugin."""
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from logging.config import fileConfig
|
||||
|
||||
from sqlalchemy import pool
|
||||
from sqlalchemy.engine import Connection
|
||||
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||
from alembic import context
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent))
|
||||
|
||||
from fabledscryer.models.base import Base
|
||||
import fabledscryer.models # noqa: F401
|
||||
from plugins.docker.models import DockerContainer, DockerMetric # noqa: F401
|
||||
|
||||
config = context.config
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def _get_url() -> str:
|
||||
import yaml
|
||||
cfg_path = os.environ.get("FABLEDSCRYER_CONFIG", "config.yaml")
|
||||
try:
|
||||
with open(cfg_path) as f:
|
||||
cfg = yaml.safe_load(f) or {}
|
||||
url = cfg.get("database", {}).get("url", "")
|
||||
except FileNotFoundError:
|
||||
url = ""
|
||||
return os.environ.get("FABLEDSCRYER_DATABASE__URL", url)
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
context.configure(
|
||||
url=_get_url(), target_metadata=target_metadata,
|
||||
literal_binds=True, dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def do_run_migrations(connection: Connection) -> None:
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
async def run_async_migrations() -> None:
|
||||
cfg = config.get_section(config.config_ini_section, {})
|
||||
cfg["sqlalchemy.url"] = _get_url()
|
||||
connectable = async_engine_from_config(cfg, prefix="sqlalchemy.", poolclass=pool.NullPool)
|
||||
async with connectable.connect() as connection:
|
||||
await connection.run_sync(do_run_migrations)
|
||||
await connectable.dispose()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
asyncio.run(run_async_migrations())
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Docker plugin initial tables
|
||||
|
||||
Revision ID: docker_001_initial
|
||||
Revises: (none — branch off core via depends_on)
|
||||
Create Date: 2026-03-22
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = "docker_001_initial"
|
||||
down_revision: Union[str, None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = "docker"
|
||||
depends_on: Union[str, Sequence[str], None] = ("0004_app_settings",)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"docker_containers",
|
||||
sa.Column("name", sa.String(255), primary_key=True),
|
||||
sa.Column("container_id", sa.String(64), nullable=False, server_default=""),
|
||||
sa.Column("image", sa.String(512), nullable=False, server_default=""),
|
||||
sa.Column("status", sa.String(32), nullable=False, server_default="unknown"),
|
||||
sa.Column("cpu_pct", sa.Float, nullable=True),
|
||||
sa.Column("mem_usage_bytes", sa.Integer, nullable=True),
|
||||
sa.Column("mem_limit_bytes", sa.Integer, nullable=True),
|
||||
sa.Column("mem_pct", sa.Float, nullable=True),
|
||||
sa.Column("restart_count", sa.Integer, nullable=False, server_default="0"),
|
||||
sa.Column("ports_json", sa.Text, nullable=False, server_default="[]"),
|
||||
sa.Column("started_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("scraped_at", sa.DateTime(timezone=True), nullable=False),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"docker_metrics",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("container_name", sa.String(255), nullable=False, index=True),
|
||||
sa.Column("scraped_at", sa.DateTime(timezone=True), nullable=False, index=True),
|
||||
sa.Column("cpu_pct", sa.Float, nullable=False, server_default="0"),
|
||||
sa.Column("mem_pct", sa.Float, nullable=False, server_default="0"),
|
||||
sa.Column("mem_usage_bytes", sa.Integer, nullable=False, server_default="0"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("docker_metrics")
|
||||
op.drop_table("docker_containers")
|
||||
@@ -0,0 +1,49 @@
|
||||
# plugins/docker/models.py
|
||||
from __future__ import annotations
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy import DateTime, Float, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from fabledscryer.models.base import Base
|
||||
|
||||
|
||||
class DockerContainer(Base):
|
||||
"""Latest known state per container — upserted by name on each scrape."""
|
||||
__tablename__ = "docker_containers"
|
||||
|
||||
# Container name is the stable natural key; container_id changes on recreation
|
||||
name: Mapped[str] = mapped_column(String(255), primary_key=True)
|
||||
container_id: Mapped[str] = mapped_column(String(64), nullable=False, default="")
|
||||
image: Mapped[str] = mapped_column(String(512), nullable=False, default="")
|
||||
status: Mapped[str] = mapped_column(String(32), nullable=False, default="unknown")
|
||||
# running | stopped | paused | exited | dead
|
||||
cpu_pct: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
mem_usage_bytes: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
mem_limit_bytes: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
mem_pct: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
restart_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
ports_json: Mapped[str] = mapped_column(Text, nullable=False, default="[]")
|
||||
# JSON: [{"host_port": 8080, "container_port": 80, "protocol": "tcp"}]
|
||||
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
scraped_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False,
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
|
||||
class DockerMetric(Base):
|
||||
"""Time-series CPU/memory per container — one row per scrape per running container."""
|
||||
__tablename__ = "docker_metrics"
|
||||
|
||||
id: Mapped[str] = mapped_column(
|
||||
String(36), primary_key=True, default=lambda: str(uuid.uuid4())
|
||||
)
|
||||
container_name: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
scraped_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False,
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
index=True,
|
||||
)
|
||||
cpu_pct: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
|
||||
mem_pct: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
|
||||
mem_usage_bytes: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
@@ -0,0 +1,17 @@
|
||||
name: docker
|
||||
version: "1.0.0"
|
||||
description: "Docker container status, resource usage, restart tracking via Docker socket"
|
||||
author: "FabledScryer"
|
||||
license: "MIT"
|
||||
min_app_version: "0.1.0"
|
||||
repository_url: "https://git.fabledsword.com/bvandeusen/FabledScryer-plugins"
|
||||
homepage: "https://git.fabledsword.com/bvandeusen/FabledScryer-plugins/src/branch/main/docker"
|
||||
tags:
|
||||
- containers
|
||||
- docker
|
||||
- infrastructure
|
||||
|
||||
config:
|
||||
socket_path: /var/run/docker.sock
|
||||
scrape_interval_seconds: 60
|
||||
include_stopped: false
|
||||
@@ -0,0 +1,161 @@
|
||||
# plugins/docker/routes.py
|
||||
from __future__ import annotations
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from quart import Blueprint, current_app, render_template, request
|
||||
from sqlalchemy import Integer, cast, func, select
|
||||
|
||||
from fabledscryer.auth.middleware import require_role
|
||||
from fabledscryer.models.users import UserRole
|
||||
from fabledscryer.core.time_range import parse_range, DEFAULT_RANGE, bucket_seconds
|
||||
from .models import DockerContainer, DockerMetric
|
||||
|
||||
docker_bp = Blueprint("docker", __name__, template_folder="templates")
|
||||
|
||||
|
||||
def _sparkline(values: list[float], width: int = 80, height: int = 20) -> str:
|
||||
if len(values) < 2:
|
||||
return f'<svg width="{width}" height="{height}"></svg>'
|
||||
mn, mx = min(values), max(values)
|
||||
if mx == mn:
|
||||
mx = mn + 1.0
|
||||
step = width / (len(values) - 1)
|
||||
pts = []
|
||||
for i, v in enumerate(values):
|
||||
x = i * step
|
||||
y = height - (v - mn) / (mx - mn) * (height - 2) - 1
|
||||
pts.append(f"{x:.1f},{y:.1f}")
|
||||
poly = " ".join(pts)
|
||||
return (
|
||||
f'<svg width="{width}" height="{height}" viewBox="0 0 {width} {height}" '
|
||||
f'style="vertical-align:middle;">'
|
||||
f'<polyline points="{poly}" fill="none" stroke="#6060c0" stroke-width="1.5"/>'
|
||||
f'</svg>'
|
||||
)
|
||||
|
||||
|
||||
@docker_bp.get("/")
|
||||
@require_role(UserRole.viewer)
|
||||
async def index():
|
||||
poll_interval = current_app.config.get("MONITORS_POLL_INTERVAL", 60)
|
||||
current_range = request.args.get("range", DEFAULT_RANGE)
|
||||
return await render_template(
|
||||
"docker/index.html",
|
||||
poll_interval=poll_interval,
|
||||
current_range=current_range,
|
||||
)
|
||||
|
||||
|
||||
@docker_bp.get("/rows")
|
||||
@require_role(UserRole.viewer)
|
||||
async def rows():
|
||||
"""HTMX fragment: container list with status and resource sparklines."""
|
||||
since, range_key = parse_range(request.args.get("range"))
|
||||
b_secs = bucket_seconds(since)
|
||||
|
||||
bucket_col = (
|
||||
cast(func.strftime('%s', DockerMetric.scraped_at), Integer) / b_secs
|
||||
).label("bucket")
|
||||
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
# All known containers ordered by running first, then name
|
||||
result = await db.execute(
|
||||
select(DockerContainer)
|
||||
.order_by(
|
||||
# running first
|
||||
(DockerContainer.status == "running").desc(),
|
||||
DockerContainer.name,
|
||||
)
|
||||
)
|
||||
containers = list(result.scalars())
|
||||
|
||||
# Build per-container sparkline histories
|
||||
histories: dict[str, list] = {}
|
||||
for c in containers:
|
||||
result = await db.execute(
|
||||
select(
|
||||
func.avg(DockerMetric.cpu_pct).label("cpu_pct"),
|
||||
func.avg(DockerMetric.mem_pct).label("mem_pct"),
|
||||
bucket_col,
|
||||
)
|
||||
.where(DockerMetric.container_name == c.name)
|
||||
.where(DockerMetric.scraped_at >= since)
|
||||
.group_by(bucket_col)
|
||||
.order_by(bucket_col)
|
||||
)
|
||||
histories[c.name] = result.all()
|
||||
|
||||
running = sum(1 for c in containers if c.status == "running")
|
||||
stopped = len(containers) - running
|
||||
|
||||
container_data = []
|
||||
for c in containers:
|
||||
hist = histories.get(c.name, [])
|
||||
ports = json.loads(c.ports_json) if c.ports_json else []
|
||||
container_data.append({
|
||||
"container": c,
|
||||
"ports": ports,
|
||||
"sparkline_cpu": _sparkline([r.cpu_pct or 0 for r in hist]),
|
||||
"sparkline_mem": _sparkline([r.mem_pct or 0 for r in hist]),
|
||||
})
|
||||
|
||||
return await render_template(
|
||||
"docker/rows.html",
|
||||
container_data=container_data,
|
||||
running=running,
|
||||
stopped=stopped,
|
||||
range_key=range_key,
|
||||
)
|
||||
|
||||
|
||||
@docker_bp.get("/widget")
|
||||
@require_role(UserRole.viewer)
|
||||
async def widget():
|
||||
"""HTMX dashboard widget: container status overview."""
|
||||
show_stopped = request.args.get("show_stopped", "no") == "yes"
|
||||
widget_id = request.args.get("wid", "0")
|
||||
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
result = await db.execute(
|
||||
select(DockerContainer)
|
||||
.order_by(
|
||||
(DockerContainer.status == "running").desc(),
|
||||
DockerContainer.name,
|
||||
)
|
||||
)
|
||||
all_containers = list(result.scalars())
|
||||
|
||||
running = [c for c in all_containers if c.status == "running"]
|
||||
stopped = [c for c in all_containers if c.status != "running"]
|
||||
display = all_containers if show_stopped else running
|
||||
|
||||
return await render_template(
|
||||
"docker/widget.html",
|
||||
containers=display,
|
||||
running_count=len(running),
|
||||
stopped_count=len(stopped),
|
||||
show_stopped=show_stopped,
|
||||
widget_id=widget_id,
|
||||
)
|
||||
|
||||
|
||||
@docker_bp.get("/widget/resources")
|
||||
@require_role(UserRole.viewer)
|
||||
async def widget_resources():
|
||||
"""HTMX dashboard widget: CPU + memory usage for running containers."""
|
||||
limit = max(1, min(20, int(request.args.get("limit", 10) or 10)))
|
||||
widget_id = request.args.get("wid", "0")
|
||||
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
result = await db.execute(
|
||||
select(DockerContainer)
|
||||
.where(DockerContainer.status == "running")
|
||||
.order_by(DockerContainer.cpu_pct.desc().nullslast())
|
||||
)
|
||||
containers = list(result.scalars())[:limit]
|
||||
|
||||
return await render_template(
|
||||
"docker/widget_resources.html",
|
||||
containers=containers,
|
||||
widget_id=widget_id,
|
||||
)
|
||||
@@ -0,0 +1,93 @@
|
||||
# plugins/docker/scheduler.py
|
||||
from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fabledscryer.core.scheduler import ScheduledTask
|
||||
from fabledscryer.core.alerts import record_metric
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def make_task(app) -> ScheduledTask:
|
||||
interval = int(
|
||||
app.config["PLUGINS"]["docker"].get("scrape_interval_seconds", 60)
|
||||
)
|
||||
|
||||
async def scrape():
|
||||
await _do_scrape(app)
|
||||
|
||||
return ScheduledTask(
|
||||
name="docker_scrape",
|
||||
coro_factory=scrape,
|
||||
interval_seconds=interval,
|
||||
run_on_startup=True,
|
||||
)
|
||||
|
||||
|
||||
async def _do_scrape(app) -> None:
|
||||
from .scraper import scrape_docker
|
||||
from .models import DockerContainer, DockerMetric
|
||||
|
||||
cfg = app.config["PLUGINS"]["docker"]
|
||||
socket_path = cfg.get("socket_path", "/var/run/docker.sock")
|
||||
include_stopped = bool(cfg.get("include_stopped", False))
|
||||
|
||||
try:
|
||||
containers = await scrape_docker(socket_path, include_stopped)
|
||||
except ConnectionError as exc:
|
||||
logger.error("Docker scrape failed: %s", exc)
|
||||
return
|
||||
except Exception:
|
||||
logger.exception("Docker scrape error")
|
||||
return
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
async with app.db_sessionmaker() as session:
|
||||
async with session.begin():
|
||||
for c in containers:
|
||||
# Upsert container state
|
||||
existing = await session.get(DockerContainer, c["name"])
|
||||
if existing is None:
|
||||
existing = DockerContainer(name=c["name"])
|
||||
session.add(existing)
|
||||
existing.container_id = c["container_id"]
|
||||
existing.image = c["image"]
|
||||
existing.status = c["status"]
|
||||
existing.cpu_pct = c["cpu_pct"]
|
||||
existing.mem_usage_bytes = c["mem_usage_bytes"]
|
||||
existing.mem_limit_bytes = c["mem_limit_bytes"]
|
||||
existing.mem_pct = c["mem_pct"]
|
||||
existing.restart_count = c["restart_count"]
|
||||
existing.ports_json = json.dumps(c["ports"])
|
||||
existing.started_at = c["started_at"]
|
||||
existing.scraped_at = now
|
||||
|
||||
# Time-series metric (running containers only)
|
||||
if c["status"] == "running" and c["cpu_pct"] is not None:
|
||||
session.add(DockerMetric(
|
||||
container_name=c["name"],
|
||||
scraped_at=now,
|
||||
cpu_pct=c["cpu_pct"],
|
||||
mem_pct=c["mem_pct"] or 0.0,
|
||||
mem_usage_bytes=c["mem_usage_bytes"] or 0,
|
||||
))
|
||||
|
||||
# Feed alert pipeline
|
||||
await record_metric(
|
||||
session=session,
|
||||
source_module="docker",
|
||||
resource_name=c["name"],
|
||||
metric_name="cpu_pct",
|
||||
value=c["cpu_pct"],
|
||||
)
|
||||
if c["mem_pct"] is not None:
|
||||
await record_metric(
|
||||
session=session,
|
||||
source_module="docker",
|
||||
resource_name=c["name"],
|
||||
metric_name="mem_pct",
|
||||
value=c["mem_pct"],
|
||||
)
|
||||
@@ -0,0 +1,140 @@
|
||||
# plugins/docker/scraper.py
|
||||
"""Docker API client via Unix socket."""
|
||||
from __future__ import annotations
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _short_id(container_id: str) -> str:
|
||||
return container_id[:12] if container_id else ""
|
||||
|
||||
|
||||
def _calc_cpu_pct(stats: dict) -> float:
|
||||
"""Calculate CPU % from a Docker stats snapshot (one-shot)."""
|
||||
try:
|
||||
cpu = stats.get("cpu_stats", {})
|
||||
precpu = stats.get("precpu_stats", {})
|
||||
cpu_delta = (
|
||||
cpu["cpu_usage"]["total_usage"] - precpu["cpu_usage"]["total_usage"]
|
||||
)
|
||||
sys_delta = cpu.get("system_cpu_usage", 0) - precpu.get("system_cpu_usage", 0)
|
||||
num_cpus = cpu.get("online_cpus") or len(
|
||||
cpu["cpu_usage"].get("percpu_usage") or [None]
|
||||
)
|
||||
if sys_delta <= 0 or cpu_delta < 0:
|
||||
return 0.0
|
||||
return round((cpu_delta / sys_delta) * num_cpus * 100.0, 2)
|
||||
except (KeyError, TypeError, ZeroDivisionError):
|
||||
return 0.0
|
||||
|
||||
|
||||
def _calc_mem(stats: dict) -> tuple[int, int, float]:
|
||||
"""Return (usage_bytes, limit_bytes, mem_pct) from Docker stats."""
|
||||
try:
|
||||
mem = stats.get("memory_stats", {})
|
||||
usage = mem.get("usage", 0)
|
||||
limit = mem.get("limit", 0)
|
||||
# Subtract page cache for working set (cgroup v1: "cache", v2: "inactive_file")
|
||||
cache = mem.get("stats", {}).get("cache", 0) or \
|
||||
mem.get("stats", {}).get("inactive_file", 0)
|
||||
actual = max(0, usage - cache)
|
||||
pct = round(actual / limit * 100.0, 2) if limit > 0 else 0.0
|
||||
return actual, limit, pct
|
||||
except (KeyError, TypeError):
|
||||
return 0, 0, 0.0
|
||||
|
||||
|
||||
def _parse_ports(ports: list) -> list[dict]:
|
||||
"""Normalise Docker port bindings to a compact list."""
|
||||
result = []
|
||||
for p in (ports or []):
|
||||
if p.get("PublicPort"):
|
||||
result.append({
|
||||
"host_port": p["PublicPort"],
|
||||
"container_port": p["PrivatePort"],
|
||||
"protocol": p.get("Type", "tcp"),
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
async def scrape_docker(socket_path: str, include_stopped: bool) -> list[dict]:
|
||||
"""
|
||||
Fetch all containers + resource stats from the Docker daemon.
|
||||
Returns a list of normalised dicts ready for the scheduler to persist.
|
||||
Raises ConnectionError if the socket is unreachable.
|
||||
"""
|
||||
transport = httpx.AsyncHTTPTransport(uds=socket_path)
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport,
|
||||
base_url="http://docker",
|
||||
timeout=10.0,
|
||||
) as client:
|
||||
resp = await client.get(
|
||||
"/containers/json",
|
||||
params={"all": "true" if include_stopped else "false"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
containers = resp.json()
|
||||
|
||||
async def _get_stats(c: dict) -> tuple[dict, dict | None]:
|
||||
if c.get("State") != "running":
|
||||
return c, None
|
||||
try:
|
||||
r = await client.get(
|
||||
f"/containers/{c['Id']}/stats",
|
||||
params={"stream": "false", "one-shot": "true"},
|
||||
timeout=5.0,
|
||||
)
|
||||
return c, r.json()
|
||||
except Exception as exc:
|
||||
logger.debug(
|
||||
"Stats unavailable for %s: %s", _short_id(c.get("Id", "")), exc
|
||||
)
|
||||
return c, None
|
||||
|
||||
pairs = await asyncio.gather(*[_get_stats(c) for c in containers])
|
||||
|
||||
except httpx.ConnectError as exc:
|
||||
raise ConnectionError(
|
||||
f"Cannot connect to Docker socket at {socket_path}: {exc}"
|
||||
) from exc
|
||||
|
||||
results = []
|
||||
now = datetime.now(timezone.utc)
|
||||
for container, stats in pairs:
|
||||
names = container.get("Names") or []
|
||||
name = names[0].lstrip("/") if names else _short_id(container.get("Id", ""))
|
||||
|
||||
cpu_pct = mem_usage = mem_limit = mem_pct = None
|
||||
if stats is not None:
|
||||
cpu_pct = _calc_cpu_pct(stats)
|
||||
mem_usage, mem_limit, mem_pct = _calc_mem(stats)
|
||||
|
||||
# Created is a Unix epoch int from /containers/json
|
||||
created_ts = container.get("Created")
|
||||
started_at = (
|
||||
datetime.fromtimestamp(created_ts, tz=timezone.utc)
|
||||
if isinstance(created_ts, (int, float)) else None
|
||||
)
|
||||
|
||||
results.append({
|
||||
"name": name,
|
||||
"container_id": _short_id(container.get("Id", "")),
|
||||
"image": container.get("Image", ""),
|
||||
"status": container.get("State", "unknown"),
|
||||
"cpu_pct": cpu_pct,
|
||||
"mem_usage_bytes": mem_usage,
|
||||
"mem_limit_bytes": mem_limit,
|
||||
"mem_pct": mem_pct,
|
||||
"restart_count": 0, # would require /inspect; left as 0 for now
|
||||
"ports": _parse_ports(container.get("Ports", [])),
|
||||
"started_at": started_at,
|
||||
})
|
||||
|
||||
return results
|
||||
@@ -0,0 +1,16 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Docker — Fabled Scryer{% endblock %}
|
||||
{% block content %}
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.5rem;gap:1rem;flex-wrap:wrap;">
|
||||
<h1 class="page-title" style="margin-bottom:0;">Docker</h1>
|
||||
{% include "_time_range.html" %}
|
||||
</div>
|
||||
|
||||
<div id="docker-rows"
|
||||
hx-get="/plugins/docker/rows"
|
||||
hx-trigger="load, rangeChange from:body"
|
||||
hx-include="#time-range"
|
||||
hx-swap="innerHTML">
|
||||
<div style="color:var(--text-muted);font-size:0.9rem;padding:2rem;">Loading...</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,86 @@
|
||||
{# docker/rows.html — HTMX fragment for the Docker main page #}
|
||||
|
||||
{# ── Summary strip ─────────────────────────────────────────────────────────── #}
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:0.75rem;margin-bottom:1.5rem;">
|
||||
<div class="card" style="margin-bottom:0;">
|
||||
<div class="section-title" style="margin-bottom:0.35rem;">Running</div>
|
||||
<span class="stat-val" style="color:var(--green);">{{ running }}</span>
|
||||
</div>
|
||||
<div class="card" style="margin-bottom:0;">
|
||||
<div class="section-title" style="margin-bottom:0.35rem;">Stopped</div>
|
||||
<span class="stat-val" style="{% if stopped %}color:var(--text-muted){% endif %};">{{ stopped }}</span>
|
||||
</div>
|
||||
<div class="card" style="margin-bottom:0;">
|
||||
<div class="section-title" style="margin-bottom:0.35rem;">Total</div>
|
||||
<span class="stat-val">{{ container_data | length }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Container table ─────────────────────────────────────────────────────── #}
|
||||
{% if container_data %}
|
||||
<div class="card-flush">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Container</th>
|
||||
<th>Image</th>
|
||||
<th>Ports</th>
|
||||
<th>CPU %</th>
|
||||
<th style="min-width:100px;">CPU history</th>
|
||||
<th>Mem %</th>
|
||||
<th style="min-width:100px;">Mem history</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in container_data %}
|
||||
{% set c = item.container %}
|
||||
<tr>
|
||||
<td>
|
||||
<div style="display:flex;align-items:center;gap:0.5rem;">
|
||||
<span class="dot {% if c.status == 'running' %}dot-up{% elif c.status == 'paused' %}dot-warn{% else %}dot-down{% endif %}"></span>
|
||||
<div>
|
||||
<div style="font-weight:500;font-size:0.9rem;">{{ c.name }}</div>
|
||||
<div style="font-size:0.73rem;color:var(--text-muted);">{{ c.status }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td style="font-size:0.82rem;color:var(--text-muted);max-width:200px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">
|
||||
{{ c.image }}
|
||||
</td>
|
||||
<td style="font-size:0.78rem;font-family:ui-monospace,monospace;">
|
||||
{% for p in item.ports %}
|
||||
<div style="color:var(--text-muted);">{{ p.host_port }}:{{ p.container_port }}/{{ p.protocol }}</div>
|
||||
{% endfor %}
|
||||
</td>
|
||||
<td style="font-family:ui-monospace,monospace;font-size:0.88rem;">
|
||||
{% if c.cpu_pct is not none %}
|
||||
<span style="color:{% if c.cpu_pct > 80 %}var(--red){% elif c.cpu_pct > 50 %}var(--orange){% else %}var(--text){% endif %};">
|
||||
{{ "%.1f" | format(c.cpu_pct) }}%
|
||||
</span>
|
||||
{% else %}
|
||||
<span style="color:var(--text-dim);">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ item.sparkline_cpu | safe }}</td>
|
||||
<td style="font-family:ui-monospace,monospace;font-size:0.88rem;">
|
||||
{% if c.mem_pct is not none %}
|
||||
<span style="color:{% if c.mem_pct > 90 %}var(--red){% elif c.mem_pct > 70 %}var(--orange){% else %}var(--text){% endif %};">
|
||||
{{ "%.1f" | format(c.mem_pct) }}%
|
||||
</span>
|
||||
{% else %}
|
||||
<span style="color:var(--text-dim);">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ item.sparkline_mem | safe }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card" style="text-align:center;padding:3rem;">
|
||||
<div style="color:var(--text-muted);font-size:0.9rem;">
|
||||
No containers found. Make sure the Docker socket is accessible and the plugin is configured correctly.
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,35 @@
|
||||
{# docker/widget.html — dashboard widget: container status overview #}
|
||||
{% if not containers and running_count == 0 and stopped_count == 0 %}
|
||||
<div style="color:var(--text-muted);font-size:0.85rem;padding:0.5rem 0;">No containers found.</div>
|
||||
{% else %}
|
||||
|
||||
<div style="display:flex;gap:1rem;margin-bottom:0.65rem;">
|
||||
<div>
|
||||
<span style="font-size:1.4rem;font-weight:700;line-height:1;color:var(--green);">{{ running_count }}</span>
|
||||
<span style="font-size:0.78rem;color:var(--text-muted);margin-left:0.25rem;">running</span>
|
||||
</div>
|
||||
{% if stopped_count %}
|
||||
<div>
|
||||
<span style="font-size:1.4rem;font-weight:700;line-height:1;color:var(--text-muted);">{{ stopped_count }}</span>
|
||||
<span style="font-size:0.78rem;color:var(--text-muted);margin-left:0.25rem;">stopped</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div style="display:grid;gap:2px;">
|
||||
{% for c in containers %}
|
||||
<div style="display:flex;align-items:center;gap:0.5rem;font-size:0.82rem;padding:0.15rem 0;">
|
||||
<span class="dot {% if c.status == 'running' %}dot-up{% elif c.status == 'paused' %}dot-warn{% else %}dot-down{% endif %}"></span>
|
||||
<span style="flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">
|
||||
{{ c.name }}
|
||||
</span>
|
||||
{% if c.cpu_pct is not none %}
|
||||
<span style="font-size:0.72rem;color:var(--text-muted);font-family:ui-monospace,monospace;flex-shrink:0;">
|
||||
{{ "%.1f" | format(c.cpu_pct) }}%
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% endif %}
|
||||
@@ -0,0 +1,36 @@
|
||||
{# docker/widget_resources.html — dashboard widget: CPU + memory bars for running containers #}
|
||||
{% if not containers %}
|
||||
<div style="color:var(--text-muted);font-size:0.85rem;padding:0.5rem 0;">No running containers.</div>
|
||||
{% else %}
|
||||
<div style="display:grid;gap:0.5rem;">
|
||||
{% for c in containers %}
|
||||
<div>
|
||||
<div style="display:flex;justify-content:space-between;align-items:baseline;margin-bottom:0.15rem;">
|
||||
<span style="font-size:0.8rem;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:55%;">
|
||||
{{ c.name }}
|
||||
</span>
|
||||
<span style="font-size:0.72rem;color:var(--text-muted);font-family:ui-monospace,monospace;flex-shrink:0;margin-left:0.5rem;">
|
||||
{% if c.cpu_pct is not none %}CPU {{ "%.1f" | format(c.cpu_pct) }}%{% endif %}
|
||||
{% if c.mem_pct is not none %} · Mem {{ "%.1f" | format(c.mem_pct) }}%{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
{% if c.cpu_pct is not none %}
|
||||
<div style="height:3px;background:var(--bg-elevated);border-radius:2px;margin-bottom:2px;">
|
||||
<div style="height:100%;border-radius:2px;width:{{ [c.cpu_pct, 100] | min }}%;
|
||||
background:{% if c.cpu_pct > 80 %}var(--red){% elif c.cpu_pct > 50 %}var(--orange){% else %}var(--accent){% endif %};
|
||||
transition:width 0.4s;">
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if c.mem_pct is not none %}
|
||||
<div style="height:3px;background:var(--bg-elevated);border-radius:2px;">
|
||||
<div style="height:100%;border-radius:2px;width:{{ [c.mem_pct, 100] | min }}%;
|
||||
background:{% if c.mem_pct > 90 %}var(--red){% elif c.mem_pct > 70 %}var(--orange){% else %}var(--green){% endif %};
|
||||
transition:width 0.4s;">
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -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
|
||||
+105
@@ -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
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
+229
@@ -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'<svg width="{width}" height="{height}"></svg>'
|
||||
mn, mx = min(values), max(values)
|
||||
if mx == mn:
|
||||
mx = mn + 1.0
|
||||
step = width / (len(values) - 1)
|
||||
pts = []
|
||||
for i, v in enumerate(values):
|
||||
x = i * step
|
||||
y = height - (v - mn) / (mx - mn) * (height - 2) - 1
|
||||
pts.append(f"{x:.1f},{y:.1f}")
|
||||
poly = " ".join(pts)
|
||||
return (
|
||||
f'<svg width="{width}" height="{height}" viewBox="0 0 {width} {height}" '
|
||||
f'style="vertical-align:middle;">'
|
||||
f'<polyline points="{poly}" fill="none" stroke="#6060c0" stroke-width="1.5"/>'
|
||||
f'</svg>'
|
||||
)
|
||||
|
||||
|
||||
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("/<monitor_id>/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("/<monitor_id>/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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -0,0 +1,77 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}HTTP Monitors — Fabled Scryer{% endblock %}
|
||||
{% block content %}
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.5rem;gap:1rem;flex-wrap:wrap;">
|
||||
<h1 class="page-title" style="margin-bottom:0;">HTTP Monitors</h1>
|
||||
{% include "_time_range.html" %}
|
||||
</div>
|
||||
|
||||
{# ── Add monitor form ─────────────────────────────────────────────────────── #}
|
||||
<div class="card" style="margin-bottom:1.25rem;">
|
||||
<div class="section-title" style="margin-bottom:0.75rem;">Add Monitor</div>
|
||||
<form method="post" action="/plugins/http/add" style="display:grid;gap:0.6rem;">
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:0.6rem;">
|
||||
<div>
|
||||
<label style="font-size:0.75rem;color:var(--text-muted);display:block;margin-bottom:0.2rem;">Name</label>
|
||||
<input type="text" name="name" placeholder="My Service" autocomplete="off"
|
||||
style="width:100%;padding:0.4rem 0.65rem;background:var(--bg);border:1px solid var(--border-mid);border-radius:4px;color:var(--text);font-size:0.88rem;">
|
||||
</div>
|
||||
<div>
|
||||
<label style="font-size:0.75rem;color:var(--text-muted);display:block;margin-bottom:0.2rem;">URL <span style="color:var(--red);">*</span></label>
|
||||
<input type="text" name="url" placeholder="https://example.com" autocomplete="off" required
|
||||
style="width:100%;padding:0.4rem 0.65rem;background:var(--bg);border:1px solid var(--border-mid);border-radius:4px;color:var(--text);font-size:0.88rem;">
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:0.6rem;align-items:end;">
|
||||
<div>
|
||||
<label style="font-size:0.75rem;color:var(--text-muted);display:block;margin-bottom:0.2rem;">Method</label>
|
||||
<select name="method" style="width:100%;padding:0.4rem 0.65rem;background:var(--bg);border:1px solid var(--border-mid);border-radius:4px;color:var(--text);font-size:0.88rem;">
|
||||
<option value="GET">GET</option>
|
||||
<option value="HEAD">HEAD</option>
|
||||
<option value="POST">POST</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style="font-size:0.75rem;color:var(--text-muted);display:block;margin-bottom:0.2rem;">Expected status</label>
|
||||
<input type="number" name="expected_status" value="200" min="100" max="599"
|
||||
style="width:100%;padding:0.4rem 0.65rem;background:var(--bg);border:1px solid var(--border-mid);border-radius:4px;color:var(--text);font-size:0.88rem;">
|
||||
</div>
|
||||
<div>
|
||||
<label style="font-size:0.75rem;color:var(--text-muted);display:block;margin-bottom:0.2rem;">Timeout (s)</label>
|
||||
<input type="number" name="timeout_seconds" value="10" min="1" max="60"
|
||||
style="width:100%;padding:0.4rem 0.65rem;background:var(--bg);border:1px solid var(--border-mid);border-radius:4px;color:var(--text);font-size:0.88rem;">
|
||||
</div>
|
||||
<div>
|
||||
<label style="font-size:0.75rem;color:var(--text-muted);display:block;margin-bottom:0.2rem;">Interval (s, 0=global)</label>
|
||||
<input type="number" name="check_interval_seconds" value="0" min="0"
|
||||
style="width:100%;padding:0.4rem 0.65rem;background:var(--bg);border:1px solid var(--border-mid);border-radius:4px;color:var(--text);font-size:0.88rem;">
|
||||
</div>
|
||||
<div>
|
||||
<label style="font-size:0.75rem;color:var(--text-muted);display:block;margin-bottom:0.2rem;">Content match (substring)</label>
|
||||
<input type="text" name="content_match" placeholder="optional"
|
||||
style="width:100%;padding:0.4rem 0.65rem;background:var(--bg);border:1px solid var(--border-mid);border-radius:4px;color:var(--text);font-size:0.88rem;">
|
||||
</div>
|
||||
<div style="display:flex;flex-direction:column;gap:0.3rem;padding-top:0.8rem;">
|
||||
<label style="display:flex;align-items:center;gap:0.4rem;font-size:0.82rem;cursor:pointer;">
|
||||
<input type="checkbox" name="follow_redirects" checked> Follow redirects
|
||||
</label>
|
||||
<label style="display:flex;align-items:center;gap:0.4rem;font-size:0.82rem;cursor:pointer;">
|
||||
<input type="checkbox" name="verify_ssl" checked> Verify SSL
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<button type="submit" class="btn btn-sm">Add Monitor</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{# ── Monitor list ─────────────────────────────────────────────────────────── #}
|
||||
<div id="http-rows"
|
||||
hx-get="/plugins/http/rows"
|
||||
hx-trigger="load, every {{ poll_interval }}s, rangeChange from:body"
|
||||
hx-include="#time-range"
|
||||
hx-swap="innerHTML">
|
||||
<div style="color:var(--text-muted);font-size:0.9rem;padding:2rem;">Loading...</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,109 @@
|
||||
{# http/rows.html — HTMX fragment for HTTP monitor status list #}
|
||||
|
||||
{# ── Summary ─────────────────────────────────────────────────────────────── #}
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(130px,1fr));gap:0.75rem;margin-bottom:1.25rem;">
|
||||
<div class="card" style="margin-bottom:0;">
|
||||
<div class="section-title" style="margin-bottom:0.3rem;">Up</div>
|
||||
<span class="stat-val" style="color:var(--green);">{{ up }}</span>
|
||||
</div>
|
||||
<div class="card" style="margin-bottom:0;">
|
||||
<div class="section-title" style="margin-bottom:0.3rem;">Down</div>
|
||||
<span class="stat-val" style="color:{% if down %}var(--red){% else %}var(--text-muted){% endif %};">{{ down }}</span>
|
||||
</div>
|
||||
<div class="card" style="margin-bottom:0;">
|
||||
<div class="section-title" style="margin-bottom:0.3rem;">Monitors</div>
|
||||
<span class="stat-val">{{ monitor_data | length }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Monitor list ─────────────────────────────────────────────────────────── #}
|
||||
{% if monitor_data %}
|
||||
<div class="card-flush">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Monitor</th>
|
||||
<th>Status</th>
|
||||
<th>Response</th>
|
||||
<th style="min-width:90px;">History</th>
|
||||
<th>TLS expiry</th>
|
||||
<th style="width:1%;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in monitor_data %}
|
||||
{% set m = item.monitor %}
|
||||
{% set latest = item.latest %}
|
||||
<tr>
|
||||
<td>
|
||||
<div style="font-weight:500;font-size:0.9rem;">{{ m.name }}</div>
|
||||
<div style="font-size:0.75rem;color:var(--text-muted);font-family:ui-monospace,monospace;word-break:break-all;">
|
||||
<span style="color:var(--text-dim);">{{ m.method }}</span> {{ m.url }}
|
||||
</div>
|
||||
{% if not m.enabled %}
|
||||
<span style="font-size:0.72rem;color:var(--text-dim);">paused</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if not latest %}
|
||||
<span style="color:var(--text-dim);font-size:0.82rem;">pending</span>
|
||||
{% elif latest.is_up %}
|
||||
<div style="display:flex;align-items:center;gap:0.4rem;">
|
||||
<span class="dot dot-up"></span>
|
||||
<span style="font-size:0.82rem;color:var(--green);">{{ latest.status_code }}</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<div style="display:flex;align-items:center;gap:0.4rem;">
|
||||
<span class="dot dot-down"></span>
|
||||
<span style="font-size:0.82rem;color:var(--red);">
|
||||
{{ latest.status_code or latest.error_msg or "error" }}
|
||||
</span>
|
||||
</div>
|
||||
{% if latest.content_matched == false %}
|
||||
<div style="font-size:0.72rem;color:var(--orange);">content mismatch</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td style="font-family:ui-monospace,monospace;font-size:0.88rem;">
|
||||
{% if latest and latest.response_ms is not none %}
|
||||
<span style="color:{% if latest.response_ms > 2000 %}var(--red){% elif latest.response_ms > 500 %}var(--orange){% else %}var(--text){% endif %};">
|
||||
{{ "%.0f" | format(latest.response_ms) }}ms
|
||||
</span>
|
||||
{% else %}
|
||||
<span style="color:var(--text-dim);">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ item.sparkline_ms | safe }}</td>
|
||||
<td style="font-size:0.82rem;">
|
||||
{% if item.tls_days is not none %}
|
||||
<span style="color:{% if item.tls_days < 14 %}var(--red){% elif item.tls_days < 30 %}var(--orange){% else %}var(--text-muted){% endif %};">
|
||||
{{ item.tls_days | int }}d
|
||||
</span>
|
||||
{% elif m.url.startswith('https://') %}
|
||||
<span style="color:var(--text-dim);">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td style="white-space:nowrap;">
|
||||
<form method="post" action="/plugins/http/{{ m.id }}/toggle" style="display:inline;margin:0;">
|
||||
<button type="submit" class="btn btn-ghost btn-sm" style="font-size:0.72rem;">
|
||||
{% if m.enabled %}Pause{% else %}Resume{% endif %}
|
||||
</button>
|
||||
</form>
|
||||
<form method="post" action="/plugins/http/{{ m.id }}/delete"
|
||||
style="display:inline;margin:0;"
|
||||
onsubmit="return confirm('Delete {{ m.name }}?')">
|
||||
<button type="submit" class="btn btn-danger btn-sm" style="font-size:0.72rem;">Del</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card" style="text-align:center;padding:3rem;">
|
||||
<div style="color:var(--text-muted);font-size:0.9rem;">
|
||||
No monitors configured yet. Add one using the form above.
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -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 %}
|
||||
<div style="color:var(--text-muted);font-size:0.85rem;padding:0.5rem 0;">
|
||||
No monitors configured. <a href="/plugins/http/">Add monitors →</a>
|
||||
</div>
|
||||
{% else %}
|
||||
|
||||
<div style="display:flex;gap:1rem;margin-bottom:0.65rem;flex-wrap:wrap;">
|
||||
{% if up %}
|
||||
<div>
|
||||
<span style="font-size:1.4rem;font-weight:700;line-height:1;color:var(--green);">{{ up }}</span>
|
||||
<span style="font-size:0.78rem;color:var(--text-muted);margin-left:0.25rem;">up</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if down %}
|
||||
<div>
|
||||
<span style="font-size:1.4rem;font-weight:700;line-height:1;color:var(--red);">{{ down }}</span>
|
||||
<span style="font-size:0.78rem;color:var(--text-muted);margin-left:0.25rem;">down</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if pending %}
|
||||
<div>
|
||||
<span style="font-size:1.4rem;font-weight:700;line-height:1;color:var(--text-dim);">{{ pending }}</span>
|
||||
<span style="font-size:0.78rem;color:var(--text-muted);margin-left:0.25rem;">pending</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div style="display:grid;gap:3px;">
|
||||
{% for item in monitor_data %}
|
||||
{% set latest = item.latest %}
|
||||
<div style="display:flex;align-items:center;gap:0.5rem;font-size:0.82rem;padding:0.15rem 0;">
|
||||
{% if not latest %}
|
||||
<span class="dot dot-dim"></span>
|
||||
{% elif latest.is_up %}
|
||||
<span class="dot dot-up"></span>
|
||||
{% else %}
|
||||
<span class="dot dot-down"></span>
|
||||
{% endif %}
|
||||
<span style="flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">
|
||||
{{ item.monitor.name }}
|
||||
</span>
|
||||
{% if latest and latest.response_ms is not none %}
|
||||
<span style="font-size:0.72rem;font-family:ui-monospace,monospace;color:var(--text-muted);flex-shrink:0;">
|
||||
{{ "%.0f" | format(latest.response_ms) }}ms
|
||||
</span>
|
||||
{% elif latest and not latest.is_up %}
|
||||
<span style="font-size:0.72rem;color:var(--red);flex-shrink:0;">
|
||||
{{ latest.status_code or "err" }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% endif %}
|
||||
+31
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
+146
@@ -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/<device_name>")
|
||||
@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,
|
||||
)
|
||||
@@ -0,0 +1,84 @@
|
||||
{# plugins/snmp/templates/snmp/device.html #}
|
||||
{% extends "base.html" %}
|
||||
{% block title %}SNMP — {{ device_name }} — Fabled Scryer{% endblock %}
|
||||
{% block content %}
|
||||
<div style="display:flex;align-items:center;gap:1rem;margin-bottom:1.5rem;flex-wrap:wrap;">
|
||||
<a href="/plugins/snmp/" style="color:var(--text-muted);font-size:0.85rem;">← SNMP</a>
|
||||
<h1 class="page-title" style="margin:0;">{{ device_name }}</h1>
|
||||
<span style="font-size:0.8rem;color:var(--text-dim);">{{ device.host }}</span>
|
||||
<div style="margin-left:auto;display:flex;gap:0.5rem;">
|
||||
{% for h in [1, 6, 24, 168] %}
|
||||
<a href="?hours={{ h }}"
|
||||
class="btn btn-ghost"
|
||||
style="font-size:0.78rem;padding:0.2rem 0.6rem;{% if hours == h %}background:var(--accent);color:#fff;border-color:var(--accent);{% endif %}">
|
||||
{% if h < 24 %}{{ h }}h{% elif h == 24 %}24h{% else %}7d{% endif %}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if oid_labels %}
|
||||
<div class="card">
|
||||
<canvas id="snmp-chart" style="width:100%;max-height:320px;"></canvas>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function(){
|
||||
const raw = {{ history_json | safe }};
|
||||
const labels = {{ oid_labels | tojson }};
|
||||
const palette = ["#6060c0","#e07040","#40b080","#c040c0","#4080e0","#e0c040","#c08040"];
|
||||
|
||||
// Collect all unique timestamps, sorted
|
||||
const tsSet = new Set();
|
||||
for (const pts of Object.values(raw)) {
|
||||
for (const [ts] of pts) tsSet.add(ts);
|
||||
}
|
||||
const allTs = Array.from(tsSet).sort();
|
||||
|
||||
if (allTs.length < 2) {
|
||||
document.getElementById("snmp-chart").parentElement.innerHTML =
|
||||
'<p style="color:var(--text-muted);padding:1rem;">Not enough data for a chart yet.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
const datasets = [];
|
||||
labels.forEach((label, i) => {
|
||||
const pts = raw[label] || [];
|
||||
const map = Object.fromEntries(pts.map(([ts, v]) => [ts, v]));
|
||||
datasets.push({
|
||||
label,
|
||||
data: allTs.map(ts => map[ts] ?? null),
|
||||
borderColor: palette[i % palette.length],
|
||||
backgroundColor: "transparent",
|
||||
borderWidth: 1.5,
|
||||
pointRadius: 0,
|
||||
tension: 0.2,
|
||||
spanGaps: true,
|
||||
});
|
||||
});
|
||||
|
||||
const ctx = document.getElementById("snmp-chart").getContext("2d");
|
||||
new Chart(ctx, {
|
||||
type: "line",
|
||||
data: { labels: allTs.map(ts => ts.substring(11, 16)), datasets },
|
||||
options: {
|
||||
responsive: true,
|
||||
interaction: { mode: "index", intersect: false },
|
||||
plugins: {
|
||||
legend: { position: "top", labels: { color: "#aaa", boxWidth: 12 } },
|
||||
tooltip: { backgroundColor: "#1e1e2e", titleColor: "#ccc", bodyColor: "#aaa" },
|
||||
},
|
||||
scales: {
|
||||
x: { ticks: { color: "#888", maxTicksLimit: 12 }, grid: { color: "#2a2a3a" } },
|
||||
y: { ticks: { color: "#888" }, grid: { color: "#2a2a3a" } },
|
||||
},
|
||||
},
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% else %}
|
||||
<div class="card" style="color:var(--text-muted);text-align:center;padding:2rem;">
|
||||
No OIDs configured for this device.
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,57 @@
|
||||
{# plugins/snmp/templates/snmp/index.html #}
|
||||
{% extends "base.html" %}
|
||||
{% block title %}SNMP — Fabled Scryer{% endblock %}
|
||||
{% block content %}
|
||||
<div style="display:flex;align-items:center;gap:1rem;margin-bottom:1.5rem;">
|
||||
<h1 class="page-title" style="margin:0;">SNMP Devices</h1>
|
||||
<span style="font-size:0.8rem;color:var(--text-dim);">{{ devices|length }} device(s) configured</span>
|
||||
</div>
|
||||
|
||||
{% if not devices %}
|
||||
<div class="card" style="color:var(--text-muted);text-align:center;padding:2rem;">
|
||||
No SNMP devices configured. Add devices to <code>plugin.yaml</code>.
|
||||
</div>
|
||||
{% else %}
|
||||
|
||||
{% for device in devices %}
|
||||
<div class="card" style="margin-bottom:1rem;">
|
||||
<div style="display:flex;align-items:center;gap:1rem;margin-bottom:1rem;">
|
||||
<h2 style="margin:0;font-size:1rem;font-weight:600;">{{ device.name }}</h2>
|
||||
<span style="font-size:0.78rem;color:var(--text-dim);">{{ device.host }}</span>
|
||||
{% if device.readings %}
|
||||
<span style="font-size:0.75rem;color:var(--green);margin-left:auto;">● reachable</span>
|
||||
{% else %}
|
||||
<span style="font-size:0.75rem;color:var(--text-dim);margin-left:auto;">○ no data yet</span>
|
||||
{% endif %}
|
||||
<a href="/plugins/snmp/device/{{ device.name }}" class="btn btn-ghost" style="font-size:0.78rem;padding:0.2rem 0.6rem;">History</a>
|
||||
</div>
|
||||
|
||||
{% if device.readings %}
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:0.75rem;">
|
||||
{% 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) %}
|
||||
<div style="background:var(--bg-alt);border-radius:6px;padding:0.6rem 0.9rem;">
|
||||
<div style="font-size:0.72rem;color:var(--text-dim);margin-bottom:0.2rem;font-family:monospace;">{{ label }}</div>
|
||||
{% if val is not none %}
|
||||
<div style="font-size:1.1rem;font-weight:600;font-variant-numeric:tabular-nums;">
|
||||
{% if val >= 1000000 %}{{ "%.2f"|format(val / 1000000) }}<span style="font-size:0.75rem;color:var(--text-muted);">M</span>
|
||||
{% elif val >= 1000 %}{{ "%.1f"|format(val / 1000) }}<span style="font-size:0.75rem;color:var(--text-muted);">K</span>
|
||||
{% else %}{{ "%.4g"|format(val) }}{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div style="font-size:0.85rem;color:var(--text-dim);">—</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p style="font-size:0.85rem;color:var(--text-muted);margin:0;">
|
||||
No readings yet — waiting for next poll cycle.
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,49 @@
|
||||
{# plugins/snmp/templates/snmp/widget.html #}
|
||||
{% if devices %}
|
||||
{% for device in devices %}
|
||||
<div class="ping-row" style="align-items:flex-start;flex-direction:column;gap:0.3rem;padding:0.4rem 0;">
|
||||
<div style="display:flex;align-items:center;gap:0.6rem;width:100%;">
|
||||
<span style="font-size:0.82rem;font-weight:500;flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">
|
||||
{{ device.name }}
|
||||
</span>
|
||||
{% if device.readings %}
|
||||
<span style="font-size:0.7rem;color:var(--green);">●</span>
|
||||
{% else %}
|
||||
<span style="font-size:0.7rem;color:var(--text-dim);">○</span>
|
||||
{% endif %}
|
||||
<a href="/plugins/snmp/device/{{ device.name }}" style="font-size:0.72rem;color:var(--text-muted);">detail →</a>
|
||||
</div>
|
||||
{% if device.readings %}
|
||||
<div style="display:flex;flex-wrap:wrap;gap:0.5rem 1rem;padding-left:0.1rem;">
|
||||
{% 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 %}
|
||||
<span style="font-size:0.78rem;font-variant-numeric:tabular-nums;">
|
||||
<span style="color:var(--text-muted);">{{ label }}</span>
|
||||
<span style="margin-left:0.25rem;">
|
||||
{% if val >= 1000000 %}{{ "%.1f"|format(val / 1000000) }}M
|
||||
{% elif val >= 1000 %}{{ "%.0f"|format(val / 1000) }}K
|
||||
{% else %}{{ "%.4g"|format(val) }}{% endif %}
|
||||
</span>
|
||||
</span>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if device.oids|length > 4 %}
|
||||
<span style="font-size:0.72rem;color:var(--text-dim);">+{{ device.oids|length - 4 }} more</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if not loop.last %}
|
||||
<div style="height:1px;background:var(--border-low);margin:0.1rem 0;"></div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if total > devices|length %}
|
||||
<div style="color:var(--text-dim);font-size:0.75rem;padding:0.25rem 0;">
|
||||
+{{ total - devices|length }} more — <a href="/plugins/snmp/">view all →</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<p class="empty" style="padding:0.5rem 0;font-size:0.85rem;">No SNMP devices configured.</p>
|
||||
{% endif %}
|
||||
+97
-1
@@ -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():
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
{# traefik/widget_access_log.html — dashboard widget: traffic origin summary #}
|
||||
{% if not summary %}
|
||||
<div style="color:var(--text-muted);font-size:0.85rem;padding:0.5rem 0;">No access log data yet.</div>
|
||||
{% else %}
|
||||
|
||||
<div style="display:flex;gap:1rem;margin-bottom:0.75rem;flex-wrap:wrap;">
|
||||
<div>
|
||||
<span style="font-size:1.4rem;font-weight:700;line-height:1;">{{ summary.total | int }}</span>
|
||||
<span style="font-size:0.78rem;color:var(--text-muted);margin-left:0.25rem;">total req</span>
|
||||
</div>
|
||||
<div>
|
||||
<span style="font-size:1.4rem;font-weight:700;line-height:1;color:var(--orange);">
|
||||
{{ "%.1f" | format(summary.external_pct) }}%
|
||||
</span>
|
||||
<span style="font-size:0.78rem;color:var(--text-muted);margin-left:0.25rem;">external</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if summary.top_ips %}
|
||||
<div style="font-size:0.72rem;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.05em;margin-bottom:0.4rem;">
|
||||
Top IPs
|
||||
{% if summary.ip_filter != "all" %}
|
||||
<span style="color:var(--accent);margin-left:0.4rem;">{{ summary.ip_filter }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div style="display:grid;gap:2px;">
|
||||
{% for ip in summary.top_ips %}
|
||||
<div style="display:flex;align-items:center;gap:0.5rem;font-size:0.82rem;padding:0.2rem 0;">
|
||||
<span class="dot {% if ip.internal %}dot-dim{% else %}dot-warn{% endif %}"></span>
|
||||
<span style="flex:1;font-family:ui-monospace,monospace;font-size:0.78rem;
|
||||
color:{% if ip.internal %}var(--text-muted){% else %}var(--text){% endif %};">
|
||||
{{ ip.ip }}
|
||||
</span>
|
||||
<span style="color:var(--text-muted);font-size:0.78rem;">{{ ip.count }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% endif %}
|
||||
@@ -0,0 +1,75 @@
|
||||
{# traefik/widget_request_chart.html — Chart.js request rate + error % over time #}
|
||||
{% if not labels_json or labels_json == '[]' %}
|
||||
<div style="color:var(--text-muted);font-size:0.85rem;padding:0.5rem 0;">No data for last {{ hours }}h.</div>
|
||||
{% else %}
|
||||
<div style="position:relative;height:160px;">
|
||||
<canvas id="chart-traefik-req-{{ widget_id }}"></canvas>
|
||||
</div>
|
||||
<script>
|
||||
(function() {
|
||||
var ctx = document.getElementById("chart-traefik-req-{{ widget_id }}");
|
||||
if (!ctx || !window.Chart) return;
|
||||
var existing = Chart.getChart(ctx);
|
||||
if (existing) existing.destroy();
|
||||
new Chart(ctx.getContext("2d"), {
|
||||
type: "line",
|
||||
data: {
|
||||
labels: {{ labels_json | safe }},
|
||||
datasets: [
|
||||
{
|
||||
label: "Req/s",
|
||||
data: {{ req_rate_json | safe }},
|
||||
borderColor: "#7c5ef4",
|
||||
backgroundColor: "rgba(124,94,244,0.08)",
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
pointRadius: 0,
|
||||
borderWidth: 1.5,
|
||||
yAxisID: "y",
|
||||
},
|
||||
{
|
||||
label: "5xx %",
|
||||
data: {{ error_rate_json | safe }},
|
||||
borderColor: "#e83848",
|
||||
fill: false,
|
||||
tension: 0.3,
|
||||
pointRadius: 0,
|
||||
borderWidth: 1.5,
|
||||
yAxisID: "y2",
|
||||
},
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
labels: { color: "#6858a0", boxWidth: 10, font: { size: 10 } }
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: { display: false },
|
||||
y: {
|
||||
min: 0,
|
||||
grid: { color: "rgba(30,30,62,0.8)" },
|
||||
ticks: { color: "#6858a0", font: { size: 10 } },
|
||||
title: { display: true, text: "req/s", color: "#6858a0", font: { size: 9 } },
|
||||
},
|
||||
y2: {
|
||||
position: "right",
|
||||
min: 0,
|
||||
max: 100,
|
||||
grid: { drawOnChartArea: false },
|
||||
ticks: { color: "#e83848", font: { size: 10 } },
|
||||
title: { display: true, text: "5xx%", color: "#e83848", font: { size: 9 } },
|
||||
},
|
||||
}
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
<div style="font-size:0.72rem;color:var(--text-muted);margin-top:0.35rem;text-align:right;">
|
||||
last {{ hours }}h
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
{# unifi/widget_clients.html — dashboard widget: top active clients #}
|
||||
{% if not snapshot %}
|
||||
<div style="color:var(--text-muted);font-size:0.85rem;padding:0.5rem 0;">No client data yet.</div>
|
||||
{% else %}
|
||||
|
||||
<div style="display:flex;gap:1rem;margin-bottom:0.75rem;flex-wrap:wrap;">
|
||||
<div>
|
||||
<span style="font-size:1.4rem;font-weight:700;line-height:1;">{{ snapshot.total_clients }}</span>
|
||||
<span style="font-size:0.78rem;color:var(--text-muted);margin-left:0.25rem;">total</span>
|
||||
</div>
|
||||
<div>
|
||||
<span style="font-size:1.4rem;font-weight:700;line-height:1;color:var(--accent);">{{ snapshot.wireless_clients }}</span>
|
||||
<span style="font-size:0.78rem;color:var(--text-muted);margin-left:0.25rem;">wireless</span>
|
||||
</div>
|
||||
<div>
|
||||
<span style="font-size:1.4rem;font-weight:700;line-height:1;color:var(--text-muted);">{{ snapshot.wired_clients }}</span>
|
||||
<span style="font-size:0.78rem;color:var(--text-muted);margin-left:0.25rem;">wired</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if clients %}
|
||||
<div style="display:grid;gap:3px;">
|
||||
{% for c in clients %}
|
||||
<div style="display:flex;align-items:center;gap:0.5rem;font-size:0.82rem;padding:0.15rem 0;">
|
||||
<span class="dot {% if c.type == 'wireless' %}dot-up{% else %}dot-dim{% endif %}"></span>
|
||||
<span style="flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">
|
||||
{{ c.hostname or c.ip or c.mac }}
|
||||
</span>
|
||||
{% if c.tx_rate or c.rx_rate %}
|
||||
<span style="font-size:0.75rem;color:var(--text-muted);font-family:ui-monospace,monospace;flex-shrink:0;">
|
||||
{% if c.tx_rate %}↑{{ "%.0f" | format(c.tx_rate / 1000) }}k{% endif %}
|
||||
{% if c.rx_rate %} ↓{{ "%.0f" | format(c.rx_rate / 1000) }}k{% endif %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div style="color:var(--text-muted);font-size:0.82rem;">No clients connected.</div>
|
||||
{% endif %}
|
||||
|
||||
{% endif %}
|
||||
@@ -0,0 +1,36 @@
|
||||
{# unifi/widget_devices.html — dashboard widget: infrastructure devices #}
|
||||
{% if not devices %}
|
||||
<div style="color:var(--text-muted);font-size:0.85rem;padding:0.5rem 0;">
|
||||
{% if type_filter != "all" %}No {{ type_filter }} devices found.{% else %}No devices found.{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
|
||||
{% set online = devices | selectattr("state", "equalto", 1) | list %}
|
||||
{% set offline = devices | rejectattr("state", "equalto", 1) | list %}
|
||||
|
||||
<div style="display:flex;gap:1rem;margin-bottom:0.75rem;">
|
||||
<div>
|
||||
<span style="font-size:1.4rem;font-weight:700;line-height:1;color:var(--green);">{{ online | length }}</span>
|
||||
<span style="font-size:0.78rem;color:var(--text-muted);margin-left:0.25rem;">online</span>
|
||||
</div>
|
||||
{% if offline %}
|
||||
<div>
|
||||
<span style="font-size:1.4rem;font-weight:700;line-height:1;color:var(--red);">{{ offline | length }}</span>
|
||||
<span style="font-size:0.78rem;color:var(--text-muted);margin-left:0.25rem;">offline</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div style="display:grid;gap:3px;">
|
||||
{% for d in devices %}
|
||||
<div style="display:flex;align-items:center;gap:0.5rem;font-size:0.82rem;padding:0.15rem 0;">
|
||||
<span class="dot {% if d.state == 1 %}dot-up{% else %}dot-down{% endif %}"></span>
|
||||
<span style="flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">
|
||||
{{ d.name or d.mac }}
|
||||
</span>
|
||||
<span style="font-size:0.72rem;color:var(--text-dim);flex-shrink:0;">{{ d.model or d.device_type or "" }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% endif %}
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
{# ups/widget_history.html — Chart.js multi-line: battery%, load%, input voltage #}
|
||||
{% if not labels_json or labels_json == '[]' %}
|
||||
<div style="color:var(--text-muted);font-size:0.85rem;padding:0.5rem 0;">No UPS history for last {{ hours }}h.</div>
|
||||
{% else %}
|
||||
<div style="position:relative;height:160px;">
|
||||
<canvas id="chart-ups-hist-{{ widget_id }}"></canvas>
|
||||
</div>
|
||||
<script>
|
||||
(function() {
|
||||
var ctx = document.getElementById("chart-ups-hist-{{ widget_id }}");
|
||||
if (!ctx || !window.Chart) return;
|
||||
var existing = Chart.getChart(ctx);
|
||||
if (existing) existing.destroy();
|
||||
new Chart(ctx.getContext("2d"), {
|
||||
type: "line",
|
||||
data: {
|
||||
labels: {{ labels_json | safe }},
|
||||
datasets: [
|
||||
{
|
||||
label: "Battery %",
|
||||
data: {{ battery_json | safe }},
|
||||
borderColor: "#26d96b",
|
||||
fill: false,
|
||||
tension: 0.3,
|
||||
pointRadius: 0,
|
||||
borderWidth: 1.5,
|
||||
yAxisID: "y",
|
||||
},
|
||||
{
|
||||
label: "Load %",
|
||||
data: {{ load_json | safe }},
|
||||
borderColor: "#e07828",
|
||||
fill: false,
|
||||
tension: 0.3,
|
||||
pointRadius: 0,
|
||||
borderWidth: 1.5,
|
||||
yAxisID: "y",
|
||||
},
|
||||
{
|
||||
label: "Input V",
|
||||
data: {{ voltage_json | safe }},
|
||||
borderColor: "#7c5ef4",
|
||||
fill: false,
|
||||
tension: 0.3,
|
||||
pointRadius: 0,
|
||||
borderWidth: 1.5,
|
||||
yAxisID: "y2",
|
||||
},
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
labels: { color: "#6858a0", boxWidth: 10, font: { size: 10 } }
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: { display: false },
|
||||
y: {
|
||||
min: 0,
|
||||
max: 100,
|
||||
grid: { color: "rgba(30,30,62,0.8)" },
|
||||
ticks: { color: "#6858a0", font: { size: 10 } },
|
||||
title: { display: true, text: "%", color: "#6858a0", font: { size: 9 } },
|
||||
},
|
||||
y2: {
|
||||
position: "right",
|
||||
grid: { drawOnChartArea: false },
|
||||
ticks: { color: "#7c5ef4", font: { size: 10 } },
|
||||
title: { display: true, text: "V", color: "#7c5ef4", font: { size: 9 } },
|
||||
},
|
||||
}
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
<div style="font-size:0.72rem;color:var(--text-muted);margin-top:0.35rem;text-align:right;">
|
||||
last {{ hours }}h
|
||||
</div>
|
||||
{% endif %}
|
||||
Reference in New Issue
Block a user