chore: move plugins/ to separate repo, gitignore the directory
plugins/ is now tracked at bvandeusen/fabledscryer-plugins. Removed tracked traefik files from this repo's index (files remain on disk). Added plugins/ to .gitignore so the directory is ignored going forward. PLUGIN_DIR in config.yaml still points to plugins/ for local dev and Docker. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1 +0,0 @@
|
||||
# plugins/__init__.py
|
||||
@@ -1,25 +0,0 @@
|
||||
# plugins/traefik/__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
|
||||
# Import model to register with SQLAlchemy metadata
|
||||
from .models import TraefikMetric # noqa: F401
|
||||
|
||||
|
||||
def get_scheduled_tasks() -> list:
|
||||
from .scheduler import make_scrape_task
|
||||
return [make_scrape_task(_app)]
|
||||
|
||||
|
||||
def get_blueprint():
|
||||
from .routes import traefik_bp
|
||||
return traefik_bp
|
||||
@@ -1,75 +0,0 @@
|
||||
# plugins/traefik/migrations/env.py
|
||||
"""Alembic env.py for the Traefik plugin.
|
||||
|
||||
For standalone use during plugin development.
|
||||
At app startup, migration_runner.py uses the core env.py with version_locations.
|
||||
"""
|
||||
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
|
||||
|
||||
# Make fablednetmon importable
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent))
|
||||
|
||||
from fablednetmon.models.base import Base
|
||||
import fablednetmon.models # noqa: F401 — core models
|
||||
from plugins.traefik.models import TraefikMetric # noqa: F401 — plugin model
|
||||
|
||||
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("FABLEDNETMON_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("FABLEDNETMON_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()
|
||||
@@ -1,37 +0,0 @@
|
||||
# plugins/traefik/migrations/versions/traefik_001_initial.py
|
||||
"""Traefik metrics table
|
||||
|
||||
Revision ID: traefik_001_initial
|
||||
Revises: (none — this is a branch off the core head via depends_on)
|
||||
Create Date: 2026-03-17
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = "traefik_001_initial"
|
||||
down_revision: Union[str, None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = "traefik"
|
||||
depends_on: Union[str, Sequence[str], None] = ("0004_app_settings",)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"traefik_metrics",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("router_name", sa.String(255), nullable=False),
|
||||
sa.Column("scraped_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("request_rate", sa.Float, nullable=False),
|
||||
sa.Column("error_rate_4xx_pct", sa.Float, nullable=False),
|
||||
sa.Column("error_rate_5xx_pct", sa.Float, nullable=False),
|
||||
sa.Column("latency_p50_ms", sa.Float, nullable=False),
|
||||
sa.Column("latency_p95_ms", sa.Float, nullable=False),
|
||||
sa.Column("latency_p99_ms", sa.Float, nullable=False),
|
||||
)
|
||||
op.create_index("ix_traefik_metrics_router_scraped", "traefik_metrics",
|
||||
["router_name", "scraped_at"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_traefik_metrics_router_scraped", "traefik_metrics")
|
||||
op.drop_table("traefik_metrics")
|
||||
@@ -1,23 +0,0 @@
|
||||
# plugins/traefik/models.py
|
||||
from __future__ import annotations
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy import DateTime, Float, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from fablednetmon.models.base import Base
|
||||
|
||||
|
||||
class TraefikMetric(Base):
|
||||
__tablename__ = "traefik_metrics"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
router_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
scraped_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
request_rate: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
error_rate_4xx_pct: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
error_rate_5xx_pct: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
latency_p50_ms: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
latency_p95_ms: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
latency_p99_ms: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
@@ -1,10 +0,0 @@
|
||||
# plugins/traefik/plugin.yaml
|
||||
name: traefik
|
||||
version: "1.0.0"
|
||||
description: "Traefik reverse proxy metrics integration"
|
||||
author: ""
|
||||
min_app_version: "0.1.0"
|
||||
|
||||
config:
|
||||
metrics_url: "http://localhost:8080/metrics"
|
||||
scrape_interval_seconds: 60
|
||||
@@ -1,93 +0,0 @@
|
||||
# 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 .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)
|
||||
|
||||
|
||||
@traefik_bp.get("/widget")
|
||||
@require_role(UserRole.viewer)
|
||||
async def widget():
|
||||
"""HTMX dashboard widget fragment."""
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
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(1)
|
||||
)
|
||||
latest = result.scalar_one_or_none()
|
||||
if latest:
|
||||
router_data.append({"name": router, "latest": latest})
|
||||
return await render_template("traefik/widget.html", router_data=router_data)
|
||||
@@ -1,97 +0,0 @@
|
||||
# plugins/traefik/scheduler.py
|
||||
from __future__ import annotations
|
||||
import logging
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from fablednetmon.core.scheduler import ScheduledTask
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from quart import Quart
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Module-level state: previous scrape data for delta computation
|
||||
_prev_metrics: dict = {}
|
||||
_prev_time: float = 0.0
|
||||
|
||||
|
||||
def make_scrape_task(app: "Quart") -> ScheduledTask:
|
||||
"""Return a ScheduledTask that scrapes Traefik metrics on the configured interval."""
|
||||
interval = app.config["PLUGINS"]["traefik"]["scrape_interval_seconds"]
|
||||
|
||||
async def scrape() -> None:
|
||||
await _do_scrape(app)
|
||||
|
||||
return ScheduledTask(
|
||||
name="traefik_scrape",
|
||||
coro_factory=scrape,
|
||||
interval_seconds=int(interval),
|
||||
run_on_startup=True,
|
||||
)
|
||||
|
||||
|
||||
async def _do_scrape(app: "Quart") -> None:
|
||||
"""Fetch Traefik metrics, write to DB, and emit plugin_metrics."""
|
||||
global _prev_metrics, _prev_time
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from .models import TraefikMetric
|
||||
from .scraper import fetch_metrics, compute_router_metrics
|
||||
from fablednetmon.core.alerts import record_metric
|
||||
|
||||
metrics_url: str = app.config["PLUGINS"]["traefik"]["metrics_url"]
|
||||
|
||||
try:
|
||||
current = await fetch_metrics(metrics_url)
|
||||
except Exception:
|
||||
logger.exception("Traefik scrape failed (url=%s)", metrics_url)
|
||||
return
|
||||
|
||||
now = time.monotonic()
|
||||
elapsed = now - _prev_time if _prev_time else 60.0
|
||||
router_metrics = compute_router_metrics(current, _prev_metrics or None, elapsed)
|
||||
|
||||
_prev_metrics = current
|
||||
_prev_time = now
|
||||
|
||||
if not router_metrics:
|
||||
logger.debug("Traefik scrape: no router metrics found")
|
||||
return
|
||||
|
||||
scraped_at = datetime.now(timezone.utc)
|
||||
|
||||
async with app.db_sessionmaker() as session:
|
||||
async with session.begin():
|
||||
for router_name, metrics in router_metrics.items():
|
||||
# Write to traefik_metrics history table
|
||||
row = TraefikMetric(
|
||||
router_name=router_name,
|
||||
scraped_at=scraped_at,
|
||||
request_rate=metrics["request_rate"],
|
||||
error_rate_4xx_pct=metrics["error_rate_4xx_pct"],
|
||||
error_rate_5xx_pct=metrics["error_rate_5xx_pct"],
|
||||
latency_p50_ms=metrics["latency_p50_ms"],
|
||||
latency_p95_ms=metrics["latency_p95_ms"],
|
||||
latency_p99_ms=metrics["latency_p99_ms"],
|
||||
)
|
||||
session.add(row)
|
||||
|
||||
# Emit plugin_metrics for alert rules
|
||||
for metric_name, value in [
|
||||
("request_rate", metrics["request_rate"]),
|
||||
("error_rate_4xx_pct", metrics["error_rate_4xx_pct"]),
|
||||
("error_rate_5xx_pct", metrics["error_rate_5xx_pct"]),
|
||||
("latency_p50_ms", metrics["latency_p50_ms"]),
|
||||
("latency_p95_ms", metrics["latency_p95_ms"]),
|
||||
("latency_p99_ms", metrics["latency_p99_ms"]),
|
||||
]:
|
||||
await record_metric(
|
||||
session=session,
|
||||
source_module="traefik",
|
||||
resource_name=router_name,
|
||||
metric_name=metric_name,
|
||||
value=value,
|
||||
)
|
||||
|
||||
logger.debug("Traefik scrape: wrote metrics for %d router(s)", len(router_metrics))
|
||||
@@ -1,194 +0,0 @@
|
||||
# plugins/traefik/scraper.py
|
||||
"""Prometheus text-format scraper and per-router metrics calculator for Traefik.
|
||||
|
||||
Traefik exposes Prometheus metrics at /metrics. This module:
|
||||
1. Fetches the endpoint via httpx
|
||||
2. Parses the Prometheus text format (counters + histograms)
|
||||
3. Computes per-router request rate (delta/elapsed), error rates (% 4xx, % 5xx),
|
||||
and latency percentiles (p50, p95, p99) via linear histogram interpolation
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import math
|
||||
import re
|
||||
from collections import defaultdict
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Prometheus text format parser
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
# Parsed sample: {metric_name: {frozenset(label_items): float}}
|
||||
ParsedMetrics = dict[str, dict[frozenset, float]]
|
||||
|
||||
_SAMPLE_RE = re.compile(
|
||||
r'^([a-zA-Z_:][a-zA-Z0-9_:]*)(?:\{([^}]*)\})?\s+'
|
||||
r'([+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?|[+-]?Inf|NaN)'
|
||||
)
|
||||
_LABEL_RE = re.compile(r'([a-zA-Z_][a-zA-Z0-9_]*)="([^"\\]*(?:\\.[^"\\]*)*)"')
|
||||
|
||||
|
||||
def parse_prometheus(text: str) -> ParsedMetrics:
|
||||
"""Parse Prometheus text exposition format into nested dicts.
|
||||
|
||||
Returns: {metric_name: {frozenset({(label, value), ...}): float}}
|
||||
"""
|
||||
metrics: ParsedMetrics = {}
|
||||
for line in text.splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
m = _SAMPLE_RE.match(line)
|
||||
if not m:
|
||||
continue
|
||||
name, labels_str, value_str = m.groups()
|
||||
try:
|
||||
value = float(value_str)
|
||||
except ValueError:
|
||||
continue
|
||||
labels = frozenset(
|
||||
(lm.group(1), lm.group(2))
|
||||
for lm in _LABEL_RE.finditer(labels_str or "")
|
||||
)
|
||||
metrics.setdefault(name, {})[labels] = value
|
||||
return metrics
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Per-router metric computation
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def compute_router_metrics(
|
||||
current: ParsedMetrics,
|
||||
previous: ParsedMetrics | None,
|
||||
elapsed_seconds: float,
|
||||
) -> dict[str, dict[str, float]]:
|
||||
"""Compute per-router derived metrics from two consecutive scrapes.
|
||||
|
||||
Args:
|
||||
current: Parsed metrics from the latest scrape.
|
||||
previous: Parsed metrics from the prior scrape (None on first call).
|
||||
elapsed_seconds: Time between scrapes (for rate calculation).
|
||||
|
||||
Returns:
|
||||
{router_name: {
|
||||
"request_rate": float, # requests/sec
|
||||
"error_rate_4xx_pct": float, # % 4xx of total
|
||||
"error_rate_5xx_pct": float, # % 5xx of total
|
||||
"latency_p50_ms": float, # approx ms
|
||||
"latency_p95_ms": float,
|
||||
"latency_p99_ms": float,
|
||||
}}
|
||||
"""
|
||||
prev = previous or {}
|
||||
elapsed = max(elapsed_seconds, 1.0)
|
||||
|
||||
# ── Request counters ──────────────────────────────────────────────────────
|
||||
req_samples = current.get("traefik_router_requests_total", {})
|
||||
prev_req = prev.get("traefik_router_requests_total", {})
|
||||
|
||||
router_total: dict[str, float] = defaultdict(float)
|
||||
router_4xx: dict[str, float] = defaultdict(float)
|
||||
router_5xx: dict[str, float] = defaultdict(float)
|
||||
|
||||
for labels, value in req_samples.items():
|
||||
label_dict = dict(labels)
|
||||
router = label_dict.get("router", "")
|
||||
if not router:
|
||||
continue
|
||||
code = label_dict.get("code", "")
|
||||
prev_value = prev_req.get(labels, value) # no delta on first scrape
|
||||
delta = max(0.0, value - prev_value)
|
||||
router_total[router] += delta
|
||||
if code.startswith("4"):
|
||||
router_4xx[router] += delta
|
||||
elif code.startswith("5"):
|
||||
router_5xx[router] += delta
|
||||
|
||||
# ── Histogram buckets ─────────────────────────────────────────────────────
|
||||
hist_buckets = current.get("traefik_router_request_duration_seconds_bucket", {})
|
||||
|
||||
# Collect all known routers (from both counter and histogram data)
|
||||
all_routers: set[str] = set(router_total.keys())
|
||||
for labels in hist_buckets:
|
||||
router = dict(labels).get("router", "")
|
||||
if router:
|
||||
all_routers.add(router)
|
||||
|
||||
result: dict[str, dict[str, float]] = {}
|
||||
|
||||
for router in all_routers:
|
||||
total = router_total.get(router, 0.0)
|
||||
result[router] = {
|
||||
"request_rate": total / elapsed,
|
||||
"error_rate_4xx_pct": (router_4xx.get(router, 0.0) / total * 100.0)
|
||||
if total > 0 else 0.0,
|
||||
"error_rate_5xx_pct": (router_5xx.get(router, 0.0) / total * 100.0)
|
||||
if total > 0 else 0.0,
|
||||
"latency_p50_ms": _percentile_ms(hist_buckets, router, 0.50),
|
||||
"latency_p95_ms": _percentile_ms(hist_buckets, router, 0.95),
|
||||
"latency_p99_ms": _percentile_ms(hist_buckets, router, 0.99),
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _percentile_ms(
|
||||
buckets: dict[frozenset, float],
|
||||
router: str,
|
||||
pct: float,
|
||||
) -> float:
|
||||
"""Linear interpolation of a Prometheus histogram percentile, returned in ms."""
|
||||
router_buckets: list[tuple[float, float]] = []
|
||||
for labels, count in buckets.items():
|
||||
label_dict = dict(labels)
|
||||
if label_dict.get("router") != router:
|
||||
continue
|
||||
le_str = label_dict.get("le", "")
|
||||
try:
|
||||
le = float("inf") if le_str == "+Inf" else float(le_str)
|
||||
router_buckets.append((le, count))
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
if not router_buckets:
|
||||
return 0.0
|
||||
|
||||
router_buckets.sort(key=lambda t: t[0])
|
||||
total = router_buckets[-1][1] # +Inf bucket count == total requests
|
||||
if total == 0.0:
|
||||
return 0.0
|
||||
|
||||
target = total * pct
|
||||
for i, (le, count) in enumerate(router_buckets):
|
||||
if count < target:
|
||||
continue
|
||||
# Found the bucket that first reaches target count
|
||||
if i == 0:
|
||||
# Interpolate from 0 to first bucket upper bound
|
||||
ratio = target / count if count > 0 else 0.0
|
||||
return le * ratio * 1000.0
|
||||
prev_le, prev_count = router_buckets[i - 1]
|
||||
if count == prev_count:
|
||||
return prev_le * 1000.0
|
||||
if math.isinf(le):
|
||||
# Percentile falls in the open (last_finite_le, +Inf] bucket;
|
||||
# cap at the last finite bucket upper bound.
|
||||
return prev_le * 1000.0
|
||||
fraction = (target - prev_count) / (count - prev_count)
|
||||
return (prev_le + fraction * (le - prev_le)) * 1000.0
|
||||
|
||||
return 0.0 # unreachable: +Inf bucket always satisfies count >= target
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# HTTP fetch
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
async def fetch_metrics(metrics_url: str) -> ParsedMetrics:
|
||||
"""Fetch and parse Prometheus metrics from the given URL."""
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.get(metrics_url)
|
||||
response.raise_for_status()
|
||||
return parse_prometheus(response.text)
|
||||
@@ -1,53 +0,0 @@
|
||||
{# plugins/traefik/templates/traefik/index.html #}
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Traefik — FabledNetMon{% endblock %}
|
||||
{% block content %}
|
||||
<h1 class="page-title">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 class="section-title" style="margin-bottom:0.75rem;word-break:break-all;">{{ r.name }}</h3>
|
||||
<table class="table" style="font-size:0.85rem;">
|
||||
<tr>
|
||||
<td style="color:var(--text-muted);padding:0.2rem 0;">Req/s</td>
|
||||
<td style="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:var(--text-muted);padding:0.2rem 0;">p95 ms</td>
|
||||
<td style="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:var(--text-muted);padding:0.2rem 0;">p99 ms</td>
|
||||
<td style="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:var(--text-muted);padding:0.2rem 0;">4xx %</td>
|
||||
<td style="color:{% if r.latest.error_rate_4xx_pct > 1 %}var(--yellow){% else %}var(--green){% 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:var(--text-muted);padding:0.2rem 0;">5xx %</td>
|
||||
<td style="color:{% if r.latest.error_rate_5xx_pct > 0.1 %}var(--red){% else %}var(--green){% 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:var(--text-dim);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 %}
|
||||
@@ -1,29 +0,0 @@
|
||||
{% if router_data %}
|
||||
{% for r in router_data %}
|
||||
<div class="ping-row">
|
||||
<div class="ping-meta">
|
||||
<span class="ping-name" style="font-size:0.8rem;word-break:break-all;">{{ r.name }}</span>
|
||||
</div>
|
||||
<div style="flex:1;display:flex;gap:1.5rem;font-size:0.82rem;font-variant-numeric:tabular-nums;">
|
||||
<span title="Requests/s">
|
||||
<span style="color:var(--text-muted);">req/s</span>
|
||||
<span style="margin-left:0.3rem;">{{ "%.2f"|format(r.latest.request_rate) }}</span>
|
||||
</span>
|
||||
<span title="p95 latency">
|
||||
<span style="color:var(--text-muted);">p95</span>
|
||||
<span style="margin-left:0.3rem;
|
||||
{% if r.latest.latency_p95_ms > 500 %}color:var(--red){% elif r.latest.latency_p95_ms > 200 %}color:var(--yellow){% else %}color:var(--green){% endif %};">
|
||||
{{ "%.0f"|format(r.latest.latency_p95_ms) }}ms
|
||||
</span>
|
||||
</span>
|
||||
{% if r.latest.error_rate_5xx_pct > 0 %}
|
||||
<span title="5xx error rate">
|
||||
<span style="color:var(--red);">{{ "%.1f"|format(r.latest.error_rate_5xx_pct) }}% 5xx</span>
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p class="empty" style="padding:0.5rem 0;font-size:0.85rem;">No Traefik data yet.</p>
|
||||
{% endif %}
|
||||
Reference in New Issue
Block a user