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:
|
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
|
- name: traefik
|
||||||
version: "1.0.0"
|
version: "1.0.0"
|
||||||
description: "Traefik reverse proxy metrics and access log integration"
|
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)
|
b_secs = bucket_seconds(since)
|
||||||
bucket_col = (
|
bucket_col = (
|
||||||
cast(func.strftime('%s', TraefikMetric.scraped_at), Integer) / b_secs
|
cast(func.extract('epoch', TraefikMetric.scraped_at), Integer) / b_secs
|
||||||
).label("bucket")
|
).label("bucket")
|
||||||
|
|
||||||
router_data = []
|
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")
|
@traefik_bp.get("/traffic")
|
||||||
@require_role(UserRole.viewer)
|
@require_role(UserRole.viewer)
|
||||||
async def traffic():
|
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,
|
active_alarms=active_alarms,
|
||||||
top_clients=top_clients,
|
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
|
- nut
|
||||||
|
|
||||||
config:
|
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_host: "localhost"
|
||||||
nut_port: 3493
|
nut_port: 3493
|
||||||
ups_name: "ups"
|
ups_name: "ups"
|
||||||
nut_username: "" # leave blank if NUT is configured without auth
|
nut_username: "" # leave blank if NUT is configured without auth
|
||||||
nut_password: ""
|
nut_password: ""
|
||||||
poll_interval_seconds: 30
|
poll_interval_seconds: 30
|
||||||
|
# ── Ansible shutdown automation ────────────────────────────────────────────
|
||||||
shutdown_after_seconds: 300 # seconds on battery before triggering shutdown (0 = disabled)
|
shutdown_after_seconds: 300 # seconds on battery before triggering shutdown (0 = disabled)
|
||||||
shutdown_source: "" # Ansible source name containing the shutdown playbook
|
shutdown_source: "" # Ansible source name containing the shutdown playbook
|
||||||
shutdown_playbook: "" # relative path within source, e.g. "shutdown.yml"
|
shutdown_playbook: "" # relative path within source, e.g. "shutdown.yml"
|
||||||
|
|||||||
@@ -123,3 +123,50 @@ async def widget():
|
|||||||
on_battery_since=_on_battery_since,
|
on_battery_since=_on_battery_since,
|
||||||
shutdown_triggered=_shutdown_triggered,
|
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