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,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 %}
|
||||
Reference in New Issue
Block a user