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:
2026-03-23 17:40:36 -04:00
parent d9886e8680
commit be4654fc72
45 changed files with 2701 additions and 1 deletions
+97 -1
View File
@@ -71,7 +71,7 @@ async def rows():
b_secs = bucket_seconds(since)
bucket_col = (
cast(func.strftime('%s', TraefikMetric.scraped_at), Integer) / b_secs
cast(func.extract('epoch', TraefikMetric.scraped_at), Integer) / b_secs
).label("bucket")
router_data = []
@@ -195,6 +195,102 @@ async def widget():
)
@traefik_bp.get("/widget/access_log")
@require_role(UserRole.viewer)
async def widget_access_log():
"""HTMX dashboard widget: traffic origin summary with IP filter."""
from collections import defaultdict
ip_filter = request.args.get("ip_filter", "all")
limit = max(1, min(50, int(request.args.get("limit", 10) or 10)))
widget_id = request.args.get("wid", "0")
async with current_app.db_sessionmaker() as db:
result = await db.execute(
select(TraefikAccessSummary)
.order_by(TraefikAccessSummary.period_end.desc())
.limit(12) # last ~1 hour of 5-min windows
)
rows = result.scalars().all()
if not rows:
return await render_template("traefik/widget_access_log.html",
summary=None, widget_id=widget_id)
total = sum(r.total_requests for r in rows)
internal = sum(r.internal_requests for r in rows)
external = sum(r.external_requests for r in rows)
ip_counts: dict = defaultdict(lambda: {"count": 0, "internal": True})
for row in rows:
for entry in json.loads(row.top_ips_json):
k = entry["ip"]
ip_counts[k]["count"] += entry["count"]
ip_counts[k]["internal"] = entry.get("internal", True)
all_ips = sorted(
[{"ip": k, **v} for k, v in ip_counts.items()],
key=lambda x: x["count"], reverse=True,
)
if ip_filter == "internal":
all_ips = [e for e in all_ips if e["internal"]]
elif ip_filter == "external":
all_ips = [e for e in all_ips if not e["internal"]]
summary = {
"total": total,
"internal": internal,
"external": external,
"external_pct": (external / total * 100.0) if total else 0.0,
"top_ips": all_ips[:limit],
"ip_filter": ip_filter,
}
return await render_template("traefik/widget_access_log.html",
summary=summary, widget_id=widget_id)
@traefik_bp.get("/widget/request_chart")
@require_role(UserRole.viewer)
async def widget_request_chart():
"""HTMX dashboard widget: Chart.js request rate + error % over time."""
from datetime import timedelta
hours = max(1, min(24, int(request.args.get("hours", 6) or 6)))
widget_id = request.args.get("wid", "0")
since = datetime.now(timezone.utc) - timedelta(hours=hours)
b_secs = max(60, (hours * 3600) // 80) # ~80 buckets
bucket_col = (
cast(func.extract('epoch', TraefikMetric.scraped_at), Integer) / b_secs
).label("bucket")
async with current_app.db_sessionmaker() as db:
result = await db.execute(
select(
func.avg(TraefikMetric.request_rate).label("req_rate"),
func.avg(TraefikMetric.error_rate_5xx_pct).label("err_pct"),
func.min(TraefikMetric.scraped_at).label("scraped_at"),
bucket_col,
)
.where(TraefikMetric.scraped_at >= since)
.group_by(bucket_col)
.order_by(bucket_col)
)
history = result.all()
labels = list(range(len(history)))
req_rates = [round(r.req_rate or 0, 3) for r in history]
err_pcts = [round(r.err_pct or 0, 2) for r in history]
return await render_template(
"traefik/widget_request_chart.html",
labels_json=json.dumps(labels),
req_rate_json=json.dumps(req_rates),
error_rate_json=json.dumps(err_pcts),
widget_id=widget_id,
hours=hours,
)
@traefik_bp.get("/traffic")
@require_role(UserRole.viewer)
async def traffic():
@@ -0,0 +1,40 @@
{# traefik/widget_access_log.html — dashboard widget: traffic origin summary #}
{% if not summary %}
<div style="color:var(--text-muted);font-size:0.85rem;padding:0.5rem 0;">No access log data yet.</div>
{% else %}
<div style="display:flex;gap:1rem;margin-bottom:0.75rem;flex-wrap:wrap;">
<div>
<span style="font-size:1.4rem;font-weight:700;line-height:1;">{{ summary.total | int }}</span>
<span style="font-size:0.78rem;color:var(--text-muted);margin-left:0.25rem;">total req</span>
</div>
<div>
<span style="font-size:1.4rem;font-weight:700;line-height:1;color:var(--orange);">
{{ "%.1f" | format(summary.external_pct) }}%
</span>
<span style="font-size:0.78rem;color:var(--text-muted);margin-left:0.25rem;">external</span>
</div>
</div>
{% if summary.top_ips %}
<div style="font-size:0.72rem;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.05em;margin-bottom:0.4rem;">
Top IPs
{% if summary.ip_filter != "all" %}
<span style="color:var(--accent);margin-left:0.4rem;">{{ summary.ip_filter }}</span>
{% endif %}
</div>
<div style="display:grid;gap:2px;">
{% for ip in summary.top_ips %}
<div style="display:flex;align-items:center;gap:0.5rem;font-size:0.82rem;padding:0.2rem 0;">
<span class="dot {% if ip.internal %}dot-dim{% else %}dot-warn{% endif %}"></span>
<span style="flex:1;font-family:ui-monospace,monospace;font-size:0.78rem;
color:{% if ip.internal %}var(--text-muted){% else %}var(--text){% endif %};">
{{ ip.ip }}
</span>
<span style="color:var(--text-muted);font-size:0.78rem;">{{ ip.count }}</span>
</div>
{% endfor %}
</div>
{% endif %}
{% endif %}
@@ -0,0 +1,75 @@
{# traefik/widget_request_chart.html — Chart.js request rate + error % over time #}
{% if not labels_json or labels_json == '[]' %}
<div style="color:var(--text-muted);font-size:0.85rem;padding:0.5rem 0;">No data for last {{ hours }}h.</div>
{% else %}
<div style="position:relative;height:160px;">
<canvas id="chart-traefik-req-{{ widget_id }}"></canvas>
</div>
<script>
(function() {
var ctx = document.getElementById("chart-traefik-req-{{ widget_id }}");
if (!ctx || !window.Chart) return;
var existing = Chart.getChart(ctx);
if (existing) existing.destroy();
new Chart(ctx.getContext("2d"), {
type: "line",
data: {
labels: {{ labels_json | safe }},
datasets: [
{
label: "Req/s",
data: {{ req_rate_json | safe }},
borderColor: "#7c5ef4",
backgroundColor: "rgba(124,94,244,0.08)",
fill: true,
tension: 0.3,
pointRadius: 0,
borderWidth: 1.5,
yAxisID: "y",
},
{
label: "5xx %",
data: {{ error_rate_json | safe }},
borderColor: "#e83848",
fill: false,
tension: 0.3,
pointRadius: 0,
borderWidth: 1.5,
yAxisID: "y2",
},
]
},
options: {
responsive: true,
maintainAspectRatio: false,
animation: false,
plugins: {
legend: {
labels: { color: "#6858a0", boxWidth: 10, font: { size: 10 } }
}
},
scales: {
x: { display: false },
y: {
min: 0,
grid: { color: "rgba(30,30,62,0.8)" },
ticks: { color: "#6858a0", font: { size: 10 } },
title: { display: true, text: "req/s", color: "#6858a0", font: { size: 9 } },
},
y2: {
position: "right",
min: 0,
max: 100,
grid: { drawOnChartArea: false },
ticks: { color: "#e83848", font: { size: 10 } },
title: { display: true, text: "5xx%", color: "#e83848", font: { size: 9 } },
},
}
}
});
})();
</script>
<div style="font-size:0.72rem;color:var(--text-muted);margin-top:0.35rem;text-align:right;">
last {{ hours }}h
</div>
{% endif %}