From fa318073f1d0e4652e12505e69ac42eda5127c26 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 22 Mar 2026 18:33:22 -0400 Subject: [PATCH] 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 --- .gitignore | 4 + plugins/__init__.py | 1 - plugins/traefik/__init__.py | 25 --- plugins/traefik/migrations/__init__.py | 0 plugins/traefik/migrations/env.py | 75 ------- .../traefik/migrations/versions/__init__.py | 0 .../versions/traefik_001_initial.py | 37 ---- plugins/traefik/models.py | 23 --- plugins/traefik/plugin.yaml | 10 - plugins/traefik/routes.py | 93 --------- plugins/traefik/scheduler.py | 97 --------- plugins/traefik/scraper.py | 194 ------------------ plugins/traefik/templates/traefik/index.html | 53 ----- plugins/traefik/templates/traefik/widget.html | 29 --- 14 files changed, 4 insertions(+), 637 deletions(-) delete mode 100644 plugins/__init__.py delete mode 100644 plugins/traefik/__init__.py delete mode 100644 plugins/traefik/migrations/__init__.py delete mode 100644 plugins/traefik/migrations/env.py delete mode 100644 plugins/traefik/migrations/versions/__init__.py delete mode 100644 plugins/traefik/migrations/versions/traefik_001_initial.py delete mode 100644 plugins/traefik/models.py delete mode 100644 plugins/traefik/plugin.yaml delete mode 100644 plugins/traefik/routes.py delete mode 100644 plugins/traefik/scheduler.py delete mode 100644 plugins/traefik/scraper.py delete mode 100644 plugins/traefik/templates/traefik/index.html delete mode 100644 plugins/traefik/templates/traefik/widget.html diff --git a/.gitignore b/.gitignore index fbe9074..980361b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,10 @@ # Planning files docs/superpowers/ +# Plugin directory — managed as a separate repo (bvandeusen/fabledscryer-plugins) +# PLUGIN_DIR in config.yaml points here at runtime +plugins/ + # Python __pycache__/ *.py[cod] diff --git a/plugins/__init__.py b/plugins/__init__.py deleted file mode 100644 index 1648f8e..0000000 --- a/plugins/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# plugins/__init__.py diff --git a/plugins/traefik/__init__.py b/plugins/traefik/__init__.py deleted file mode 100644 index ca1e76c..0000000 --- a/plugins/traefik/__init__.py +++ /dev/null @@ -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 diff --git a/plugins/traefik/migrations/__init__.py b/plugins/traefik/migrations/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/plugins/traefik/migrations/env.py b/plugins/traefik/migrations/env.py deleted file mode 100644 index 933ee87..0000000 --- a/plugins/traefik/migrations/env.py +++ /dev/null @@ -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() diff --git a/plugins/traefik/migrations/versions/__init__.py b/plugins/traefik/migrations/versions/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/plugins/traefik/migrations/versions/traefik_001_initial.py b/plugins/traefik/migrations/versions/traefik_001_initial.py deleted file mode 100644 index aa53f78..0000000 --- a/plugins/traefik/migrations/versions/traefik_001_initial.py +++ /dev/null @@ -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") diff --git a/plugins/traefik/models.py b/plugins/traefik/models.py deleted file mode 100644 index d364777..0000000 --- a/plugins/traefik/models.py +++ /dev/null @@ -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) diff --git a/plugins/traefik/plugin.yaml b/plugins/traefik/plugin.yaml deleted file mode 100644 index a3c63ce..0000000 --- a/plugins/traefik/plugin.yaml +++ /dev/null @@ -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 diff --git a/plugins/traefik/routes.py b/plugins/traefik/routes.py deleted file mode 100644 index d6ab2ee..0000000 --- a/plugins/traefik/routes.py +++ /dev/null @@ -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'' - 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'' - f'' - f'' - ) - - -@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) diff --git a/plugins/traefik/scheduler.py b/plugins/traefik/scheduler.py deleted file mode 100644 index 5a1e547..0000000 --- a/plugins/traefik/scheduler.py +++ /dev/null @@ -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)) diff --git a/plugins/traefik/scraper.py b/plugins/traefik/scraper.py deleted file mode 100644 index a786374..0000000 --- a/plugins/traefik/scraper.py +++ /dev/null @@ -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) diff --git a/plugins/traefik/templates/traefik/index.html b/plugins/traefik/templates/traefik/index.html deleted file mode 100644 index aa802db..0000000 --- a/plugins/traefik/templates/traefik/index.html +++ /dev/null @@ -1,53 +0,0 @@ -{# plugins/traefik/templates/traefik/index.html #} -{% extends "base.html" %} -{% block title %}Traefik — FabledNetMon{% endblock %} -{% block content %} -

Traefik Routers

-{% if not router_data %} -
-

No Traefik metrics yet. Configure metrics_url in config.yaml under plugins.traefik.

-
-{% else %} -
-{% for r in router_data %} -
-

{{ r.name }}

- - - - - - - - - - - - - - - - - - - - - - - - - - -
Req/s{{ "%.2f"|format(r.latest.request_rate) }}{{ r.sparkline_req | safe }}
p95 ms{{ "%.1f"|format(r.latest.latency_p95_ms) }}{{ r.sparkline_p95 | safe }}
p99 ms{{ "%.1f"|format(r.latest.latency_p99_ms) }}
4xx % - {{ "%.1f"|format(r.latest.error_rate_4xx_pct) }}% -
5xx % - {{ "%.1f"|format(r.latest.error_rate_5xx_pct) }}% - {{ r.sparkline_5xx | safe }}
-
- Last scraped {{ r.latest.scraped_at.strftime("%H:%M:%S") }} UTC -
-
-{% endfor %} -
-{% endif %} -{% endblock %} diff --git a/plugins/traefik/templates/traefik/widget.html b/plugins/traefik/templates/traefik/widget.html deleted file mode 100644 index 9649198..0000000 --- a/plugins/traefik/templates/traefik/widget.html +++ /dev/null @@ -1,29 +0,0 @@ -{% if router_data %} -{% for r in router_data %} -
-
- {{ r.name }} -
-
- - req/s - {{ "%.2f"|format(r.latest.request_rate) }} - - - p95 - - {{ "%.0f"|format(r.latest.latency_p95_ms) }}ms - - - {% if r.latest.error_rate_5xx_pct > 0 %} - - {{ "%.1f"|format(r.latest.error_rate_5xx_pct) }}% 5xx - - {% endif %} -
-
-{% endfor %} -{% else %} -

No Traefik data yet.

-{% endif %}