feat(plugins): fold first-party plugins in-tree; bundled + external roots

First-party plugins (host_agent, http, snmp, traefik, unifi, docker) are now
tracked under plugins/ and baked into the image, so they version atomically
with core — ending the cross-repo import drift the roundtable->steward rename
exposed. History for these files is preserved in the archived Roundtable-plugins
repo.

Plugin discovery becomes multi-root: PLUGIN_DIR (single) -> PLUGIN_DIRS
(bundled first, then external) + PLUGIN_INSTALL_DIR. Bundled ships in the image;
third-party plugins still mount at runtime into the external root
(STEWARD_PLUGIN_DIR, default /data/plugins) and downloads/installs land there.
Bundled shadows external on a name collision.

- config.py: load_bootstrap returns plugin_dirs + plugin_install_dir
- app.py: iterate PLUGIN_DIRS at the migration + load sites
- migration_runner.py: discover_all_in() unions every plugin root
- plugin_manager.py: resolve_plugin_path() (pure, first-root-wins); load /
  install / hot-reload span all roots; installs target the external root
- settings/routes.py: _discover_plugins scans all roots, dedup bundled-first
- Dockerfile: COPY plugins/ ; docker-compose: drop host bind, document external
- tests/test_plugin_dirs.py: resolution, multi-root discovery, bootstrap split

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-01 08:37:24 -04:00
parent 88ab5b917e
commit a7a281cb11
99 changed files with 7931 additions and 52 deletions
+30
View File
@@ -0,0 +1,30 @@
# 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
from .models import TraefikMetric, TraefikCert, TraefikGlobalStat, TraefikAccessSummary # noqa: F401
def get_scheduled_tasks() -> list:
from .scheduler import make_scrape_task, make_access_log_task
tasks = [make_scrape_task(_app)]
access_log_cfg = _app.config["PLUGINS"]["traefik"].get("access_log", {})
if not isinstance(access_log_cfg, dict):
access_log_cfg = {}
if access_log_cfg.get("enabled", False):
tasks.append(make_access_log_task(_app))
return tasks
def get_blueprint():
from .routes import traefik_bp
return traefik_bp
+199
View File
@@ -0,0 +1,199 @@
# plugins/traefik/access_log.py
"""Traefik JSON access log tailer and traffic-origin aggregator.
Reads new lines from Traefik's JSON access log on each scheduler tick,
classifies source IPs as internal (RFC1918) or external, optionally
looks up country codes via MaxMind GeoLite2, and returns an aggregated
summary ready to persist in TraefikAccessSummary.
"""
from __future__ import annotations
import ipaddress
import json
from collections import defaultdict
from pathlib import Path
# ── Module-level file-position state ─────────────────────────────────────────
_log_position: int = 0 # byte offset of last read
_log_inode: int = 0 # detect rotation by inode change
# ── Private IP ranges (RFC1918 + loopback + link-local) ──────────────────────
_PRIVATE_NETS = [
ipaddress.ip_network("10.0.0.0/8"),
ipaddress.ip_network("172.16.0.0/12"),
ipaddress.ip_network("192.168.0.0/16"),
ipaddress.ip_network("127.0.0.0/8"),
ipaddress.ip_network("169.254.0.0/16"),
ipaddress.ip_network("::1/128"),
ipaddress.ip_network("fc00::/7"),
ipaddress.ip_network("fe80::/10"),
]
def _strip_port(raw: str) -> str:
"""Strip trailing :port from an IPv4 address string. Leaves IPv6 untouched."""
if not raw:
return raw
# Bracketed IPv6 with port: [::1]:12345
if raw.startswith("["):
return raw[1:raw.index("]")] if "]" in raw else raw
# IPv4:port has exactly one colon
if raw.count(":") == 1:
return raw.rsplit(":", 1)[0]
return raw
def is_internal(raw_ip: str) -> bool:
"""Return True if the address falls within a private/loopback range."""
ip_str = _strip_port(raw_ip)
try:
addr = ipaddress.ip_address(ip_str)
return any(addr in net for net in _PRIVATE_NETS)
except ValueError:
return False
def _country(ip_str: str, geoip_db) -> str | None:
"""Return ISO country code via an open maxminddb reader, or None."""
if geoip_db is None:
return None
clean = _strip_port(ip_str)
try:
record = geoip_db.get(clean)
if record:
return record.get("country", {}).get("iso_code")
except Exception:
pass
return None
def read_new_lines(log_path: str) -> list[dict]:
"""Return all new JSON-parsed access log entries since the last call.
Handles log rotation: if the file shrinks or its inode changes, we
reset to the beginning of the new file.
"""
global _log_position, _log_inode
path = Path(log_path)
if not path.exists():
return []
try:
stat = path.stat()
except OSError:
return []
current_inode = stat.st_ino
current_size = stat.st_size
# Rotation detected
if current_inode != _log_inode or current_size < _log_position:
_log_position = 0
_log_inode = current_inode
if current_size == _log_position:
return []
entries: list[dict] = []
try:
with open(log_path, "rb") as f:
f.seek(_log_position)
raw = f.read(current_size - _log_position)
_log_position = current_size
except OSError:
return []
for raw_line in raw.split(b"\n"):
raw_line = raw_line.strip()
if not raw_line:
continue
try:
entries.append(json.loads(raw_line))
except json.JSONDecodeError:
continue
return entries
def aggregate(entries: list[dict], geoip_db=None, top_n: int = 10) -> dict | None:
"""Aggregate a batch of access log entries into a summary dict.
Returns None if there are no entries.
Result keys map directly to TraefikAccessSummary columns (excluding
id and period_start/period_end, which the caller provides).
"""
if not entries:
return None
total = 0
internal = 0
external = 0
total_bytes = 0.0
by_router: dict[str, int] = defaultdict(int)
ip_counts: dict[str, int] = defaultdict(int)
ip_internal: dict[str, bool] = {}
ip_country: dict[str, str | None] = {}
country_counts: dict[str, int] = defaultdict(int)
for entry in entries:
total += 1
raw_ip = entry.get("ClientHost", "")
router = entry.get("RouterName") or entry.get("ServiceName") or "unknown"
total_bytes += entry.get("DownstreamContentSize") or 0
by_router[router] += 1
internal_flag = is_internal(raw_ip)
if internal_flag:
internal += 1
else:
external += 1
if raw_ip not in ip_country:
ip_country[raw_ip] = _country(raw_ip, geoip_db)
c = ip_country[raw_ip]
if c:
country_counts[c] += 1
ip_counts[raw_ip] += 1
ip_internal[raw_ip] = internal_flag
top_ips = [
{
"ip": ip,
"count": cnt,
"internal": ip_internal[ip],
"country": ip_country.get(ip),
}
for ip, cnt in sorted(ip_counts.items(), key=lambda x: x[1], reverse=True)[:top_n]
]
top_countries = [
{"country": c, "count": n}
for c, n in sorted(country_counts.items(), key=lambda x: x[1], reverse=True)[:top_n]
]
top_routers = [
{"router": r, "count": n}
for r, n in sorted(by_router.items(), key=lambda x: x[1], reverse=True)[:top_n]
]
return {
"total_requests": total,
"internal_requests": internal,
"external_requests": external,
"total_bytes": total_bytes,
"top_ips_json": json.dumps(top_ips),
"top_countries_json": json.dumps(top_countries),
"top_routers_json": json.dumps(top_routers),
}
def open_geoip(db_path: str | None):
"""Open a MaxMind mmdb file if available. Returns None if not configured or missing."""
if not db_path:
return None
try:
import maxminddb # type: ignore[import]
return maxminddb.open_database(db_path)
except Exception:
return None
+75
View File
@@ -0,0 +1,75 @@
# 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 fabledscryer importable
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent))
from fabledscryer.models.base import Base
import fabledscryer.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("FABLEDSCRYER_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("FABLEDSCRYER_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()
@@ -0,0 +1,37 @@
# 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")
@@ -0,0 +1,48 @@
"""Traefik extended stats: bandwidth, certs, global stats
Revision ID: traefik_002_stats
Revises: traefik_001_initial
Create Date: 2026-03-19
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "traefik_002_stats"
down_revision: Union[str, None] = "traefik_001_initial"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"traefik_metrics",
sa.Column("response_bytes_rate", sa.Float, nullable=False, server_default="0"),
)
op.create_table(
"traefik_certs",
sa.Column("serial", sa.String(255), primary_key=True),
sa.Column("cn", sa.String(512), nullable=True),
sa.Column("sans", sa.Text, nullable=True),
sa.Column("not_after", sa.DateTime(timezone=True), nullable=False),
sa.Column("scraped_at", sa.DateTime(timezone=True), nullable=False),
)
op.create_table(
"traefik_global_stats",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("scraped_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("open_conns_total", sa.Float, nullable=True),
sa.Column("config_reloads_total", sa.Float, nullable=True),
sa.Column("config_last_reload_success", sa.Float, nullable=True),
sa.Column("process_memory_bytes", sa.Float, nullable=True),
)
op.create_index("ix_traefik_global_stats_scraped", "traefik_global_stats", ["scraped_at"])
def downgrade() -> None:
op.drop_index("ix_traefik_global_stats_scraped", "traefik_global_stats")
op.drop_table("traefik_global_stats")
op.drop_table("traefik_certs")
op.drop_column("traefik_metrics", "response_bytes_rate")
@@ -0,0 +1,40 @@
"""Traefik access log traffic summaries
Revision ID: traefik_003_access_log
Revises: traefik_002_stats
Create Date: 2026-03-20
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "traefik_003_access_log"
down_revision: Union[str, None] = "traefik_002_stats"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"traefik_access_summaries",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("period_start", sa.DateTime(timezone=True), nullable=False),
sa.Column("period_end", sa.DateTime(timezone=True), nullable=False),
sa.Column("total_requests", sa.Integer, nullable=False, server_default="0"),
sa.Column("internal_requests", sa.Integer, nullable=False, server_default="0"),
sa.Column("external_requests", sa.Integer, nullable=False, server_default="0"),
sa.Column("total_bytes", sa.Float, nullable=False, server_default="0"),
sa.Column("top_ips_json", sa.Text, nullable=False, server_default="[]"),
sa.Column("top_countries_json", sa.Text, nullable=False, server_default="[]"),
sa.Column("top_routers_json", sa.Text, nullable=False, server_default="[]"),
)
op.create_index(
"ix_traefik_access_summaries_period_end",
"traefik_access_summaries",
["period_end"],
)
def downgrade() -> None:
op.drop_index("ix_traefik_access_summaries_period_end", "traefik_access_summaries")
op.drop_table("traefik_access_summaries")
+67
View File
@@ -0,0 +1,67 @@
# plugins/traefik/models.py
from __future__ import annotations
import uuid
from datetime import datetime, timezone
from sqlalchemy import DateTime, Float, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from fabledscryer.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)
response_bytes_rate: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
class TraefikCert(Base):
"""TLS certificate expiry tracking — upserted by serial each scrape."""
__tablename__ = "traefik_certs"
serial: Mapped[str] = mapped_column(String(255), primary_key=True)
cn: Mapped[str | None] = mapped_column(String(512), nullable=True)
sans: Mapped[str | None] = mapped_column(Text, nullable=True)
not_after: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
scraped_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc)
)
class TraefikAccessSummary(Base):
"""Aggregated traffic-origin summary — one row per scheduler tick."""
__tablename__ = "traefik_access_summaries"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
period_start: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
period_end: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
total_requests: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
internal_requests: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
external_requests: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
total_bytes: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
top_ips_json: Mapped[str] = mapped_column(Text, nullable=False, default="[]")
top_countries_json: Mapped[str] = mapped_column(Text, nullable=False, default="[]")
top_routers_json: Mapped[str] = mapped_column(Text, nullable=False, default="[]")
class TraefikGlobalStat(Base):
"""One row per scrape — process-level and config stats."""
__tablename__ = "traefik_global_stats"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
scraped_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc)
)
open_conns_total: Mapped[float | None] = mapped_column(Float, nullable=True)
config_reloads_total: Mapped[float | None] = mapped_column(Float, nullable=True)
config_last_reload_success: Mapped[float | None] = mapped_column(Float, nullable=True)
process_memory_bytes: Mapped[float | None] = mapped_column(Float, nullable=True)
+21
View File
@@ -0,0 +1,21 @@
# plugins/traefik/plugin.yaml
name: traefik
version: "1.0.0"
description: "Traefik reverse proxy metrics and access log integration"
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/traefik"
tags:
- proxy
- metrics
- access-log
config:
metrics_url: "http://localhost:8080/metrics"
scrape_interval_seconds: 60
access_log:
enabled: false
path: "/var/log/traefik/access.log"
geoip_db: "" # optional: path to MaxMind GeoLite2-Country.mmdb
+373
View File
@@ -0,0 +1,373 @@
# plugins/traefik/routes.py
from __future__ import annotations
from collections import defaultdict
from datetime import datetime, timezone
from quart import Blueprint, current_app, render_template, request
from sqlalchemy import Integer, cast, func, select
import json
from fabledscryer.auth.middleware import require_role
from fabledscryer.models.users import UserRole
from fabledscryer.core.time_range import parse_range, DEFAULT_RANGE, bucket_seconds, subsample
from .models import TraefikAccessSummary, TraefikCert, TraefikGlobalStat, 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():
poll_interval = current_app.config.get("MONITORS_POLL_INTERVAL", 60)
_al_cfg = current_app.config["PLUGINS"]["traefik"].get("access_log", {})
if not isinstance(_al_cfg, dict):
_al_cfg = {}
access_log_enabled = _al_cfg.get("enabled", False)
current_range = request.args.get("range", DEFAULT_RANGE)
return await render_template(
"traefik/index.html",
poll_interval=poll_interval,
access_log_enabled=access_log_enabled,
current_range=current_range,
)
@traefik_bp.get("/rows")
@require_role(UserRole.viewer)
async def rows():
"""HTMX fragment: service cards with sparklines scoped to selected time range."""
since, range_key = parse_range(request.args.get("range"))
now = datetime.now(timezone.utc)
async with current_app.db_sessionmaker() as db:
# All known routers
result = await db.execute(
select(TraefikMetric.router_name)
.distinct()
.order_by(TraefikMetric.router_name)
)
routers = [row[0] for row in result.all()]
b_secs = bucket_seconds(since)
bucket_col = (
cast(func.extract('epoch', TraefikMetric.scraped_at), Integer) / b_secs
).label("bucket")
router_data = []
for router in routers:
# Bucketed history for sparklines — always ~80 points regardless of range
result = await db.execute(
select(
func.avg(TraefikMetric.request_rate).label("request_rate"),
func.avg(TraefikMetric.latency_p95_ms).label("latency_p95_ms"),
func.avg(TraefikMetric.error_rate_5xx_pct).label("error_rate_5xx_pct"),
func.min(TraefikMetric.scraped_at).label("scraped_at"),
bucket_col,
)
.where(TraefikMetric.router_name == router)
.where(TraefikMetric.scraped_at >= since)
.group_by(bucket_col)
.order_by(bucket_col)
)
history = result.all()
# Latest value — always the most recent raw row
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 not latest:
continue
router_data.append({
"name": router,
"latest": latest,
"sparkline_req": _sparkline([r.request_rate for r in history]),
"sparkline_p95": _sparkline([r.latency_p95_ms for r in history]),
"sparkline_5xx": _sparkline([r.error_rate_5xx_pct for r in history]),
})
# Latest global stat (always most recent, not range-filtered)
result = await db.execute(
select(TraefikGlobalStat)
.order_by(TraefikGlobalStat.scraped_at.desc())
.limit(1)
)
global_stat = result.scalar_one_or_none()
# All certs, sorted by soonest expiry
result = await db.execute(
select(TraefikCert).order_by(TraefikCert.not_after)
)
raw_certs = result.scalars().all()
certs = []
for c in raw_certs:
days = (c.not_after - now).total_seconds() / 86400.0
certs.append({
"serial": c.serial,
"cn": c.cn or c.serial,
"sans": c.sans,
"not_after": c.not_after,
"days_remaining": days,
})
return await render_template(
"traefik/rows.html",
router_data=router_data,
global_stat=global_stat,
certs=certs,
range_key=range_key,
)
@traefik_bp.get("/widget")
@require_role(UserRole.viewer)
async def widget():
"""HTMX dashboard widget fragment."""
now = datetime.now(timezone.utc)
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})
router_data.sort(key=lambda r: (
-(1 if r["latest"].error_rate_5xx_pct > 0.1 else 0),
-(1 if r["latest"].error_rate_4xx_pct > 5.0 else 0),
-r["latest"].request_rate,
))
total_routers = len(router_data)
router_data = router_data[:5]
result = await db.execute(
select(TraefikCert).order_by(TraefikCert.not_after)
)
raw_certs = result.scalars().all()
expiring_certs = []
for c in raw_certs:
days = (c.not_after - now).total_seconds() / 86400.0
if days < 30:
expiring_certs.append({"cn": c.cn or c.serial, "days_remaining": days})
return await render_template(
"traefik/widget.html",
router_data=router_data,
expiring_certs=expiring_certs,
total_routers=total_routers,
)
@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():
"""HTMX fragment: traffic origin summary scoped to selected time range."""
since, range_key = parse_range(request.args.get("range"))
async with current_app.db_sessionmaker() as db:
result = await db.execute(
select(TraefikAccessSummary)
.where(TraefikAccessSummary.period_end >= since)
.order_by(TraefikAccessSummary.period_end.asc())
# No LIMIT — all summaries in range needed for accurate totals;
# sparkline subsamples to 80 points afterwards
)
rows = result.scalars().all()
if not rows:
return await render_template(
"traefik/traffic.html", summary=None, history=[], range_key=range_key
)
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)
total_bytes = sum(r.total_bytes for r in rows)
ip_counts: dict = defaultdict(lambda: {"count": 0, "internal": True, "country": None})
country_counts: dict[str, int] = defaultdict(int)
router_counts: dict[str, int] = defaultdict(int)
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["internal"]
ip_counts[k]["country"] = entry.get("country")
for entry in json.loads(row.top_countries_json):
country_counts[entry["country"]] += entry["count"]
for entry in json.loads(row.top_routers_json):
router_counts[entry["router"]] += entry["count"]
top_ips = sorted(
[{"ip": k, **v} for k, v in ip_counts.items()],
key=lambda x: x["count"], reverse=True
)[:10]
top_countries = sorted(
[{"country": k, "count": v} for k, v in country_counts.items()],
key=lambda x: x["count"], reverse=True
)[:10]
top_routers = sorted(
[{"router": k, "count": v} for k, v in router_counts.items()],
key=lambda x: x["count"], reverse=True
)[:10]
history = [
{
"period_end": r.period_end,
"external_pct": (r.external_requests / r.total_requests * 100.0)
if r.total_requests else 0.0,
}
for r in rows
]
summary = {
"total": total,
"internal": internal,
"external": external,
"total_bytes": total_bytes,
"external_pct": (external / total * 100.0) if total else 0.0,
"top_ips": top_ips,
"top_countries": top_countries,
"top_routers": top_routers,
"sparkline_external": _sparkline(
[h["external_pct"] for h in subsample(history, 80)]
),
}
return await render_template(
"traefik/traffic.html", summary=summary, history=history, range_key=range_key
)
+204
View File
@@ -0,0 +1,204 @@
# plugins/traefik/scheduler.py
from __future__ import annotations
import logging
import time
from typing import TYPE_CHECKING
from datetime import datetime
from fabledscryer.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
# Access log state
_geoip_db = None
_access_log_last_tick: "datetime | None" = None
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 sqlalchemy import select
from .models import TraefikCert, TraefikGlobalStat, TraefikMetric
from .scraper import (
compute_global_stats,
compute_router_metrics,
extract_certs,
fetch_metrics,
)
from fabledscryer.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)
global_stats = compute_global_stats(current, _prev_metrics or None)
cert_list = extract_certs(current)
_prev_metrics = current
_prev_time = now
scraped_at = datetime.now(timezone.utc)
async with app.db_sessionmaker() as session:
async with session.begin():
# ── Per-service metrics ───────────────────────────────────────────
for router_name, metrics in router_metrics.items():
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"],
response_bytes_rate=metrics["response_bytes_rate"],
)
session.add(row)
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"]),
("response_bytes_rate", metrics["response_bytes_rate"]),
]:
await record_metric(
session=session,
source_module="traefik",
resource_name=router_name,
metric_name=metric_name,
value=value,
)
# ── Global stats ──────────────────────────────────────────────────
session.add(TraefikGlobalStat(scraped_at=scraped_at, **global_stats))
# ── TLS certs (upsert by serial) ──────────────────────────────────
for cert in cert_list:
existing = await session.get(TraefikCert, cert["serial"])
if existing:
existing.cn = cert["cn"]
existing.sans = cert["sans"]
existing.not_after = cert["not_after"]
existing.scraped_at = scraped_at
else:
session.add(TraefikCert(
serial=cert["serial"],
cn=cert["cn"],
sans=cert["sans"],
not_after=cert["not_after"],
scraped_at=scraped_at,
))
# Emit cert_expiry_days for alert rules
days_remaining = (cert["not_after"] - scraped_at).total_seconds() / 86400.0
await record_metric(
session=session,
source_module="traefik",
resource_name=cert["cn"] or cert["serial"],
metric_name="cert_expiry_days",
value=max(0.0, days_remaining),
)
logger.debug(
"Traefik scrape: %d router(s), %d cert(s)",
len(router_metrics),
len(cert_list),
)
def make_access_log_task(app: "Quart") -> ScheduledTask:
"""Return a ScheduledTask that processes new Traefik access log lines."""
global _geoip_db
cfg = app.config["PLUGINS"]["traefik"].get("access_log", {})
if not isinstance(cfg, dict):
cfg = {}
log_path: str = cfg.get("path", "/var/log/traefik/access.log")
geoip_path: str = cfg.get("geoip_db", "")
from .access_log import open_geoip
_geoip_db = open_geoip(geoip_path or None)
if _geoip_db:
logger.info("Traefik access log: GeoIP database loaded from %s", geoip_path)
else:
logger.info("Traefik access log: GeoIP not configured, country lookup disabled")
interval = app.config["PLUGINS"]["traefik"]["scrape_interval_seconds"]
async def process() -> None:
await _do_access_log(app, log_path)
return ScheduledTask(
name="traefik_access_log",
coro_factory=process,
interval_seconds=int(interval),
run_on_startup=True,
)
async def _do_access_log(app: "Quart", log_path: str) -> None:
"""Read new access log lines, aggregate, and persist a summary."""
global _access_log_last_tick
from datetime import timezone
from .access_log import read_new_lines, aggregate
from .models import TraefikAccessSummary
now = datetime.now(timezone.utc)
period_start = _access_log_last_tick or now
_access_log_last_tick = now
entries = read_new_lines(log_path)
summary = aggregate(entries, geoip_db=_geoip_db)
if summary is None:
return
async with app.db_sessionmaker() as session:
async with session.begin():
session.add(TraefikAccessSummary(
period_start=period_start,
period_end=now,
**summary,
))
logger.debug(
"Traefik access log: %d requests (%d internal, %d external)",
summary["total_requests"],
summary["internal_requests"],
summary["external_requests"],
)
+276
View File
@@ -0,0 +1,276 @@
# plugins/traefik/scraper.py
"""Prometheus text-format scraper and per-service 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-service request rate (delta/elapsed), error rates (% 4xx, % 5xx),
latency percentiles (p50, p95, p99) via linear histogram interpolation,
and bandwidth (bytes/sec from response bytes counter delta)
4. Extracts TLS certificate expiry info
5. Computes process-level and config health stats
"""
from __future__ import annotations
import math
import re
from collections import defaultdict
from datetime import datetime, timezone
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-service derived metrics from two consecutive scrapes.
Uses traefik_service_* metrics (available by default).
Returns:
{service_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,
"latency_p95_ms": float,
"latency_p99_ms": float,
"response_bytes_rate": float, # bytes/sec
}}
"""
prev = previous or {}
elapsed = max(elapsed_seconds, 1.0)
# ── Request counters ──────────────────────────────────────────────────────
req_samples = current.get("traefik_service_requests_total", {})
prev_req = prev.get("traefik_service_requests_total", {})
service_total: dict[str, float] = defaultdict(float)
service_4xx: dict[str, float] = defaultdict(float)
service_5xx: dict[str, float] = defaultdict(float)
for labels, value in req_samples.items():
label_dict = dict(labels)
service = label_dict.get("service", "")
if not service:
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)
service_total[service] += delta
if code.startswith("4"):
service_4xx[service] += delta
elif code.startswith("5"):
service_5xx[service] += delta
# ── Response bytes counters ───────────────────────────────────────────────
bytes_samples = current.get("traefik_service_responses_bytes_total", {})
prev_bytes = prev.get("traefik_service_responses_bytes_total", {})
service_bytes: dict[str, float] = defaultdict(float)
for labels, value in bytes_samples.items():
service = dict(labels).get("service", "")
if not service:
continue
prev_value = prev_bytes.get(labels, value)
service_bytes[service] += max(0.0, value - prev_value)
# ── Histogram buckets ─────────────────────────────────────────────────────
hist_buckets = current.get("traefik_service_request_duration_seconds_bucket", {})
# Collect all known services
all_services: set[str] = set(service_total.keys()) | set(service_bytes.keys())
for labels in hist_buckets:
service = dict(labels).get("service", "")
if service:
all_services.add(service)
result: dict[str, dict[str, float]] = {}
for service in all_services:
total = service_total.get(service, 0.0)
result[service] = {
"request_rate": total / elapsed,
"error_rate_4xx_pct": (service_4xx.get(service, 0.0) / total * 100.0)
if total > 0 else 0.0,
"error_rate_5xx_pct": (service_5xx.get(service, 0.0) / total * 100.0)
if total > 0 else 0.0,
"latency_p50_ms": _percentile_ms(hist_buckets, service, 0.50),
"latency_p95_ms": _percentile_ms(hist_buckets, service, 0.95),
"latency_p99_ms": _percentile_ms(hist_buckets, service, 0.99),
"response_bytes_rate": service_bytes.get(service, 0.0) / elapsed,
}
return result
def compute_global_stats(
current: ParsedMetrics,
previous: ParsedMetrics | None,
) -> dict[str, float | None]:
"""Extract process-level and config health metrics from a scrape.
Returns:
{
"open_conns_total": float | None,
"config_reloads_total": float | None,
"config_last_reload_success": float | None, # 1.0 = success, 0.0 = failure
"process_memory_bytes": float | None,
}
"""
stats: dict[str, float | None] = {
"open_conns_total": None,
"config_reloads_total": None,
"config_last_reload_success": None,
"process_memory_bytes": None,
}
# Open connections — sum across all entrypoints/protocols
open_conns = current.get("traefik_open_connections", {})
if open_conns:
stats["open_conns_total"] = sum(open_conns.values())
# Config reloads — sum across result labels (success + failure)
reloads = current.get("traefik_config_reloads_total", {})
if reloads:
stats["config_reloads_total"] = sum(reloads.values())
# Last reload success gauge
last_success = current.get("traefik_config_last_reload_success", {})
if last_success:
# Usually a single sample with no labels
stats["config_last_reload_success"] = next(iter(last_success.values()))
# Process RSS memory
mem = current.get("process_resident_memory_bytes", {})
if mem:
stats["process_memory_bytes"] = next(iter(mem.values()))
return stats
def extract_certs(current: ParsedMetrics) -> list[dict]:
"""Extract TLS certificate expiry info from traefik_tls_certs_not_after.
Returns a list of dicts:
[{"serial": str, "cn": str, "sans": str, "not_after": datetime}, ...]
"""
certs = []
samples = current.get("traefik_tls_certs_not_after", {})
for labels, ts_value in samples.items():
label_dict = dict(labels)
serial = label_dict.get("serial", "")
if not serial:
continue
try:
not_after = datetime.fromtimestamp(ts_value, tz=timezone.utc)
except (ValueError, OSError, OverflowError):
continue
certs.append({
"serial": serial,
"cn": label_dict.get("cn") or None,
"sans": label_dict.get("sans") or None,
"not_after": not_after,
})
return certs
def _percentile_ms(
buckets: dict[frozenset, float],
service: 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("service") != service:
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
if i == 0:
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):
return prev_le * 1000.0
fraction = (target - prev_count) / (count - prev_count)
return (prev_le + fraction * (le - prev_le)) * 1000.0
return 0.0
# ──────────────────────────────────────────────────────────────────────────────
# 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)
@@ -0,0 +1,31 @@
{# plugins/traefik/templates/traefik/index.html #}
{% extends "base.html" %}
{% block title %}Traefik — Fabled Scryer{% endblock %}
{% block content %}
<div style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:0.75rem;margin-bottom:1.5rem;">
<div style="display:flex;align-items:baseline;gap:1rem;">
<h1 class="page-title" style="margin-bottom:0;">Traefik Services</h1>
<span style="color:var(--text-muted);font-size:0.85rem;">polling every {{ poll_interval }}s</span>
</div>
{% include "_time_range.html" %}
</div>
<div id="traefik-rows"
hx-get="/plugins/traefik/rows"
hx-trigger="load, every {{ poll_interval }}s, rangeChange from:body"
hx-include="#time-range"
hx-swap="innerHTML">
</div>
{% if access_log_enabled %}
<div style="margin-top:1.5rem;">
<h2 class="section-title" style="margin-bottom:1rem;">Traffic Origin</h2>
<div id="traefik-traffic"
hx-get="/plugins/traefik/traffic"
hx-trigger="load, every {{ poll_interval }}s, rangeChange from:body"
hx-include="#time-range"
hx-swap="innerHTML">
</div>
</div>
{% endif %}
{% endblock %}
+127
View File
@@ -0,0 +1,127 @@
{# plugins/traefik/templates/traefik/rows.html #}
{# ── Global stats bar ──────────────────────────────────────────────────────── #}
{% if global_stat %}
<div style="display:flex;flex-wrap:wrap;gap:1.5rem;margin-bottom:1.25rem;padding:0.75rem 1rem;background:var(--card-bg);border:1px solid var(--border);border-radius:6px;font-size:0.85rem;font-variant-numeric:tabular-nums;">
{% if global_stat.open_conns_total is not none %}
<span>
<span style="color:var(--text-muted);">Open conns</span>
<span style="margin-left:0.4rem;font-weight:600;">{{ global_stat.open_conns_total | int }}</span>
</span>
{% endif %}
{% if global_stat.config_last_reload_success is not none %}
<span>
<span style="color:var(--text-muted);">Config</span>
{% if global_stat.config_last_reload_success == 1.0 %}
<span style="margin-left:0.4rem;color:var(--green);">&#10003; OK</span>
{% else %}
<span style="margin-left:0.4rem;color:var(--red);">&#10007; failed</span>
{% endif %}
{% if global_stat.config_reloads_total is not none %}
<span style="color:var(--text-dim);margin-left:0.3rem;">({{ global_stat.config_reloads_total | int }} reloads)</span>
{% endif %}
</span>
{% endif %}
{% if global_stat.process_memory_bytes is not none %}
<span>
<span style="color:var(--text-muted);">Memory</span>
{% set mb = (global_stat.process_memory_bytes / 1048576) %}
<span style="margin-left:0.4rem;">
{% if mb >= 1024 %}{{ "%.1f"|format(mb / 1024) }} GB{% else %}{{ "%.0f"|format(mb) }} MB{% endif %}
</span>
</span>
{% endif %}
<span style="margin-left:auto;color:var(--text-dim);font-size:0.78rem;">
{{ global_stat.scraped_at.strftime("%H:%M:%S") }} UTC
</span>
</div>
{% endif %}
{# ── Per-service cards + cert card in shared columns layout ───────────────── #}
{% if router_data or certs %}
<div style="column-width:340px;column-count:3;column-gap:1rem;">
{% for r in router_data %}
<div class="card" style="break-inside:avoid;margin-bottom:1rem;">
<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;">Bandwidth</td>
<td style="padding:0.2rem 0.5rem;">
{% set bps = r.latest.response_bytes_rate %}
{% if bps >= 1048576 %}{{ "%.1f"|format(bps / 1048576) }} MB/s
{% elif bps >= 1024 %}{{ "%.1f"|format(bps / 1024) }} KB/s
{% else %}{{ "%.0f"|format(bps) }} B/s{% endif %}
</td>
<td style="padding:0.2rem 0;"></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 %}
{% if certs %}
<div class="card" style="break-inside:avoid;margin-bottom:1rem;">
<div class="section-title" style="margin-bottom:0.75rem;">TLS Certificates</div>
<table class="table" style="font-size:0.83rem;width:100%;">
{% for cert in certs %}
<tr>
<td style="padding:0.2rem 0 0.2rem 0;word-break:break-all;">{{ cert.cn }}</td>
<td style="text-align:right;padding:0.2rem 0;white-space:nowrap;font-weight:600;
{% if cert.days_remaining < 7 %}color:var(--red);
{% elif cert.days_remaining < 30 %}color:var(--yellow);
{% else %}color:var(--green);{% endif %}">
{{ cert.days_remaining | int }}d
</td>
</tr>
<tr>
<td colspan="2" style="padding:0 0 0.4rem 0;color:var(--text-dim);font-size:0.75rem;">
{{ cert.not_after.strftime("%Y-%m-%d") }}
</td>
</tr>
{% endfor %}
</table>
</div>
{% endif %}
</div>
{% else %}
<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>
{% endif %}
@@ -0,0 +1,101 @@
{# plugins/traefik/templates/traefik/traffic.html #}
{% if summary %}
{# ── Overview bar ─────────────────────────────────────────────────────────── #}
<div style="display:flex;flex-wrap:wrap;gap:1.5rem;margin-bottom:1.25rem;padding:0.75rem 1rem;background:var(--card-bg);border:1px solid var(--border);border-radius:6px;font-size:0.85rem;font-variant-numeric:tabular-nums;align-items:center;">
<span>
<span style="color:var(--text-muted);">Total</span>
<span style="margin-left:0.4rem;font-weight:600;">{{ summary.total }}</span>
</span>
<span>
<span style="color:var(--text-muted);">Internal</span>
<span style="margin-left:0.4rem;color:var(--green);font-weight:600;">{{ summary.internal }}</span>
</span>
<span>
<span style="color:var(--text-muted);">External</span>
<span style="margin-left:0.4rem;color:{% if summary.external_pct > 50 %}var(--yellow){% else %}var(--green){% endif %};font-weight:600;">
{{ summary.external }} ({{ "%.1f"|format(summary.external_pct) }}%)
</span>
</span>
<span>
<span style="color:var(--text-muted);">Bytes</span>
<span style="margin-left:0.4rem;">
{% set mb = summary.total_bytes / 1048576 %}
{% if mb >= 1024 %}{{ "%.1f"|format(mb / 1024) }} GB{% else %}{{ "%.1f"|format(mb) }} MB{% endif %}
</span>
</span>
{# External % sparkline #}
<span style="margin-left:auto;">
<span style="color:var(--text-muted);font-size:0.78rem;margin-right:0.4rem;">ext % over time</span>
{{ summary.sparkline_external | safe }}
</span>
</div>
{# ── Internal/external fill bar ───────────────────────────────────────────── #}
{% set ext_pct = summary.external_pct %}
<div style="height:6px;border-radius:3px;background:var(--border);margin-bottom:1.25rem;overflow:hidden;">
<div style="height:100%;width:{{ ext_pct | round(1) }}%;background:var(--yellow);border-radius:3px;transition:width 0.4s;"></div>
</div>
{# ── Detail cards in columns ──────────────────────────────────────────────── #}
<div style="column-width:300px;column-count:3;column-gap:1rem;">
{# Top source IPs #}
<div class="card" style="break-inside:avoid;margin-bottom:1rem;">
<div class="section-title" style="margin-bottom:0.75rem;">Top Source IPs</div>
<table class="table" style="font-size:0.82rem;width:100%;">
{% for entry in summary.top_ips %}
<tr>
<td style="padding:0.2rem 0.5rem 0.2rem 0;font-family:monospace;">{{ entry.ip }}</td>
{% if entry.country %}
<td style="padding:0.2rem 0.3rem;color:var(--text-muted);">{{ entry.country }}</td>
{% endif %}
<td style="text-align:right;padding:0.2rem 0;">
<span style="color:{% if entry.internal %}var(--text-muted){% else %}var(--yellow){% endif %};">
{{ entry.count }}
</span>
</td>
</tr>
{% endfor %}
</table>
</div>
{# Top routers #}
<div class="card" style="break-inside:avoid;margin-bottom:1rem;">
<div class="section-title" style="margin-bottom:0.75rem;">Top Services Hit</div>
<table class="table" style="font-size:0.82rem;width:100%;">
{% for entry in summary.top_routers %}
<tr>
<td style="padding:0.2rem 0.5rem 0.2rem 0;word-break:break-all;">{{ entry.router }}</td>
<td style="text-align:right;padding:0.2rem 0;">{{ entry.count }}</td>
</tr>
{% endfor %}
</table>
</div>
{# Top countries (only shown if GeoIP is active) #}
{% if summary.top_countries %}
<div class="card" style="break-inside:avoid;margin-bottom:1rem;">
<div class="section-title" style="margin-bottom:0.75rem;">External Traffic by Country</div>
<table class="table" style="font-size:0.82rem;width:100%;">
{% for entry in summary.top_countries %}
<tr>
<td style="padding:0.2rem 0.5rem 0.2rem 0;">{{ entry.country }}</td>
<td style="text-align:right;padding:0.2rem 0;">{{ entry.count }}</td>
</tr>
{% endfor %}
</table>
</div>
{% endif %}
</div>
{% else %}
<div class="card" style="color:var(--text-muted);font-size:0.85rem;">
No access log data yet.
{% if not access_log_enabled %}
Enable <code>access_log.enabled: true</code> in your plugin config and bind-mount the Traefik log directory.
{% endif %}
</div>
{% endif %}
@@ -0,0 +1,56 @@
{# plugins/traefik/templates/traefik/widget.html #}
{% if expiring_certs %}
<div style="margin-bottom:0.6rem;">
{% for cert in expiring_certs %}
<div style="display:flex;justify-content:space-between;font-size:0.8rem;padding:0.15rem 0;
{% if cert.days_remaining < 7 %}color:var(--red);{% else %}color:var(--yellow);{% endif %}">
<span>&#9888; {{ cert.cn }}</span>
<span>{{ cert.days_remaining | int }}d</span>
</div>
{% endfor %}
</div>
{% endif %}
{% 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="Bandwidth">
<span style="color:var(--text-muted);">bw</span>
<span style="margin-left:0.3rem;">
{% set bps = r.latest.response_bytes_rate %}
{% if bps >= 1048576 %}{{ "%.1f"|format(bps / 1048576) }}MB/s
{% elif bps >= 1024 %}{{ "%.0f"|format(bps / 1024) }}KB/s
{% else %}{{ "%.0f"|format(bps) }}B/s{% endif %}
</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 %}
{% if total_routers > 5 %}
<div style="color:var(--text-dim);font-size:0.75rem;padding:0.25rem 0;">
+{{ total_routers - 5 }} more — <a href="/plugins/traefik/">view all →</a>
</div>
{% endif %}
{% else %}
<p class="empty" style="padding:0.5rem 0;font-size:0.85rem;">No Traefik data yet.</p>
{% endif %}
@@ -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 %}