feat: add http, snmp, docker plugins; new widgets and routes for traefik/unifi/ups

- Add HTTP monitor plugin: endpoint checking with status history, latency tracking, time-range views
- Add SNMP plugin: OID polling, device management, metric recording
- Add Docker plugin: container status, resource usage, widget
- Traefik: access log widget, request chart widget, expanded routes
- UniFi: clients widget, devices widget, expanded poll routes
- UPS: history widget, additional routes
- Update plugin index with new entries

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-23 17:40:36 -04:00
parent d9886e8680
commit be4654fc72
45 changed files with 2701 additions and 1 deletions
+24
View File
@@ -0,0 +1,24 @@
# plugins/http/__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 HttpMonitor, HttpResult # noqa: F401
def get_scheduled_tasks() -> list:
from .scheduler import make_task
return [make_task(_app)]
def get_blueprint():
from .routes import http_bp
return http_bp
+105
View File
@@ -0,0 +1,105 @@
# plugins/http/checker.py
"""Core HTTP check logic — runs a single monitor check and returns a result dict."""
from __future__ import annotations
import asyncio
import json
import logging
import ssl
import time
from datetime import datetime, timezone
from urllib.parse import urlparse
import httpx
logger = logging.getLogger(__name__)
async def _get_tls_expiry(hostname: str, port: int) -> datetime | None:
"""Attempt a bare TLS handshake to extract the certificate expiry date."""
ctx = ssl.create_default_context()
try:
reader, writer = await asyncio.wait_for(
asyncio.open_connection(hostname, port, ssl=ctx),
timeout=5.0,
)
cert = writer.get_extra_info("ssl_object").getpeercert()
writer.close()
try:
await writer.wait_closed()
except Exception:
pass
exp_str = cert.get("notAfter", "")
if not exp_str:
return None
# Format: "Mar 22 12:00:00 2027 GMT"
return datetime.strptime(exp_str, "%b %d %H:%M:%S %Y %Z").replace(
tzinfo=timezone.utc
)
except Exception as exc:
logger.debug("TLS check failed for %s:%d%s", hostname, port, exc)
return None
async def run_check(
url: str,
method: str = "GET",
expected_status: int = 200,
content_match: str = "",
headers: dict | None = None,
timeout_seconds: int = 10,
follow_redirects: bool = True,
verify_ssl: bool = True,
) -> dict:
"""
Perform one HTTP check. Returns a dict with:
is_up, status_code, response_ms, content_matched, error_msg, tls_expires_at
"""
result: dict = {
"is_up": False,
"status_code": None,
"response_ms": None,
"content_matched": None,
"error_msg": None,
"tls_expires_at": None,
}
parsed = urlparse(url)
is_https = parsed.scheme.lower() == "https"
t0 = time.monotonic()
try:
async with httpx.AsyncClient(
follow_redirects=follow_redirects,
verify=verify_ssl,
timeout=timeout_seconds,
) as client:
response = await client.request(
method,
url,
headers=headers or {},
)
result["response_ms"] = round((time.monotonic() - t0) * 1000, 1)
result["status_code"] = response.status_code
status_ok = response.status_code == expected_status
if content_match:
matched = content_match in response.text
result["content_matched"] = matched
result["is_up"] = status_ok and matched
else:
result["is_up"] = status_ok
except httpx.TimeoutException:
result["response_ms"] = round((time.monotonic() - t0) * 1000, 1)
result["error_msg"] = "Timeout"
except httpx.ConnectError as exc:
result["error_msg"] = f"Connection error: {exc}"
except Exception as exc:
result["error_msg"] = str(exc)[:512]
# TLS expiry — only for HTTPS, and only when the check succeeded or we got a response
if is_https and result["status_code"] is not None:
port = parsed.port or 443
result["tls_expires_at"] = await _get_tls_expiry(parsed.hostname, port)
return result
View File
+70
View File
@@ -0,0 +1,70 @@
# plugins/http/migrations/env.py
"""Alembic env.py for the HTTP monitoring plugin."""
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
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent))
from fabledscryer.models.base import Base
import fabledscryer.models # noqa: F401
from plugins.http.models import HttpMonitor, HttpResult # noqa: F401
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,52 @@
"""HTTP monitoring plugin initial tables
Revision ID: http_001_initial
Revises: (none — branch off core via depends_on)
Create Date: 2026-03-22
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "http_001_initial"
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = "http"
depends_on: Union[str, Sequence[str], None] = ("0004_app_settings",)
def upgrade() -> None:
op.create_table(
"http_monitors",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("name", sa.String(128), nullable=False),
sa.Column("url", sa.String(2048), nullable=False),
sa.Column("method", sa.String(8), nullable=False, server_default="GET"),
sa.Column("expected_status", sa.Integer, nullable=False, server_default="200"),
sa.Column("content_match", sa.String(512), nullable=False, server_default=""),
sa.Column("headers_json", sa.Text, nullable=False, server_default="{}"),
sa.Column("timeout_seconds", sa.Integer, nullable=False, server_default="10"),
sa.Column("check_interval_seconds", sa.Integer, nullable=False, server_default="0"),
sa.Column("follow_redirects", sa.Boolean, nullable=False, server_default="1"),
sa.Column("verify_ssl", sa.Boolean, nullable=False, server_default="1"),
sa.Column("enabled", sa.Boolean, nullable=False, server_default="1"),
sa.Column("last_checked_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
)
op.create_table(
"http_results",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("monitor_id", sa.String(36), nullable=False, index=True),
sa.Column("checked_at", sa.DateTime(timezone=True), nullable=False, index=True),
sa.Column("status_code", sa.Integer, nullable=True),
sa.Column("response_ms", sa.Float, nullable=True),
sa.Column("is_up", sa.Boolean, nullable=False, server_default="0"),
sa.Column("content_matched", sa.Boolean, nullable=True),
sa.Column("error_msg", sa.String(512), nullable=True),
sa.Column("tls_expires_at", sa.DateTime(timezone=True), nullable=True),
)
def downgrade() -> None:
op.drop_table("http_results")
op.drop_table("http_monitors")
+63
View File
@@ -0,0 +1,63 @@
# plugins/http/models.py
from __future__ import annotations
import uuid
from datetime import datetime, timezone
from sqlalchemy import Boolean, DateTime, Float, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from fabledscryer.models.base import Base
class HttpMonitor(Base):
"""A configured HTTP endpoint to check periodically."""
__tablename__ = "http_monitors"
id: Mapped[str] = mapped_column(
String(36), primary_key=True, default=lambda: str(uuid.uuid4())
)
name: Mapped[str] = mapped_column(String(128), nullable=False)
url: Mapped[str] = mapped_column(String(2048), nullable=False)
method: Mapped[str] = mapped_column(String(8), nullable=False, default="GET")
expected_status: Mapped[int] = mapped_column(Integer, nullable=False, default=200)
# Optional substring to find in the response body (empty = skip check)
content_match: Mapped[str] = mapped_column(String(512), nullable=False, default="")
# JSON dict of extra request headers, e.g. {"Authorization": "Bearer ..."}
headers_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
timeout_seconds: Mapped[int] = mapped_column(Integer, nullable=False, default=10)
# 0 = use global plugin check_interval_seconds
check_interval_seconds: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
follow_redirects: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
verify_ssl: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
# Updated after each check so the scheduler can compute next-run time efficiently
last_checked_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False,
default=lambda: datetime.now(timezone.utc),
)
class HttpResult(Base):
"""One check result for an HttpMonitor."""
__tablename__ = "http_results"
id: Mapped[str] = mapped_column(
String(36), primary_key=True, default=lambda: str(uuid.uuid4())
)
monitor_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
checked_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False,
default=lambda: datetime.now(timezone.utc),
index=True,
)
status_code: Mapped[int | None] = mapped_column(Integer, nullable=True)
response_ms: Mapped[float | None] = mapped_column(Float, nullable=True)
is_up: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
# None when no content_match is configured; True/False when it is
content_matched: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
error_msg: Mapped[str | None] = mapped_column(String(512), nullable=True)
# TLS expiry if the endpoint is HTTPS; None for HTTP or on error
tls_expires_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
+19
View File
@@ -0,0 +1,19 @@
name: http
version: "1.0.0"
description: "Synthetic HTTP endpoint monitoring — status code, response time, content match, TLS expiry"
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/http"
tags:
- monitoring
- http
- synthetic
- uptime
config:
check_interval_seconds: 60
default_timeout_seconds: 10
follow_redirects: true
verify_ssl: true
+229
View File
@@ -0,0 +1,229 @@
# plugins/http/routes.py
from __future__ import annotations
import json
import uuid
from datetime import datetime, timezone
from quart import Blueprint, current_app, render_template, request, redirect, url_for
from sqlalchemy import Integer, cast, func, select
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
from .models import HttpMonitor, HttpResult
http_bp = Blueprint("http", __name__, template_folder="templates")
def _sparkline(values: list[float], width: int = 80, height: int = 20) -> str:
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>'
)
async def _render_rows(range_key: str = DEFAULT_RANGE):
since, range_key = parse_range(range_key)
b_secs = bucket_seconds(since)
bucket_col = (
cast(func.extract('epoch', HttpResult.checked_at), Integer) / b_secs
).label("bucket")
async with current_app.db_sessionmaker() as db:
result = await db.execute(
select(HttpMonitor).order_by(HttpMonitor.created_at)
)
monitors = list(result.scalars())
# Latest result per monitor
latest_map: dict[str, HttpResult] = {}
histories: dict[str, list] = {}
for m in monitors:
r = await db.execute(
select(HttpResult)
.where(HttpResult.monitor_id == m.id)
.order_by(HttpResult.checked_at.desc())
.limit(1)
)
latest = r.scalar_one_or_none()
if latest:
latest_map[m.id] = latest
r2 = await db.execute(
select(
func.avg(HttpResult.response_ms).label("response_ms"),
func.min(HttpResult.is_up.cast(Integer)).label("had_down"),
bucket_col,
)
.where(HttpResult.monitor_id == m.id)
.where(HttpResult.checked_at >= since)
.group_by(bucket_col)
.order_by(bucket_col)
)
histories[m.id] = r2.all()
monitor_data = []
for m in monitors:
hist = histories.get(m.id, [])
latest = latest_map.get(m.id)
now = datetime.now(timezone.utc)
tls_days = None
if latest and latest.tls_expires_at:
tls_days = (latest.tls_expires_at - now).total_seconds() / 86400.0
monitor_data.append({
"monitor": m,
"latest": latest,
"tls_days": tls_days,
"sparkline_ms": _sparkline([r.response_ms or 0 for r in hist]),
})
up = sum(1 for d in monitor_data if d["latest"] and d["latest"].is_up)
down = sum(1 for d in monitor_data if d["latest"] and not d["latest"].is_up)
return monitor_data, up, down, range_key
@http_bp.get("/")
@require_role(UserRole.viewer)
async def index():
poll_interval = current_app.config.get("MONITORS_POLL_INTERVAL", 60)
current_range = request.args.get("range", DEFAULT_RANGE)
return await render_template(
"http/index.html",
poll_interval=poll_interval,
current_range=current_range,
)
@http_bp.get("/rows")
@require_role(UserRole.viewer)
async def rows():
"""HTMX fragment: monitor list with status + sparklines."""
monitor_data, up, down, range_key = await _render_rows(
request.args.get("range", DEFAULT_RANGE)
)
return await render_template(
"http/rows.html",
monitor_data=monitor_data,
up=up,
down=down,
range_key=range_key,
)
@http_bp.post("/add")
@require_role(UserRole.operator)
async def add_monitor():
"""Add a new HTTP monitor."""
form = await request.form
url = form.get("url", "").strip()
if not url:
return redirect(url_for("http.index"))
if not url.startswith(("http://", "https://")):
url = "https://" + url
monitor = HttpMonitor(
id=str(uuid.uuid4()),
name=form.get("name", "").strip() or url,
url=url,
method=form.get("method", "GET").upper(),
expected_status=int(form.get("expected_status", 200) or 200),
content_match=form.get("content_match", "").strip(),
headers_json="{}",
timeout_seconds=int(form.get("timeout_seconds", 10) or 10),
check_interval_seconds=int(form.get("check_interval_seconds", 0) or 0),
follow_redirects="follow_redirects" in form,
verify_ssl="verify_ssl" in form,
enabled=True,
created_at=datetime.now(timezone.utc),
)
async with current_app.db_sessionmaker() as db:
async with db.begin():
db.add(monitor)
return redirect(url_for("http.index"))
@http_bp.post("/<monitor_id>/delete")
@require_role(UserRole.operator)
async def delete_monitor(monitor_id: str):
async with current_app.db_sessionmaker() as db:
async with db.begin():
m = await db.get(HttpMonitor, monitor_id)
if m:
await db.delete(m)
return redirect(url_for("http.index"))
@http_bp.post("/<monitor_id>/toggle")
@require_role(UserRole.operator)
async def toggle_monitor(monitor_id: str):
async with current_app.db_sessionmaker() as db:
async with db.begin():
m = await db.get(HttpMonitor, monitor_id)
if m:
m.enabled = not m.enabled
return redirect(url_for("http.index"))
@http_bp.get("/widget")
@require_role(UserRole.viewer)
async def widget():
"""HTMX dashboard widget: up/down counts + monitor list."""
show_down_only = request.args.get("show_down_only", "no") == "yes"
limit = max(1, min(20, 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(HttpMonitor)
.where(HttpMonitor.enabled == True) # noqa: E712
.order_by(HttpMonitor.created_at)
)
monitors = list(result.scalars())
latest_map: dict[str, HttpResult] = {}
for m in monitors:
r = await db.execute(
select(HttpResult)
.where(HttpResult.monitor_id == m.id)
.order_by(HttpResult.checked_at.desc())
.limit(1)
)
latest = r.scalar_one_or_none()
if latest:
latest_map[m.id] = latest
monitor_data = [
{"monitor": m, "latest": latest_map.get(m.id)}
for m in monitors
]
up = sum(1 for d in monitor_data if d["latest"] and d["latest"].is_up)
down = sum(1 for d in monitor_data if d["latest"] and not d["latest"].is_up)
pending = sum(1 for d in monitor_data if not d["latest"])
if show_down_only:
monitor_data = [d for d in monitor_data if not (d["latest"] and d["latest"].is_up)]
return await render_template(
"http/widget.html",
monitor_data=monitor_data[:limit],
up=up,
down=down,
pending=pending,
show_down_only=show_down_only,
widget_id=widget_id,
)
+121
View File
@@ -0,0 +1,121 @@
# plugins/http/scheduler.py
from __future__ import annotations
import asyncio
import json
import logging
from datetime import datetime, timezone, timedelta
from fabledscryer.core.scheduler import ScheduledTask
from fabledscryer.core.alerts import record_metric
logger = logging.getLogger(__name__)
def make_task(app) -> ScheduledTask:
interval = int(
app.config["PLUGINS"]["http"].get("check_interval_seconds", 60)
)
async def check():
await _do_checks(app)
return ScheduledTask(
name="http_check",
coro_factory=check,
interval_seconds=interval,
run_on_startup=True,
)
async def _do_checks(app) -> None:
from sqlalchemy import select
from .models import HttpMonitor, HttpResult
from .checker import run_check
cfg = app.config["PLUGINS"]["http"]
global_interval = int(cfg.get("check_interval_seconds", 60))
global_timeout = int(cfg.get("default_timeout_seconds", 10))
global_follow = bool(cfg.get("follow_redirects", True))
global_verify = bool(cfg.get("verify_ssl", True))
now = datetime.now(timezone.utc)
async with app.db_sessionmaker() as session:
result = await session.execute(
select(HttpMonitor).where(HttpMonitor.enabled == True) # noqa: E712
)
monitors = list(result.scalars())
# Filter to monitors whose next check time has passed
due: list[HttpMonitor] = []
for m in monitors:
effective_interval = m.check_interval_seconds or global_interval
if m.last_checked_at is None:
due.append(m)
elif (now - m.last_checked_at).total_seconds() >= effective_interval:
due.append(m)
if not due:
return
async def _check_one(monitor: HttpMonitor) -> tuple[HttpMonitor, dict]:
try:
headers = json.loads(monitor.headers_json or "{}")
except (ValueError, TypeError):
headers = {}
result = await run_check(
url=monitor.url,
method=monitor.method,
expected_status=monitor.expected_status,
content_match=monitor.content_match,
headers=headers,
timeout_seconds=monitor.timeout_seconds or global_timeout,
follow_redirects=monitor.follow_redirects if monitor.follow_redirects is not None else global_follow,
verify_ssl=monitor.verify_ssl if monitor.verify_ssl is not None else global_verify,
)
return monitor, result
pairs = await asyncio.gather(*[_check_one(m) for m in due], return_exceptions=True)
check_time = datetime.now(timezone.utc)
async with app.db_sessionmaker() as session:
async with session.begin():
for item in pairs:
if isinstance(item, Exception):
logger.error("HTTP check task error: %s", item)
continue
monitor, res = item
session.add(HttpResult(
monitor_id=monitor.id,
checked_at=check_time,
status_code=res["status_code"],
response_ms=res["response_ms"],
is_up=res["is_up"],
content_matched=res["content_matched"],
error_msg=res["error_msg"],
tls_expires_at=res["tls_expires_at"],
))
# Update monitor's last_checked_at for next-run scheduling
db_monitor = await session.get(HttpMonitor, monitor.id)
if db_monitor:
db_monitor.last_checked_at = check_time
# Alert pipeline
if res["response_ms"] is not None:
await record_metric(
session=session,
source_module="http",
resource_name=monitor.name,
metric_name="response_ms",
value=res["response_ms"],
)
await record_metric(
session=session,
source_module="http",
resource_name=monitor.name,
metric_name="is_up",
value=1.0 if res["is_up"] else 0.0,
)
+77
View File
@@ -0,0 +1,77 @@
{% extends "base.html" %}
{% block title %}HTTP Monitors — Fabled Scryer{% endblock %}
{% block content %}
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.5rem;gap:1rem;flex-wrap:wrap;">
<h1 class="page-title" style="margin-bottom:0;">HTTP Monitors</h1>
{% include "_time_range.html" %}
</div>
{# ── Add monitor form ─────────────────────────────────────────────────────── #}
<div class="card" style="margin-bottom:1.25rem;">
<div class="section-title" style="margin-bottom:0.75rem;">Add Monitor</div>
<form method="post" action="/plugins/http/add" style="display:grid;gap:0.6rem;">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:0.6rem;">
<div>
<label style="font-size:0.75rem;color:var(--text-muted);display:block;margin-bottom:0.2rem;">Name</label>
<input type="text" name="name" placeholder="My Service" autocomplete="off"
style="width:100%;padding:0.4rem 0.65rem;background:var(--bg);border:1px solid var(--border-mid);border-radius:4px;color:var(--text);font-size:0.88rem;">
</div>
<div>
<label style="font-size:0.75rem;color:var(--text-muted);display:block;margin-bottom:0.2rem;">URL <span style="color:var(--red);">*</span></label>
<input type="text" name="url" placeholder="https://example.com" autocomplete="off" required
style="width:100%;padding:0.4rem 0.65rem;background:var(--bg);border:1px solid var(--border-mid);border-radius:4px;color:var(--text);font-size:0.88rem;">
</div>
</div>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:0.6rem;align-items:end;">
<div>
<label style="font-size:0.75rem;color:var(--text-muted);display:block;margin-bottom:0.2rem;">Method</label>
<select name="method" style="width:100%;padding:0.4rem 0.65rem;background:var(--bg);border:1px solid var(--border-mid);border-radius:4px;color:var(--text);font-size:0.88rem;">
<option value="GET">GET</option>
<option value="HEAD">HEAD</option>
<option value="POST">POST</option>
</select>
</div>
<div>
<label style="font-size:0.75rem;color:var(--text-muted);display:block;margin-bottom:0.2rem;">Expected status</label>
<input type="number" name="expected_status" value="200" min="100" max="599"
style="width:100%;padding:0.4rem 0.65rem;background:var(--bg);border:1px solid var(--border-mid);border-radius:4px;color:var(--text);font-size:0.88rem;">
</div>
<div>
<label style="font-size:0.75rem;color:var(--text-muted);display:block;margin-bottom:0.2rem;">Timeout (s)</label>
<input type="number" name="timeout_seconds" value="10" min="1" max="60"
style="width:100%;padding:0.4rem 0.65rem;background:var(--bg);border:1px solid var(--border-mid);border-radius:4px;color:var(--text);font-size:0.88rem;">
</div>
<div>
<label style="font-size:0.75rem;color:var(--text-muted);display:block;margin-bottom:0.2rem;">Interval (s, 0=global)</label>
<input type="number" name="check_interval_seconds" value="0" min="0"
style="width:100%;padding:0.4rem 0.65rem;background:var(--bg);border:1px solid var(--border-mid);border-radius:4px;color:var(--text);font-size:0.88rem;">
</div>
<div>
<label style="font-size:0.75rem;color:var(--text-muted);display:block;margin-bottom:0.2rem;">Content match (substring)</label>
<input type="text" name="content_match" placeholder="optional"
style="width:100%;padding:0.4rem 0.65rem;background:var(--bg);border:1px solid var(--border-mid);border-radius:4px;color:var(--text);font-size:0.88rem;">
</div>
<div style="display:flex;flex-direction:column;gap:0.3rem;padding-top:0.8rem;">
<label style="display:flex;align-items:center;gap:0.4rem;font-size:0.82rem;cursor:pointer;">
<input type="checkbox" name="follow_redirects" checked> Follow redirects
</label>
<label style="display:flex;align-items:center;gap:0.4rem;font-size:0.82rem;cursor:pointer;">
<input type="checkbox" name="verify_ssl" checked> Verify SSL
</label>
</div>
</div>
<div>
<button type="submit" class="btn btn-sm">Add Monitor</button>
</div>
</form>
</div>
{# ── Monitor list ─────────────────────────────────────────────────────────── #}
<div id="http-rows"
hx-get="/plugins/http/rows"
hx-trigger="load, every {{ poll_interval }}s, rangeChange from:body"
hx-include="#time-range"
hx-swap="innerHTML">
<div style="color:var(--text-muted);font-size:0.9rem;padding:2rem;">Loading...</div>
</div>
{% endblock %}
+109
View File
@@ -0,0 +1,109 @@
{# http/rows.html — HTMX fragment for HTTP monitor status list #}
{# ── Summary ─────────────────────────────────────────────────────────────── #}
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(130px,1fr));gap:0.75rem;margin-bottom:1.25rem;">
<div class="card" style="margin-bottom:0;">
<div class="section-title" style="margin-bottom:0.3rem;">Up</div>
<span class="stat-val" style="color:var(--green);">{{ up }}</span>
</div>
<div class="card" style="margin-bottom:0;">
<div class="section-title" style="margin-bottom:0.3rem;">Down</div>
<span class="stat-val" style="color:{% if down %}var(--red){% else %}var(--text-muted){% endif %};">{{ down }}</span>
</div>
<div class="card" style="margin-bottom:0;">
<div class="section-title" style="margin-bottom:0.3rem;">Monitors</div>
<span class="stat-val">{{ monitor_data | length }}</span>
</div>
</div>
{# ── Monitor list ─────────────────────────────────────────────────────────── #}
{% if monitor_data %}
<div class="card-flush">
<table class="table">
<thead>
<tr>
<th>Monitor</th>
<th>Status</th>
<th>Response</th>
<th style="min-width:90px;">History</th>
<th>TLS expiry</th>
<th style="width:1%;"></th>
</tr>
</thead>
<tbody>
{% for item in monitor_data %}
{% set m = item.monitor %}
{% set latest = item.latest %}
<tr>
<td>
<div style="font-weight:500;font-size:0.9rem;">{{ m.name }}</div>
<div style="font-size:0.75rem;color:var(--text-muted);font-family:ui-monospace,monospace;word-break:break-all;">
<span style="color:var(--text-dim);">{{ m.method }}</span> {{ m.url }}
</div>
{% if not m.enabled %}
<span style="font-size:0.72rem;color:var(--text-dim);">paused</span>
{% endif %}
</td>
<td>
{% if not latest %}
<span style="color:var(--text-dim);font-size:0.82rem;">pending</span>
{% elif latest.is_up %}
<div style="display:flex;align-items:center;gap:0.4rem;">
<span class="dot dot-up"></span>
<span style="font-size:0.82rem;color:var(--green);">{{ latest.status_code }}</span>
</div>
{% else %}
<div style="display:flex;align-items:center;gap:0.4rem;">
<span class="dot dot-down"></span>
<span style="font-size:0.82rem;color:var(--red);">
{{ latest.status_code or latest.error_msg or "error" }}
</span>
</div>
{% if latest.content_matched == false %}
<div style="font-size:0.72rem;color:var(--orange);">content mismatch</div>
{% endif %}
{% endif %}
</td>
<td style="font-family:ui-monospace,monospace;font-size:0.88rem;">
{% if latest and latest.response_ms is not none %}
<span style="color:{% if latest.response_ms > 2000 %}var(--red){% elif latest.response_ms > 500 %}var(--orange){% else %}var(--text){% endif %};">
{{ "%.0f" | format(latest.response_ms) }}ms
</span>
{% else %}
<span style="color:var(--text-dim);"></span>
{% endif %}
</td>
<td>{{ item.sparkline_ms | safe }}</td>
<td style="font-size:0.82rem;">
{% if item.tls_days is not none %}
<span style="color:{% if item.tls_days < 14 %}var(--red){% elif item.tls_days < 30 %}var(--orange){% else %}var(--text-muted){% endif %};">
{{ item.tls_days | int }}d
</span>
{% elif m.url.startswith('https://') %}
<span style="color:var(--text-dim);"></span>
{% endif %}
</td>
<td style="white-space:nowrap;">
<form method="post" action="/plugins/http/{{ m.id }}/toggle" style="display:inline;margin:0;">
<button type="submit" class="btn btn-ghost btn-sm" style="font-size:0.72rem;">
{% if m.enabled %}Pause{% else %}Resume{% endif %}
</button>
</form>
<form method="post" action="/plugins/http/{{ m.id }}/delete"
style="display:inline;margin:0;"
onsubmit="return confirm('Delete {{ m.name }}?')">
<button type="submit" class="btn btn-danger btn-sm" style="font-size:0.72rem;">Del</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="card" style="text-align:center;padding:3rem;">
<div style="color:var(--text-muted);font-size:0.9rem;">
No monitors configured yet. Add one using the form above.
</div>
</div>
{% endif %}
+56
View File
@@ -0,0 +1,56 @@
{# http/widget.html — dashboard widget: HTTP monitor summary #}
{% if not monitor_data and up == 0 and down == 0 and pending == 0 %}
<div style="color:var(--text-muted);font-size:0.85rem;padding:0.5rem 0;">
No monitors configured. <a href="/plugins/http/">Add monitors →</a>
</div>
{% else %}
<div style="display:flex;gap:1rem;margin-bottom:0.65rem;flex-wrap:wrap;">
{% if up %}
<div>
<span style="font-size:1.4rem;font-weight:700;line-height:1;color:var(--green);">{{ up }}</span>
<span style="font-size:0.78rem;color:var(--text-muted);margin-left:0.25rem;">up</span>
</div>
{% endif %}
{% if down %}
<div>
<span style="font-size:1.4rem;font-weight:700;line-height:1;color:var(--red);">{{ down }}</span>
<span style="font-size:0.78rem;color:var(--text-muted);margin-left:0.25rem;">down</span>
</div>
{% endif %}
{% if pending %}
<div>
<span style="font-size:1.4rem;font-weight:700;line-height:1;color:var(--text-dim);">{{ pending }}</span>
<span style="font-size:0.78rem;color:var(--text-muted);margin-left:0.25rem;">pending</span>
</div>
{% endif %}
</div>
<div style="display:grid;gap:3px;">
{% for item in monitor_data %}
{% set latest = item.latest %}
<div style="display:flex;align-items:center;gap:0.5rem;font-size:0.82rem;padding:0.15rem 0;">
{% if not latest %}
<span class="dot dot-dim"></span>
{% elif latest.is_up %}
<span class="dot dot-up"></span>
{% else %}
<span class="dot dot-down"></span>
{% endif %}
<span style="flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">
{{ item.monitor.name }}
</span>
{% if latest and latest.response_ms is not none %}
<span style="font-size:0.72rem;font-family:ui-monospace,monospace;color:var(--text-muted);flex-shrink:0;">
{{ "%.0f" | format(latest.response_ms) }}ms
</span>
{% elif latest and not latest.is_up %}
<span style="font-size:0.72rem;color:var(--red);flex-shrink:0;">
{{ latest.status_code or "err" }}
</span>
{% endif %}
</div>
{% endfor %}
</div>
{% endif %}