feat: traefik UI (per-router dashboard with SVG sparklines)
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
# plugins/traefik/routes.py
|
||||
from __future__ import annotations
|
||||
from quart import Blueprint, current_app, render_template
|
||||
from sqlalchemy import select
|
||||
|
||||
from fablednetmon.auth.middleware import require_role
|
||||
from fablednetmon.models.users import UserRole
|
||||
from plugins.traefik.models import TraefikMetric
|
||||
|
||||
traefik_bp = Blueprint("traefik", __name__, template_folder="templates")
|
||||
|
||||
|
||||
def _sparkline(values: list[float], width: int = 80, height: int = 20) -> str:
|
||||
"""Generate an inline SVG sparkline from a list of float values."""
|
||||
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>'
|
||||
)
|
||||
|
||||
|
||||
@traefik_bp.get("/")
|
||||
@require_role(UserRole.viewer)
|
||||
async def index():
|
||||
# Last 20 data points per router for sparklines
|
||||
history_limit = 20
|
||||
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
# Get distinct router names with a recent row
|
||||
result = await db.execute(
|
||||
select(TraefikMetric.router_name)
|
||||
.distinct()
|
||||
.order_by(TraefikMetric.router_name)
|
||||
)
|
||||
routers = [row[0] for row in result.all()]
|
||||
|
||||
router_data = []
|
||||
for router in routers:
|
||||
result = await db.execute(
|
||||
select(TraefikMetric)
|
||||
.where(TraefikMetric.router_name == router)
|
||||
.order_by(TraefikMetric.scraped_at.desc())
|
||||
.limit(history_limit)
|
||||
)
|
||||
rows = list(reversed(result.scalars().all()))
|
||||
if not rows:
|
||||
continue
|
||||
latest = rows[-1]
|
||||
router_data.append({
|
||||
"name": router,
|
||||
"latest": latest,
|
||||
"sparkline_req": _sparkline([r.request_rate for r in rows]),
|
||||
"sparkline_p95": _sparkline([r.latency_p95_ms for r in rows]),
|
||||
"sparkline_5xx": _sparkline([r.error_rate_5xx_pct for r in rows]),
|
||||
})
|
||||
|
||||
return await render_template("traefik/index.html", router_data=router_data)
|
||||
@@ -0,0 +1,53 @@
|
||||
{# plugins/traefik/templates/traefik/index.html #}
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Traefik — FabledNetMon{% endblock %}
|
||||
{% block content %}
|
||||
<h1 style="color:#c0c0ff;margin-bottom:1.5rem;">Traefik Routers</h1>
|
||||
{% if not router_data %}
|
||||
<div class="card">
|
||||
<p style="color:#606080;">No Traefik metrics yet. Configure <code>metrics_url</code> in <code>config.yaml</code> under <code>plugins.traefik</code>.</p>
|
||||
</div>
|
||||
{% else %}
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(340px,1fr));gap:1rem;">
|
||||
{% for r in router_data %}
|
||||
<div class="card">
|
||||
<h3 style="color:#8080ff;margin-bottom:0.75rem;font-size:0.95rem;word-break:break-all;">{{ r.name }}</h3>
|
||||
<table style="width:100%;border-collapse:collapse;font-size:0.85rem;">
|
||||
<tr>
|
||||
<td style="color:#606080;padding:0.2rem 0;">Req/s</td>
|
||||
<td style="color:#c0c0e0;padding:0.2rem 0.5rem;">{{ "%.2f"|format(r.latest.request_rate) }}</td>
|
||||
<td style="padding:0.2rem 0;">{{ r.sparkline_req | safe }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="color:#606080;padding:0.2rem 0;">p95 ms</td>
|
||||
<td style="color:#c0c0e0;padding:0.2rem 0.5rem;">{{ "%.1f"|format(r.latest.latency_p95_ms) }}</td>
|
||||
<td style="padding:0.2rem 0;">{{ r.sparkline_p95 | safe }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="color:#606080;padding:0.2rem 0;">p99 ms</td>
|
||||
<td style="color:#e0e0e0;padding:0.2rem 0.5rem;">{{ "%.1f"|format(r.latest.latency_p99_ms) }}</td>
|
||||
<td style="padding:0.2rem 0;"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="color:#606080;padding:0.2rem 0;">4xx %</td>
|
||||
<td style="color:{% if r.latest.error_rate_4xx_pct > 1 %}#c0a000{% else %}#40a040{% endif %};padding:0.2rem 0.5rem;">
|
||||
{{ "%.1f"|format(r.latest.error_rate_4xx_pct) }}%
|
||||
</td>
|
||||
<td style="padding:0.2rem 0;"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="color:#606080;padding:0.2rem 0;">5xx %</td>
|
||||
<td style="color:{% if r.latest.error_rate_5xx_pct > 0.1 %}#a04040{% else %}#40a040{% endif %};padding:0.2rem 0.5rem;">
|
||||
{{ "%.1f"|format(r.latest.error_rate_5xx_pct) }}%
|
||||
</td>
|
||||
<td style="padding:0.2rem 0;">{{ r.sparkline_5xx | safe }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
<div style="color:#404060;font-size:0.75rem;margin-top:0.5rem;">
|
||||
Last scraped {{ r.latest.scraped_at.strftime("%H:%M:%S") }} UTC
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user