refactor: rename package directory fabledscryer → roundtable
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,261 @@
|
||||
from __future__ import annotations
|
||||
import asyncio
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import and_, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from fabledscryer.models.alerts import (
|
||||
AlertEvent,
|
||||
AlertOperator,
|
||||
AlertRule,
|
||||
AlertState,
|
||||
AlertStateEnum,
|
||||
MaintenanceWindow,
|
||||
)
|
||||
from fabledscryer.models.metrics import PluginMetric
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from quart import Quart
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_app: "Quart | None" = None
|
||||
|
||||
|
||||
def init_alerts(app: "Quart") -> None:
|
||||
"""Store app reference for use in notification dispatch tasks."""
|
||||
global _app
|
||||
_app = app
|
||||
|
||||
|
||||
async def record_metric(
|
||||
session: AsyncSession,
|
||||
source_module: str,
|
||||
resource_name: str,
|
||||
metric_name: str,
|
||||
value: float,
|
||||
) -> None:
|
||||
"""Write a metric row and evaluate alert rules inline.
|
||||
|
||||
Must be called inside an active transaction:
|
||||
async with session.begin():
|
||||
await record_metric(session, ...)
|
||||
|
||||
Notification I/O is deferred via asyncio.create_task() so no network
|
||||
calls occur while the transaction is open.
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
# Write metric row
|
||||
metric = PluginMetric(
|
||||
source_module=source_module,
|
||||
resource_name=resource_name,
|
||||
metric_name=metric_name,
|
||||
value=value,
|
||||
recorded_at=now,
|
||||
)
|
||||
session.add(metric)
|
||||
await session.flush()
|
||||
|
||||
# Check active maintenance windows — skip rule evaluation if suppressed
|
||||
mw_result = await session.execute(
|
||||
select(MaintenanceWindow).where(
|
||||
MaintenanceWindow.start_at <= now,
|
||||
MaintenanceWindow.end_at >= now,
|
||||
or_(
|
||||
MaintenanceWindow.scope_source.is_(None),
|
||||
and_(
|
||||
MaintenanceWindow.scope_source == source_module,
|
||||
or_(
|
||||
MaintenanceWindow.scope_resource.is_(None),
|
||||
MaintenanceWindow.scope_resource == resource_name,
|
||||
),
|
||||
),
|
||||
),
|
||||
).limit(1)
|
||||
)
|
||||
if mw_result.scalar_one_or_none() is not None:
|
||||
return # suppressed by maintenance window
|
||||
|
||||
# Load matching enabled rules
|
||||
result = await session.execute(
|
||||
select(AlertRule).where(
|
||||
AlertRule.source_module == source_module,
|
||||
AlertRule.resource_name == resource_name,
|
||||
AlertRule.metric_name == metric_name,
|
||||
AlertRule.enabled.is_(True),
|
||||
)
|
||||
)
|
||||
rules = result.scalars().all()
|
||||
|
||||
for rule in rules:
|
||||
state_result = await session.execute(
|
||||
select(AlertState).where(AlertState.rule_id == rule.id)
|
||||
)
|
||||
alert_state = state_result.scalar_one_or_none()
|
||||
if alert_state is None:
|
||||
logger.warning(f"No alert_state row for rule {rule.id}, skipping")
|
||||
continue
|
||||
|
||||
notifications = await _evaluate_rule(session, rule, alert_state, value, now)
|
||||
|
||||
for notif_type, event_id in notifications:
|
||||
if _app is not None:
|
||||
asyncio.create_task(
|
||||
_dispatch_notification(
|
||||
_app, notif_type, rule, value, event_id
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _is_breached(value: float, operator: AlertOperator, threshold: float) -> bool:
|
||||
op = operator.value
|
||||
if op == ">":
|
||||
return value > threshold
|
||||
if op == "<":
|
||||
return value < threshold
|
||||
if op == ">=":
|
||||
return value >= threshold
|
||||
if op == "<=":
|
||||
return value <= threshold
|
||||
if op == "==":
|
||||
return value == threshold
|
||||
if op == "!=":
|
||||
return value != threshold
|
||||
return False
|
||||
|
||||
|
||||
async def _evaluate_rule(
|
||||
session: AsyncSession,
|
||||
rule: AlertRule,
|
||||
state: AlertState,
|
||||
value: float,
|
||||
now: datetime,
|
||||
) -> list[tuple[str, str]]:
|
||||
"""Evaluate a single rule against a metric value. Returns list of (notif_type, event_id)."""
|
||||
breached = _is_breached(value, rule.operator, rule.threshold)
|
||||
from_state = state.state
|
||||
notifications: list[tuple[str, str]] = []
|
||||
|
||||
if state.state == AlertStateEnum.inactive:
|
||||
if breached:
|
||||
state.consecutive_failure_count = 1
|
||||
if state.consecutive_failure_count >= rule.consecutive_failures_required:
|
||||
state.state = AlertStateEnum.firing
|
||||
state.last_transition_at = now
|
||||
event_id = _add_event(session, rule.id, from_state.value, "firing", value, now)
|
||||
notifications.append(("firing", event_id))
|
||||
else:
|
||||
state.state = AlertStateEnum.pending
|
||||
state.last_transition_at = now
|
||||
|
||||
elif state.state == AlertStateEnum.pending:
|
||||
if breached:
|
||||
state.consecutive_failure_count += 1
|
||||
if state.consecutive_failure_count >= rule.consecutive_failures_required:
|
||||
state.state = AlertStateEnum.firing
|
||||
state.last_transition_at = now
|
||||
event_id = _add_event(session, rule.id, from_state.value, "firing", value, now)
|
||||
notifications.append(("firing", event_id))
|
||||
else:
|
||||
state.consecutive_failure_count = 0
|
||||
state.state = AlertStateEnum.inactive
|
||||
state.last_transition_at = now
|
||||
|
||||
elif state.state == AlertStateEnum.firing:
|
||||
if not breached:
|
||||
# RESOLVED is transient: record event, notify, then immediately go inactive
|
||||
event_id = _add_event(session, rule.id, from_state.value, "resolved", value, now)
|
||||
notifications.append(("resolved", event_id))
|
||||
state.consecutive_failure_count = 0
|
||||
state.state = AlertStateEnum.inactive
|
||||
state.last_transition_at = now
|
||||
|
||||
elif state.state == AlertStateEnum.acknowledged:
|
||||
if not breached:
|
||||
event_id = _add_event(session, rule.id, from_state.value, "resolved", value, now)
|
||||
notifications.append(("resolved", event_id))
|
||||
state.consecutive_failure_count = 0
|
||||
state.state = AlertStateEnum.inactive
|
||||
state.last_transition_at = now
|
||||
state.acknowledged_by = None
|
||||
state.acknowledged_at = None
|
||||
else:
|
||||
state.consecutive_failure_count += 1
|
||||
if state.consecutive_failure_count >= rule.consecutive_failures_required:
|
||||
state.state = AlertStateEnum.firing
|
||||
state.last_transition_at = now
|
||||
event_id = _add_event(session, rule.id, from_state.value, "firing", value, now)
|
||||
notifications.append(("firing", event_id))
|
||||
|
||||
await session.flush()
|
||||
return notifications
|
||||
|
||||
|
||||
def _add_event(
|
||||
session: AsyncSession,
|
||||
rule_id: str,
|
||||
from_state: str,
|
||||
to_state: str,
|
||||
value: float,
|
||||
now: datetime,
|
||||
) -> str:
|
||||
"""Add an AlertEvent row and return its ID."""
|
||||
event_id = str(uuid.uuid4())
|
||||
event = AlertEvent(
|
||||
id=event_id,
|
||||
rule_id=rule_id,
|
||||
from_state=from_state,
|
||||
to_state=to_state,
|
||||
metric_value=value,
|
||||
transitioned_at=now,
|
||||
)
|
||||
session.add(event)
|
||||
return event_id
|
||||
|
||||
|
||||
async def _dispatch_notification(
|
||||
app: "Quart",
|
||||
notif_type: str,
|
||||
rule: AlertRule,
|
||||
value: float,
|
||||
event_id: str,
|
||||
) -> None:
|
||||
"""Run after the transaction commits. Sends notifications and updates the event row."""
|
||||
from fabledscryer.core.notifications import dispatch_notifications
|
||||
|
||||
alert_data = {
|
||||
"rule_name": rule.name,
|
||||
"state": notif_type.upper(),
|
||||
"metric": rule.metric_name,
|
||||
"value": value,
|
||||
"threshold": rule.threshold,
|
||||
"resource": rule.resource_name,
|
||||
"source_module": rule.source_module,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
smtp_cfg = app.config.get("SMTP", {})
|
||||
webhook_cfg = app.config.get("WEBHOOK", {})
|
||||
results = await dispatch_notifications(smtp_cfg, webhook_cfg, alert_data)
|
||||
|
||||
# Update event row with delivery results in a new transaction
|
||||
async with app.db_sessionmaker() as db:
|
||||
async with db.begin():
|
||||
event_result = await db.execute(
|
||||
select(AlertEvent).where(AlertEvent.id == event_id)
|
||||
)
|
||||
event = event_result.scalar_one_or_none()
|
||||
if event:
|
||||
email_r = results.get("email")
|
||||
if email_r:
|
||||
event.email_sent = email_r["sent"]
|
||||
event.email_error = email_r.get("error")
|
||||
webhook_r = results.get("webhook")
|
||||
if webhook_r:
|
||||
event.webhook_sent = webhook_r["sent"]
|
||||
event.webhook_error = webhook_r.get("error")
|
||||
@@ -0,0 +1,40 @@
|
||||
"""fabledscryer/core/audit.py
|
||||
|
||||
Helpers for writing audit log entries. Each call opens its own DB
|
||||
session so audit events are committed independently of the calling
|
||||
route's transaction (audit is only written after the main action
|
||||
succeeds, by calling this after the main `async with db.begin()` block).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def log_audit(
|
||||
app,
|
||||
user_id: str | None,
|
||||
username: str,
|
||||
action: str,
|
||||
entity_type: str | None = None,
|
||||
entity_id: str | None = None,
|
||||
detail: dict | None = None,
|
||||
) -> None:
|
||||
"""Write one audit event. Never raises — failures are logged and swallowed."""
|
||||
from fabledscryer.models.audit import AuditEvent
|
||||
try:
|
||||
async with app.db_sessionmaker() as db:
|
||||
async with db.begin():
|
||||
db.add(AuditEvent(
|
||||
user_id=user_id,
|
||||
username=username or "unknown",
|
||||
action=action,
|
||||
entity_type=entity_type,
|
||||
entity_id=entity_id,
|
||||
detail_json=json.dumps(detail) if detail else None,
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
))
|
||||
except Exception:
|
||||
logger.exception("Failed to write audit event action=%r", action)
|
||||
@@ -0,0 +1,35 @@
|
||||
from __future__ import annotations
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import delete
|
||||
|
||||
from fabledscryer.models.monitors import DnsResult, PingResult
|
||||
from fabledscryer.models.metrics import PluginMetric
|
||||
from fabledscryer.models.ansible import AnsibleRun
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from quart import Quart
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def run_cleanup(app: "Quart") -> None:
|
||||
"""Delete rows older than DATA_RETENTION_DAYS from time-series tables."""
|
||||
retention_days: int = app.config.get("DATA_RETENTION_DAYS", 90)
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(days=retention_days)
|
||||
|
||||
async with app.db_sessionmaker() as session:
|
||||
async with session.begin():
|
||||
for model, ts_col in [
|
||||
(PingResult, PingResult.probed_at),
|
||||
(DnsResult, DnsResult.resolved_at),
|
||||
(PluginMetric, PluginMetric.recorded_at),
|
||||
(AnsibleRun, AnsibleRun.started_at),
|
||||
]:
|
||||
result = await session.execute(
|
||||
delete(model).where(ts_col < cutoff)
|
||||
)
|
||||
if result.rowcount:
|
||||
logger.info(f"Pruned {result.rowcount} rows from {model.__tablename__}")
|
||||
@@ -0,0 +1,82 @@
|
||||
# fabledscryer/core/migration_runner.py
|
||||
from __future__ import annotations
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def discover_all_plugin_migration_dirs(plugin_dir: Path) -> list[Path]:
|
||||
"""Scan plugin_dir for any migrations/ subdirs, regardless of enabled status.
|
||||
|
||||
Used by run_core_migrations so Alembic can resolve the full revision graph
|
||||
even when a plugin migration is already stamped in alembic_version.
|
||||
"""
|
||||
dirs: list[Path] = []
|
||||
if not plugin_dir.exists():
|
||||
return dirs
|
||||
for entry in sorted(plugin_dir.iterdir()):
|
||||
if not entry.is_dir():
|
||||
continue
|
||||
mdir = entry / "migrations"
|
||||
if mdir.exists():
|
||||
dirs.append(mdir)
|
||||
return dirs
|
||||
|
||||
|
||||
def run_core_migrations(db_url: str, plugin_dir: Path | None = None) -> None:
|
||||
"""Run core Alembic migrations.
|
||||
|
||||
Includes all discovered plugin migration dirs (if plugin_dir given) so
|
||||
Alembic can resolve any previously-applied plugin revisions in the graph.
|
||||
Called first so the app_settings table exists before loading settings.
|
||||
"""
|
||||
dirs = discover_all_plugin_migration_dirs(plugin_dir) if plugin_dir else []
|
||||
_run(db_url, plugin_migration_dirs=dirs)
|
||||
|
||||
|
||||
def run_plugin_migrations(db_url: str, plugin_migration_dirs: list[Path]) -> None:
|
||||
"""Run plugin Alembic migrations after settings are loaded."""
|
||||
if plugin_migration_dirs:
|
||||
_run(db_url, plugin_migration_dirs=plugin_migration_dirs)
|
||||
|
||||
|
||||
def run_migrations(db_url: str, plugin_migration_dirs: list[Path] | None = None) -> None:
|
||||
"""Run core + plugin Alembic migrations (single-phase convenience wrapper)."""
|
||||
_run(db_url, plugin_migration_dirs=plugin_migration_dirs or [])
|
||||
|
||||
|
||||
def _run(db_url: str, plugin_migration_dirs: list[Path]) -> None:
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
|
||||
core_migrations = Path(__file__).parent.parent / "migrations"
|
||||
core_versions = core_migrations / "versions"
|
||||
|
||||
cfg = Config()
|
||||
cfg.set_main_option("script_location", str(core_migrations))
|
||||
cfg.set_main_option("sqlalchemy.url", db_url)
|
||||
|
||||
version_locs = [str(core_versions)]
|
||||
for plugin_dir in plugin_migration_dirs:
|
||||
pv = plugin_dir / "versions"
|
||||
if pv.exists():
|
||||
version_locs.append(str(pv))
|
||||
|
||||
cfg.set_main_option("version_locations", " ".join(version_locs))
|
||||
|
||||
logger.info("Running migrations (version_locations: %s)", " ".join(version_locs))
|
||||
command.upgrade(cfg, "heads")
|
||||
logger.info("Migrations complete")
|
||||
|
||||
|
||||
def get_plugin_migration_dirs(plugin_dir: Path, plugins_cfg: dict) -> list[Path]:
|
||||
"""Return migration directories for enabled plugins (filesystem scan only)."""
|
||||
dirs = []
|
||||
for name, cfg in plugins_cfg.items():
|
||||
if not cfg.get("enabled", False):
|
||||
continue
|
||||
mdir = plugin_dir / name / "migrations"
|
||||
if mdir.exists():
|
||||
dirs.append(mdir)
|
||||
return dirs
|
||||
@@ -0,0 +1,24 @@
|
||||
from __future__ import annotations
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def get_core_head() -> str:
|
||||
"""Return the current head revision ID of the core migration scripts.
|
||||
|
||||
Called at migration authoring time (when running ``alembic revision``),
|
||||
not at application runtime. The returned ID should be captured statically
|
||||
in a plugin's ``depends_on`` field.
|
||||
"""
|
||||
from alembic.config import Config
|
||||
from alembic.script import ScriptDirectory
|
||||
|
||||
migrations_dir = Path(__file__).parent.parent / "migrations"
|
||||
cfg = Config()
|
||||
cfg.set_main_option("script_location", str(migrations_dir))
|
||||
script = ScriptDirectory.from_config(cfg)
|
||||
heads = script.get_heads()
|
||||
if len(heads) != 1:
|
||||
raise RuntimeError(
|
||||
f"Expected exactly 1 core migration head, got {len(heads)}: {heads}"
|
||||
)
|
||||
return heads[0]
|
||||
@@ -0,0 +1,100 @@
|
||||
from __future__ import annotations
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import smtplib
|
||||
import ssl
|
||||
import types
|
||||
from email.message import EmailMessage
|
||||
|
||||
import httpx
|
||||
from jinja2 import Template
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def dispatch_notifications(
|
||||
smtp_cfg: dict,
|
||||
webhook_cfg: dict,
|
||||
alert_data: dict,
|
||||
) -> dict:
|
||||
"""Dispatch all configured notification channels. Returns results dict."""
|
||||
results = {}
|
||||
|
||||
if smtp_cfg.get("host") and smtp_cfg.get("recipients"):
|
||||
results["email"] = await _send_email(smtp_cfg, alert_data)
|
||||
|
||||
if webhook_cfg.get("url"):
|
||||
results["webhook"] = await _send_webhook(webhook_cfg, alert_data)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
async def _send_email(cfg: dict, alert: dict) -> dict:
|
||||
try:
|
||||
msg = EmailMessage()
|
||||
msg["Subject"] = f"[Fabled Scryer] {alert['state']} — {alert['rule_name']}"
|
||||
msg["From"] = cfg.get("username", "fabledscryer@localhost")
|
||||
msg["To"] = ", ".join(cfg["recipients"])
|
||||
msg.set_content(
|
||||
f"Alert: {alert['state']}\n"
|
||||
f"Rule: {alert['rule_name']}\n"
|
||||
f"Resource: {alert['resource']}\n"
|
||||
f"Metric: {alert['metric']} = {alert['value']}\n"
|
||||
f"Threshold: {alert['threshold']}\n"
|
||||
f"Time: {alert['timestamp']}\n"
|
||||
)
|
||||
loop = asyncio.get_running_loop()
|
||||
await loop.run_in_executor(None, _smtp_send_sync, cfg, msg)
|
||||
return {"sent": True, "error": None}
|
||||
except Exception as exc:
|
||||
logger.error(f"Email notification failed: {exc}")
|
||||
return {"sent": False, "error": str(exc)}
|
||||
|
||||
|
||||
def _smtp_send_sync(cfg: dict, msg: EmailMessage) -> None:
|
||||
ctx = ssl.create_default_context() if cfg.get("tls", True) else None
|
||||
with smtplib.SMTP(cfg["host"], cfg.get("port", 587)) as smtp:
|
||||
if ctx:
|
||||
smtp.starttls(context=ctx)
|
||||
if cfg.get("username"):
|
||||
smtp.login(cfg["username"], cfg.get("password", ""))
|
||||
smtp.send_message(msg)
|
||||
|
||||
|
||||
async def _send_webhook(cfg: dict, alert: dict) -> dict:
|
||||
template_str = cfg.get(
|
||||
"template",
|
||||
'{"content": "{{ alert.state }} — {{ alert.rule_name }}"}',
|
||||
)
|
||||
|
||||
# Render Jinja2 template with alert as a namespace object (attribute access)
|
||||
try:
|
||||
alert_ns = types.SimpleNamespace(**alert)
|
||||
rendered = Template(template_str).render(alert=alert_ns)
|
||||
except Exception as exc:
|
||||
error = f"Template render failed: {exc}"
|
||||
logger.error(error)
|
||||
return {"sent": False, "error": error}
|
||||
|
||||
# Validate JSON before sending
|
||||
try:
|
||||
json.loads(rendered)
|
||||
except json.JSONDecodeError as exc:
|
||||
error = f"Rendered webhook template is not valid JSON: {exc}"
|
||||
logger.error(error)
|
||||
return {"sent": False, "error": error}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
response = await client.post(
|
||||
cfg["url"],
|
||||
content=rendered,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
response.raise_for_status()
|
||||
return {"sent": True, "error": None}
|
||||
except Exception as exc:
|
||||
error = str(exc)
|
||||
logger.error(f"Webhook notification failed: {error}")
|
||||
return {"sent": False, "error": error}
|
||||
@@ -0,0 +1,93 @@
|
||||
# fabledscryer/core/plugin_index.py
|
||||
"""Remote plugin catalog: fetch, parse, and cache the plugin index.yaml."""
|
||||
from __future__ import annotations
|
||||
import logging
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_CACHE_TTL = 300 # seconds
|
||||
# Per-URL cache: url → (raw_data, fetched_at monotonic)
|
||||
_url_cache: dict[str, tuple[dict, float]] = {}
|
||||
|
||||
|
||||
class CatalogPlugin:
|
||||
"""One entry from the remote plugin index."""
|
||||
|
||||
__slots__ = (
|
||||
"name", "version", "description", "author",
|
||||
"min_app_version", "download_url", "checksum_sha256",
|
||||
"repository_url", "homepage", "license", "tags",
|
||||
)
|
||||
|
||||
def __init__(self, data: dict[str, Any]) -> None:
|
||||
self.name: str = data.get("name", "")
|
||||
self.version: str = data.get("version", "0.0.0")
|
||||
self.description: str = data.get("description", "")
|
||||
self.author: str = data.get("author", "")
|
||||
self.min_app_version: str | None = data.get("min_app_version")
|
||||
self.download_url: str = data.get("download_url", "")
|
||||
self.checksum_sha256: str = data.get("checksum_sha256", "")
|
||||
self.repository_url: str = data.get("repository_url", "")
|
||||
self.homepage: str = data.get("homepage", "")
|
||||
self.license: str = data.get("license", "")
|
||||
self.tags: list[str] = data.get("tags", [])
|
||||
|
||||
|
||||
async def _fetch_one(url: str, force: bool = False) -> list[CatalogPlugin]:
|
||||
"""Fetch a single index URL, using per-URL cache."""
|
||||
import httpx
|
||||
import yaml
|
||||
|
||||
now = time.monotonic()
|
||||
if not force and url in _url_cache:
|
||||
raw, fetched_at = _url_cache[url]
|
||||
if now - fetched_at < _CACHE_TTL:
|
||||
return [CatalogPlugin(p) for p in raw.get("plugins", [])]
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
resp = await client.get(url)
|
||||
resp.raise_for_status()
|
||||
raw = yaml.safe_load(resp.text) or {}
|
||||
_url_cache[url] = (raw, now)
|
||||
logger.info("Plugin catalog fetched from %s: %d plugin(s)", url, len(raw.get("plugins", [])))
|
||||
return [CatalogPlugin(p) for p in raw.get("plugins", [])]
|
||||
except Exception:
|
||||
logger.exception("Failed to fetch plugin catalog from %s", url)
|
||||
if url in _url_cache:
|
||||
raw, _ = _url_cache[url]
|
||||
logger.warning("Returning stale cached catalog for %s", url)
|
||||
return [CatalogPlugin(p) for p in raw.get("plugins", [])]
|
||||
return []
|
||||
|
||||
|
||||
async def fetch_catalog(
|
||||
index_urls: str | list[str],
|
||||
force: bool = False,
|
||||
) -> list[CatalogPlugin]:
|
||||
"""Fetch and merge plugin catalogs from one or more index URLs.
|
||||
|
||||
Results are cached per-URL for _CACHE_TTL seconds. Plugins with the same
|
||||
name across repos are deduplicated — the first occurrence (repo list order)
|
||||
wins. Pass force=True to bypass all caches.
|
||||
"""
|
||||
if isinstance(index_urls, str):
|
||||
index_urls = [index_urls]
|
||||
|
||||
seen: dict[str, CatalogPlugin] = {}
|
||||
for url in index_urls:
|
||||
url = url.strip()
|
||||
if not url:
|
||||
continue
|
||||
for plugin in await _fetch_one(url, force=force):
|
||||
if plugin.name not in seen:
|
||||
seen[plugin.name] = plugin
|
||||
|
||||
return list(seen.values())
|
||||
|
||||
|
||||
def clear_catalog_cache() -> None:
|
||||
"""Invalidate all in-process catalog caches."""
|
||||
_url_cache.clear()
|
||||
@@ -0,0 +1,436 @@
|
||||
# fabledscryer/core/plugin_manager.py
|
||||
from __future__ import annotations
|
||||
import hashlib
|
||||
import importlib
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import yaml
|
||||
from packaging.version import Version
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from quart import Quart
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Track which plugins were successfully loaded so hot-reload knows
|
||||
# whether to register a blueprint (new plugin) or skip it (already registered).
|
||||
_LOADED_PLUGINS: set[str] = set()
|
||||
|
||||
# Track plugins that failed to load: name → human-readable reason.
|
||||
_FAILED_PLUGINS: dict[str, str] = {}
|
||||
|
||||
|
||||
def get_plugin_failures() -> dict[str, str]:
|
||||
"""Return a copy of the failed-plugin registry (name → error message)."""
|
||||
return dict(_FAILED_PLUGINS)
|
||||
|
||||
|
||||
def _import_plugin(name: str, plugin_path: Path):
|
||||
"""Load a plugin module by file path, avoiding sys.modules stdlib collisions.
|
||||
|
||||
Using importlib.import_module(name) fails for plugins whose names shadow
|
||||
Python stdlib modules (e.g. the 'http' plugin vs stdlib's 'http' package).
|
||||
This helper loads from the filesystem path directly and registers the module
|
||||
under a namespaced key so relative imports within the plugin still work.
|
||||
"""
|
||||
import importlib.util
|
||||
|
||||
module_key = f"_fabledscryer_plugin_{name}"
|
||||
if module_key in sys.modules:
|
||||
return sys.modules[module_key]
|
||||
|
||||
init_file = plugin_path / "__init__.py"
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
module_key,
|
||||
str(init_file),
|
||||
submodule_search_locations=[str(plugin_path)],
|
||||
)
|
||||
if spec is None or spec.loader is None:
|
||||
raise ImportError(f"Could not create import spec for plugin {name!r}")
|
||||
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
module.__package__ = module_key
|
||||
sys.modules[module_key] = module
|
||||
try:
|
||||
spec.loader.exec_module(module)
|
||||
except Exception:
|
||||
sys.modules.pop(module_key, None)
|
||||
raise
|
||||
return module
|
||||
|
||||
|
||||
def load_plugins(app: "Quart") -> None:
|
||||
"""Import all enabled plugins and register their blueprints and scheduled tasks.
|
||||
|
||||
For each plugin listed as enabled in config.yaml under 'plugins':
|
||||
1. Locate the plugin directory under PLUGIN_DIR
|
||||
2. Load and validate plugin.yaml (name must match dir, min_app_version check)
|
||||
3. Merge plugin.yaml config defaults with user overrides in app.config["PLUGINS"]
|
||||
4. Import the plugin Python package
|
||||
5. Validate required exports: setup() and get_scheduled_tasks()
|
||||
6. Call setup(app)
|
||||
7. Register blueprint at /plugins/<name>/ if get_blueprint() exists
|
||||
8. Append get_scheduled_tasks() results to app._task_registry
|
||||
"""
|
||||
import fabledscryer
|
||||
|
||||
plugin_dir = Path(app.config["PLUGIN_DIR"])
|
||||
plugins_cfg: dict = app.config["PLUGINS"]
|
||||
|
||||
# Ensure plugin_dir is on sys.path so plugins are importable by name
|
||||
plugin_dir_str = str(plugin_dir.resolve())
|
||||
if plugin_dir_str not in sys.path:
|
||||
sys.path.insert(0, plugin_dir_str)
|
||||
|
||||
for name, cfg in list(plugins_cfg.items()):
|
||||
if not cfg.get("enabled", False):
|
||||
continue
|
||||
|
||||
plugin_path = plugin_dir / name
|
||||
if not plugin_path.exists():
|
||||
_FAILED_PLUGINS[name] = f"Plugin directory not found: {plugin_path}"
|
||||
logger.error("Plugin %r: directory %s not found, skipping", name, plugin_path)
|
||||
continue
|
||||
|
||||
# Load and validate plugin.yaml
|
||||
yaml_path = plugin_path / "plugin.yaml"
|
||||
if not yaml_path.exists():
|
||||
_FAILED_PLUGINS[name] = "plugin.yaml not found"
|
||||
logger.error("Plugin %r: plugin.yaml not found, skipping", name)
|
||||
continue
|
||||
|
||||
try:
|
||||
with yaml_path.open() as f:
|
||||
meta = yaml.safe_load(f) or {}
|
||||
except Exception as exc:
|
||||
_FAILED_PLUGINS[name] = f"Failed to read plugin.yaml: {exc}"
|
||||
logger.exception("Plugin %r: failed to read plugin.yaml, skipping", name)
|
||||
continue
|
||||
|
||||
if meta.get("name") != name:
|
||||
_FAILED_PLUGINS[name] = (
|
||||
f"plugin.yaml name {meta.get('name')!r} does not match directory name"
|
||||
)
|
||||
logger.error(
|
||||
"Plugin %r: plugin.yaml name=%r does not match directory name, skipping",
|
||||
name, meta.get("name"),
|
||||
)
|
||||
continue
|
||||
|
||||
min_ver = meta.get("min_app_version")
|
||||
if min_ver:
|
||||
try:
|
||||
if Version(fabledscryer.__version__) < Version(min_ver):
|
||||
_FAILED_PLUGINS[name] = (
|
||||
f"Requires fabledscryer>={min_ver}, running {fabledscryer.__version__}"
|
||||
)
|
||||
logger.error(
|
||||
"Plugin %r: requires fabledscryer>=%s but running %s, skipping",
|
||||
name, min_ver, fabledscryer.__version__,
|
||||
)
|
||||
continue
|
||||
except Exception as exc:
|
||||
_FAILED_PLUGINS[name] = f"Invalid min_app_version {min_ver!r}: {exc}"
|
||||
logger.exception("Plugin %r: invalid min_app_version %r, skipping", name, min_ver)
|
||||
continue
|
||||
|
||||
# Merge plugin.yaml config defaults with user overrides (non-"enabled" keys).
|
||||
# If a stored value's type doesn't match the default (e.g. a list default
|
||||
# was corrupted to a string in the DB), fall back to the yaml default.
|
||||
defaults = meta.get("config", {})
|
||||
user_overrides = {k: v for k, v in cfg.items() if k != "enabled"}
|
||||
merged = {"enabled": True, **defaults}
|
||||
for k, v in user_overrides.items():
|
||||
default_v = defaults.get(k)
|
||||
if default_v is not None and type(v) is not type(default_v):
|
||||
logger.warning(
|
||||
"Plugin %r: config key %r has wrong type (%s), using yaml default",
|
||||
name, k, type(v).__name__,
|
||||
)
|
||||
else:
|
||||
merged[k] = v
|
||||
plugins_cfg[name] = merged
|
||||
|
||||
# Import the plugin package (file-path load avoids stdlib name collisions)
|
||||
try:
|
||||
module = _import_plugin(name, plugin_path)
|
||||
except Exception as exc:
|
||||
_FAILED_PLUGINS[name] = f"Import error: {exc}"
|
||||
logger.exception("Plugin %r: import failed, skipping", name)
|
||||
continue
|
||||
|
||||
# Validate required exports
|
||||
if not hasattr(module, "setup"):
|
||||
_FAILED_PLUGINS[name] = "Missing required export: setup()"
|
||||
logger.error("Plugin %r: missing required export 'setup', skipping", name)
|
||||
continue
|
||||
if not hasattr(module, "get_scheduled_tasks"):
|
||||
_FAILED_PLUGINS[name] = "Missing required export: get_scheduled_tasks()"
|
||||
logger.error(
|
||||
"Plugin %r: missing required export 'get_scheduled_tasks', skipping", name
|
||||
)
|
||||
continue
|
||||
|
||||
# Call setup
|
||||
try:
|
||||
module.setup(app)
|
||||
except Exception as exc:
|
||||
_FAILED_PLUGINS[name] = f"setup() failed: {exc}"
|
||||
logger.exception("Plugin %r: setup() raised, skipping", name)
|
||||
continue
|
||||
|
||||
# Register blueprint (optional)
|
||||
if hasattr(module, "get_blueprint"):
|
||||
try:
|
||||
bp = module.get_blueprint()
|
||||
app.register_blueprint(bp, url_prefix=f"/plugins/{name}")
|
||||
logger.info("Plugin %r: blueprint registered at /plugins/%s/", name, name)
|
||||
except Exception as exc:
|
||||
_FAILED_PLUGINS[name] = f"Blueprint registration failed: {exc}"
|
||||
logger.exception("Plugin %r: get_blueprint() raised", name)
|
||||
continue
|
||||
|
||||
# Register scheduled tasks
|
||||
try:
|
||||
tasks = module.get_scheduled_tasks()
|
||||
app._task_registry.extend(tasks)
|
||||
logger.info("Plugin %r: registered %d scheduled task(s)", name, len(tasks))
|
||||
except Exception as exc:
|
||||
_FAILED_PLUGINS[name] = f"get_scheduled_tasks() failed: {exc}"
|
||||
logger.exception("Plugin %r: get_scheduled_tasks() raised", name)
|
||||
continue
|
||||
|
||||
_FAILED_PLUGINS.pop(name, None) # clear any stale failure from a previous reload attempt
|
||||
_LOADED_PLUGINS.add(name)
|
||||
logger.info("Plugin %r loaded (v%s)", name, meta.get("version", "?"))
|
||||
|
||||
|
||||
async def download_and_install_plugin(
|
||||
app: "Quart",
|
||||
name: str,
|
||||
download_url: str,
|
||||
expected_sha256: str,
|
||||
) -> tuple[bool, str]:
|
||||
"""Download a plugin zip, verify its checksum, and extract it to PLUGIN_DIR.
|
||||
|
||||
Returns (success, message). Does NOT hot-reload — call hot_reload_plugin()
|
||||
after this to activate the plugin without a restart (new plugins only).
|
||||
"""
|
||||
import httpx
|
||||
|
||||
plugin_dir = Path(app.config["PLUGIN_DIR"]).resolve()
|
||||
|
||||
# Ensure plugin_dir is on sys.path (may not be set yet if no plugins were
|
||||
# enabled at startup)
|
||||
plugin_dir_str = str(plugin_dir)
|
||||
if plugin_dir_str not in sys.path:
|
||||
sys.path.insert(0, plugin_dir_str)
|
||||
|
||||
try:
|
||||
logger.info("Downloading plugin %r from %s", name, download_url)
|
||||
async with httpx.AsyncClient(timeout=60, follow_redirects=True) as client:
|
||||
resp = await client.get(download_url)
|
||||
resp.raise_for_status()
|
||||
content = resp.content
|
||||
except Exception as exc:
|
||||
msg = f"Download failed: {exc}"
|
||||
logger.error("Plugin %r: %s", name, msg)
|
||||
return False, msg
|
||||
|
||||
# Verify checksum if provided
|
||||
if expected_sha256:
|
||||
actual = hashlib.sha256(content).hexdigest()
|
||||
if actual != expected_sha256.lower():
|
||||
msg = f"Checksum mismatch: expected {expected_sha256}, got {actual}"
|
||||
logger.error("Plugin %r: %s", name, msg)
|
||||
return False, msg
|
||||
|
||||
# Extract the zip
|
||||
try:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
zip_path = Path(tmpdir) / f"{name}.zip"
|
||||
zip_path.write_bytes(content)
|
||||
with zipfile.ZipFile(zip_path) as zf:
|
||||
members = zf.namelist()
|
||||
# Collect unique top-level names (dirs and loose files)
|
||||
top_level = {m.split("/")[0] for m in members}
|
||||
|
||||
dest = plugin_dir / name
|
||||
dest.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Detect a single top-level directory to strip. Handles:
|
||||
# name/ (clean plugin zip)
|
||||
# name-v1.0.0/ (GitHub release tag archive)
|
||||
# fabledscryer-plugins-main/name/ (whole-repo archive)
|
||||
# Strategy: if there is exactly one top-level item and it is a
|
||||
# directory whose name starts with the plugin name (case-insensitive),
|
||||
# strip it. Otherwise extract flat into dest.
|
||||
prefix: str | None = None
|
||||
if len(top_level) == 1:
|
||||
candidate = list(top_level)[0]
|
||||
if candidate.lower().startswith(name.lower()):
|
||||
prefix = candidate + "/"
|
||||
|
||||
for member in members:
|
||||
if prefix:
|
||||
if not member.startswith(prefix):
|
||||
continue
|
||||
rel = member[len(prefix):]
|
||||
else:
|
||||
rel = member
|
||||
if not rel or rel.endswith("/"):
|
||||
if rel:
|
||||
(dest / rel.rstrip("/")).mkdir(parents=True, exist_ok=True)
|
||||
continue
|
||||
target = dest / rel
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_bytes(zf.read(member))
|
||||
except Exception as exc:
|
||||
msg = f"Extraction failed: {exc}"
|
||||
logger.error("Plugin %r: %s", name, msg)
|
||||
return False, msg
|
||||
|
||||
# Run migrations for the newly installed plugin.
|
||||
# Include all plugin migration dirs so Alembic can resolve any previously-stamped
|
||||
# revisions from other plugins already in alembic_version.
|
||||
try:
|
||||
mdir = plugin_dir / name / "migrations"
|
||||
if mdir.exists():
|
||||
from fabledscryer.core.migration_runner import (
|
||||
run_plugin_migrations,
|
||||
discover_all_plugin_migration_dirs,
|
||||
)
|
||||
all_dirs = discover_all_plugin_migration_dirs(plugin_dir)
|
||||
run_plugin_migrations(app.config["DATABASE_URL"], all_dirs)
|
||||
except Exception:
|
||||
logger.exception("Plugin %r: migration failed after install", name)
|
||||
return False, "Plugin extracted but migrations failed — check logs"
|
||||
|
||||
logger.info("Plugin %r installed successfully", name)
|
||||
return True, "Installed"
|
||||
|
||||
|
||||
def hot_reload_plugin(app: "Quart", name: str) -> tuple[bool, str]:
|
||||
"""Activate a newly installed plugin without restarting the app.
|
||||
|
||||
This works for plugins that were NOT previously loaded. For plugins that
|
||||
are already registered (blueprint already mounted), a full restart is
|
||||
required to pick up code changes — use restart_app() in that case.
|
||||
|
||||
Returns (success, message).
|
||||
"""
|
||||
import fabledscryer
|
||||
|
||||
if name in _LOADED_PLUGINS:
|
||||
return False, "Plugin already loaded — restart required to apply updates"
|
||||
|
||||
plugin_dir = Path(app.config["PLUGIN_DIR"]).resolve()
|
||||
plugin_path = plugin_dir / name
|
||||
|
||||
if not plugin_path.exists():
|
||||
return False, f"Plugin directory {plugin_path} not found"
|
||||
|
||||
yaml_path = plugin_path / "plugin.yaml"
|
||||
if not yaml_path.exists():
|
||||
return False, "plugin.yaml not found"
|
||||
|
||||
try:
|
||||
with yaml_path.open() as f:
|
||||
meta = yaml.safe_load(f) or {}
|
||||
except Exception as exc:
|
||||
return False, f"Could not read plugin.yaml: {exc}"
|
||||
|
||||
if meta.get("name") != name:
|
||||
return False, f"plugin.yaml name mismatch: expected {name!r}, got {meta.get('name')!r}"
|
||||
|
||||
min_ver = meta.get("min_app_version")
|
||||
if min_ver:
|
||||
try:
|
||||
if Version(fabledscryer.__version__) < Version(min_ver):
|
||||
return False, (
|
||||
f"Plugin requires fabledscryer>={min_ver} "
|
||||
f"but running {fabledscryer.__version__}"
|
||||
)
|
||||
except Exception:
|
||||
return False, f"Invalid min_app_version: {min_ver!r}"
|
||||
|
||||
# Merge config defaults with any stored user config.
|
||||
# Discard stored values whose type doesn't match the yaml default (corruption guard).
|
||||
defaults = meta.get("config", {})
|
||||
stored_cfg = app.config.get("PLUGINS", {}).get(name, {})
|
||||
merged = {"enabled": True, **defaults}
|
||||
for k, v in stored_cfg.items():
|
||||
if k == "enabled":
|
||||
continue
|
||||
default_v = defaults.get(k)
|
||||
if default_v is not None and type(v) is not type(default_v):
|
||||
logger.warning("Plugin %r: config key %r has wrong type, using yaml default", name, k)
|
||||
else:
|
||||
merged[k] = v
|
||||
app.config["PLUGINS"][name] = merged
|
||||
|
||||
# Run migrations if the plugin has them (safe to re-run — alembic is idempotent).
|
||||
# Pass ALL plugin migration dirs so Alembic can resolve any previously-stamped
|
||||
# revisions from other plugins that are already in alembic_version.
|
||||
mdir = plugin_path / "migrations"
|
||||
if mdir.exists():
|
||||
try:
|
||||
from fabledscryer.core.migration_runner import (
|
||||
run_plugin_migrations,
|
||||
discover_all_plugin_migration_dirs,
|
||||
)
|
||||
all_dirs = discover_all_plugin_migration_dirs(plugin_dir)
|
||||
run_plugin_migrations(app.config["DATABASE_URL"], all_dirs)
|
||||
except Exception:
|
||||
logger.exception("Plugin %r: migration failed during hot-reload", name)
|
||||
return False, "Migration failed — check logs"
|
||||
|
||||
# Import (file-path load avoids stdlib name collisions)
|
||||
try:
|
||||
module = _import_plugin(name, plugin_path)
|
||||
except Exception as exc:
|
||||
return False, f"Import failed: {exc}"
|
||||
|
||||
if not hasattr(module, "setup") or not hasattr(module, "get_scheduled_tasks"):
|
||||
return False, "Plugin missing required exports (setup, get_scheduled_tasks)"
|
||||
|
||||
try:
|
||||
module.setup(app)
|
||||
except Exception as exc:
|
||||
return False, f"setup() raised: {exc}"
|
||||
|
||||
if hasattr(module, "get_blueprint"):
|
||||
try:
|
||||
bp = module.get_blueprint()
|
||||
app.register_blueprint(bp, url_prefix=f"/plugins/{name}")
|
||||
except Exception as exc:
|
||||
return False, f"Blueprint registration failed: {exc}"
|
||||
|
||||
try:
|
||||
tasks = module.get_scheduled_tasks()
|
||||
# De-duplicate: skip tasks whose name already exists in the registry
|
||||
existing_names = {t.name for t in app._task_registry}
|
||||
new_tasks = [t for t in tasks if t.name not in existing_names]
|
||||
app._task_registry.extend(new_tasks)
|
||||
except Exception as exc:
|
||||
return False, f"get_scheduled_tasks() raised: {exc}"
|
||||
|
||||
_LOADED_PLUGINS.add(name)
|
||||
logger.info("Plugin %r hot-reloaded (v%s)", name, meta.get("version", "?"))
|
||||
return True, f"Plugin activated (v{meta.get('version', '?')})"
|
||||
|
||||
|
||||
def restart_app() -> None:
|
||||
"""Replace the current process with a fresh copy (in-app restart).
|
||||
|
||||
Used when a plugin update requires a full restart to take effect.
|
||||
In Docker the container will be relaunched by the restart policy.
|
||||
"""
|
||||
logger.info("Initiating in-app restart via os.execv")
|
||||
os.execv(sys.executable, [sys.executable] + sys.argv)
|
||||
@@ -0,0 +1,275 @@
|
||||
"""fabledscryer/core/reports.py
|
||||
|
||||
Weekly digest report: uptime, active alerts, alert events, top metrics.
|
||||
Called by the scheduler (hourly check) or triggered manually from Settings.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import asyncio
|
||||
import logging
|
||||
import smtplib
|
||||
import ssl
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from email.message import EmailMessage
|
||||
|
||||
from sqlalchemy import and_, case, func, select
|
||||
|
||||
from fabledscryer.models.alerts import AlertRule, AlertState, AlertStateEnum, AlertEvent
|
||||
from fabledscryer.models.hosts import Host
|
||||
from fabledscryer.models.metrics import PluginMetric
|
||||
from fabledscryer.models.monitors import PingResult, PingStatus
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
|
||||
|
||||
|
||||
async def build_report_data(app) -> dict:
|
||||
"""Collect all data for the weekly digest."""
|
||||
now = datetime.now(timezone.utc)
|
||||
cutoff_7d = now - timedelta(days=7)
|
||||
cutoff_24h = now - timedelta(hours=24)
|
||||
|
||||
async with app.db_sessionmaker() as db:
|
||||
# ── Uptime summary (7d) ───────────────────────────────────────────────
|
||||
host_result = await db.execute(
|
||||
select(Host).where(Host.ping_enabled.is_(True)).order_by(Host.name)
|
||||
)
|
||||
hosts = host_result.scalars().all()
|
||||
|
||||
uptime_rows = await db.execute(
|
||||
select(
|
||||
PingResult.host_id,
|
||||
func.count(PingResult.id).label("total"),
|
||||
func.sum(case((PingResult.status == PingStatus.up, 1), else_=0)).label("up"),
|
||||
)
|
||||
.where(PingResult.probed_at >= cutoff_7d)
|
||||
.group_by(PingResult.host_id)
|
||||
)
|
||||
uptime_map: dict[str, float | None] = {}
|
||||
for row in uptime_rows:
|
||||
pct = round(float(row.up) / float(row.total) * 100, 2) if row.total else None
|
||||
uptime_map[row.host_id] = pct
|
||||
|
||||
uptime_summary = []
|
||||
for h in hosts:
|
||||
uptime_summary.append({
|
||||
"name": h.name,
|
||||
"pct_7d": uptime_map.get(h.id),
|
||||
})
|
||||
|
||||
# ── Active alerts ─────────────────────────────────────────────────────
|
||||
active_result = await db.execute(
|
||||
select(AlertState, AlertRule)
|
||||
.join(AlertRule, AlertState.rule_id == AlertRule.id)
|
||||
.where(AlertState.state.in_([
|
||||
AlertStateEnum.firing, AlertStateEnum.acknowledged, AlertStateEnum.pending,
|
||||
]))
|
||||
.order_by(AlertState.last_transition_at.desc())
|
||||
)
|
||||
active_alerts = [
|
||||
{
|
||||
"state": state.state.value.upper(),
|
||||
"name": rule.name,
|
||||
"resource": rule.resource_name,
|
||||
"metric": rule.metric_name,
|
||||
"operator": rule.operator.value,
|
||||
"threshold": rule.threshold,
|
||||
}
|
||||
for state, rule in active_result.all()
|
||||
]
|
||||
|
||||
# ── Alert event counts (7d) ───────────────────────────────────────────
|
||||
events_result = await db.execute(
|
||||
select(
|
||||
func.count(case((AlertEvent.to_state == "firing", 1), else_=None)).label("fired"),
|
||||
func.count(case((AlertEvent.to_state == "resolved", 1), else_=None)).label("resolved"),
|
||||
)
|
||||
.where(AlertEvent.transitioned_at >= cutoff_7d)
|
||||
)
|
||||
event_row = events_result.one()
|
||||
alert_events = {
|
||||
"fired": int(event_row.fired or 0),
|
||||
"resolved": int(event_row.resolved or 0),
|
||||
}
|
||||
|
||||
# ── Top Traefik routers by avg request_rate (last 24h) ────────────────
|
||||
top_routers_result = await db.execute(
|
||||
select(
|
||||
PluginMetric.resource_name,
|
||||
func.avg(PluginMetric.value).label("avg_rate"),
|
||||
)
|
||||
.where(
|
||||
and_(
|
||||
PluginMetric.source_module == "traefik",
|
||||
PluginMetric.metric_name == "request_rate",
|
||||
PluginMetric.recorded_at >= cutoff_24h,
|
||||
)
|
||||
)
|
||||
.group_by(PluginMetric.resource_name)
|
||||
.order_by(func.avg(PluginMetric.value).desc())
|
||||
.limit(10)
|
||||
)
|
||||
top_routers = [
|
||||
{"name": row.resource_name, "avg_rate": float(row.avg_rate)}
|
||||
for row in top_routers_result
|
||||
]
|
||||
|
||||
# ── Hosts currently down ──────────────────────────────────────────────
|
||||
latest_ping_subq = (
|
||||
select(PingResult.host_id, func.max(PingResult.probed_at).label("max_at"))
|
||||
.group_by(PingResult.host_id)
|
||||
.subquery()
|
||||
)
|
||||
down_result = await db.execute(
|
||||
select(Host.name)
|
||||
.join(PingResult, PingResult.host_id == Host.id)
|
||||
.join(
|
||||
latest_ping_subq,
|
||||
and_(
|
||||
PingResult.host_id == latest_ping_subq.c.host_id,
|
||||
PingResult.probed_at == latest_ping_subq.c.max_at,
|
||||
),
|
||||
)
|
||||
.where(
|
||||
Host.ping_enabled.is_(True),
|
||||
PingResult.status == PingStatus.down,
|
||||
)
|
||||
.order_by(Host.name)
|
||||
)
|
||||
hosts_down = [row.name for row in down_result]
|
||||
|
||||
return {
|
||||
"generated_at": now,
|
||||
"uptime_summary": uptime_summary,
|
||||
"active_alerts": active_alerts,
|
||||
"alert_events": alert_events,
|
||||
"top_routers": top_routers,
|
||||
"hosts_down": hosts_down,
|
||||
}
|
||||
|
||||
|
||||
def _render_report_text(data: dict) -> str:
|
||||
now = data["generated_at"]
|
||||
lines: list[str] = []
|
||||
|
||||
lines.append("Fabled Scryer — Weekly Report")
|
||||
lines.append(f"Generated: {now.strftime('%Y-%m-%d %H:%M UTC')}")
|
||||
lines.append("")
|
||||
|
||||
# ── Hosts currently down ──────────────────────────────────────────────────
|
||||
down = data["hosts_down"]
|
||||
if down:
|
||||
lines.append(f"⚠ {len(down)} host(s) currently DOWN: {', '.join(down)}")
|
||||
lines.append("")
|
||||
|
||||
# ── Uptime summary ────────────────────────────────────────────────────────
|
||||
lines.append("UPTIME SUMMARY (7-day)")
|
||||
lines.append("─" * 40)
|
||||
for entry in data["uptime_summary"]:
|
||||
pct = entry["pct_7d"]
|
||||
pct_str = f"{pct:.2f}%" if pct is not None else "no data"
|
||||
lines.append(f" {entry['name']:<32} {pct_str}")
|
||||
if not data["uptime_summary"]:
|
||||
lines.append(" (no ping-enabled hosts)")
|
||||
lines.append("")
|
||||
|
||||
# ── Active alerts ─────────────────────────────────────────────────────────
|
||||
alerts = data["active_alerts"]
|
||||
lines.append(f"ACTIVE ALERTS ({len(alerts)})")
|
||||
lines.append("─" * 40)
|
||||
if alerts:
|
||||
for a in alerts:
|
||||
lines.append(
|
||||
f" [{a['state']}] {a['name']} — {a['resource']}:"
|
||||
f"{a['metric']} {a['operator']} {a['threshold']}"
|
||||
)
|
||||
else:
|
||||
lines.append(" None — all clear.")
|
||||
lines.append("")
|
||||
|
||||
# ── Alert events this week ────────────────────────────────────────────────
|
||||
ev = data["alert_events"]
|
||||
lines.append("ALERT EVENTS THIS WEEK")
|
||||
lines.append("─" * 40)
|
||||
lines.append(f" Fired: {ev['fired']}")
|
||||
lines.append(f" Resolved: {ev['resolved']}")
|
||||
lines.append("")
|
||||
|
||||
# ── Top Traefik routers ───────────────────────────────────────────────────
|
||||
if data["top_routers"]:
|
||||
lines.append("TOP TRAEFIK ROUTERS (avg req/s, last 24h)")
|
||||
lines.append("─" * 40)
|
||||
for r in data["top_routers"]:
|
||||
lines.append(f" {r['name']:<40} {r['avg_rate']:.2f} req/s")
|
||||
lines.append("")
|
||||
|
||||
lines.append("─" * 40)
|
||||
lines.append("Fabled Scryer — https://git.fabledsword.com/bvandeusen/FabledScryer")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
async def send_report(app) -> tuple[bool, str]:
|
||||
"""Build and email the weekly digest. Returns (success, message)."""
|
||||
smtp_cfg = app.config.get("SMTP", {})
|
||||
if not smtp_cfg.get("host") or not smtp_cfg.get("recipients"):
|
||||
return False, "SMTP not configured — set host and recipients in Settings → Notifications."
|
||||
|
||||
try:
|
||||
data = await build_report_data(app)
|
||||
except Exception as exc:
|
||||
logger.exception("Failed to build report data")
|
||||
return False, f"Data collection failed: {exc}"
|
||||
|
||||
body = _render_report_text(data)
|
||||
date_str = data["generated_at"].strftime("%Y-%m-%d")
|
||||
subject = f"[Fabled Scryer] Weekly Report — {date_str}"
|
||||
|
||||
msg = EmailMessage()
|
||||
msg["Subject"] = subject
|
||||
msg["From"] = smtp_cfg.get("username", "fabledscryer@localhost")
|
||||
msg["To"] = ", ".join(smtp_cfg["recipients"])
|
||||
msg.set_content(body)
|
||||
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
from fabledscryer.core.notifications import _smtp_send_sync
|
||||
await loop.run_in_executor(None, _smtp_send_sync, smtp_cfg, msg)
|
||||
logger.info("Weekly report sent to %s", smtp_cfg["recipients"])
|
||||
return True, f"Report sent to {', '.join(smtp_cfg['recipients'])}."
|
||||
except Exception as exc:
|
||||
logger.error("Failed to send weekly report: %s", exc)
|
||||
return False, f"Send failed: {exc}"
|
||||
|
||||
|
||||
async def check_and_send(app) -> None:
|
||||
"""Hourly scheduler hook: send if day/hour match and not sent recently."""
|
||||
from fabledscryer.core.settings import get_setting, set_setting
|
||||
|
||||
async with app.db_sessionmaker() as db:
|
||||
enabled = await get_setting(db, "reports.enabled")
|
||||
if not enabled:
|
||||
return
|
||||
|
||||
schedule_day = int(await get_setting(db, "reports.schedule_day") or 6)
|
||||
schedule_hour = int(await get_setting(db, "reports.schedule_hour") or 8)
|
||||
last_sent_str = await get_setting(db, "reports.last_sent_at") or ""
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
if now.weekday() != schedule_day or now.hour != schedule_hour:
|
||||
return
|
||||
|
||||
if last_sent_str:
|
||||
try:
|
||||
last_sent = datetime.fromisoformat(last_sent_str)
|
||||
if (now - last_sent).total_seconds() < 23 * 3600:
|
||||
return # already sent this window
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
ok, msg = await send_report(app)
|
||||
if ok:
|
||||
async with app.db_sessionmaker() as db:
|
||||
async with db.begin():
|
||||
await set_setting(db, "reports.last_sent_at", now.isoformat())
|
||||
else:
|
||||
logger.warning("Weekly report send failed: %s", msg)
|
||||
@@ -0,0 +1,42 @@
|
||||
from __future__ import annotations
|
||||
import asyncio
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable, Coroutine
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScheduledTask:
|
||||
name: str
|
||||
coro_factory: Callable[[], Coroutine]
|
||||
interval_seconds: int
|
||||
run_on_startup: bool = False
|
||||
|
||||
|
||||
async def start_scheduler(tasks: list[ScheduledTask]) -> None:
|
||||
"""Run scheduled tasks in a loop. Call with asyncio.create_task()."""
|
||||
last_run: dict[str, float] = {}
|
||||
|
||||
for task in tasks:
|
||||
if task.run_on_startup:
|
||||
logger.info(f"Startup task: {task.name}")
|
||||
asyncio.create_task(_run_task(task))
|
||||
last_run[task.name] = asyncio.get_event_loop().time()
|
||||
|
||||
while True:
|
||||
now = asyncio.get_event_loop().time()
|
||||
for task in tasks:
|
||||
last = last_run.get(task.name, 0)
|
||||
if now - last >= task.interval_seconds:
|
||||
asyncio.create_task(_run_task(task))
|
||||
last_run[task.name] = now
|
||||
await asyncio.sleep(1)
|
||||
|
||||
|
||||
async def _run_task(task: ScheduledTask) -> None:
|
||||
try:
|
||||
await task.coro_factory()
|
||||
except Exception:
|
||||
logger.exception(f"Scheduled task {task.name!r} raised an exception")
|
||||
@@ -0,0 +1,201 @@
|
||||
# fabledscryer/core/settings.py
|
||||
"""DB-backed application settings.
|
||||
|
||||
Keys use dotted notation (e.g. "smtp.host").
|
||||
Values are JSON-encoded in the DB.
|
||||
|
||||
Usage in create_app() (before event loop):
|
||||
from fabledscryer.core.settings import load_settings_sync
|
||||
settings = load_settings_sync(db_url)
|
||||
|
||||
Usage at runtime (inside async handlers):
|
||||
from fabledscryer.core.settings import get_setting, set_setting
|
||||
async with app.db_sessionmaker() as session:
|
||||
value = await get_setting(session, "smtp.host")
|
||||
await set_setting(session, "smtp.host", "mail.example.com")
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from fabledscryer.models.settings import AppSetting
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DEFAULT_WEBHOOK_TEMPLATE = (
|
||||
'{"content": "**{{ alert.state }}** — {{ alert.resource }} — '
|
||||
'{{ alert.rule_name }} ({{ alert.metric }} = {{ alert.value }})"}'
|
||||
)
|
||||
|
||||
# All recognised settings and their defaults.
|
||||
# Plugin settings are stored as "plugin.<name>" and handled separately.
|
||||
DEFAULTS: dict[str, Any] = {
|
||||
"session.lifetime_hours": 8,
|
||||
"data.retention_days": 90,
|
||||
"monitors.poll_interval_seconds": 60,
|
||||
"smtp.host": "",
|
||||
"smtp.port": 587,
|
||||
"smtp.tls": True,
|
||||
"smtp.username": "",
|
||||
"smtp.password": "",
|
||||
"smtp.recipients": [],
|
||||
"webhook.url": "",
|
||||
"webhook.template": _DEFAULT_WEBHOOK_TEMPLATE,
|
||||
"ansible.sources": [],
|
||||
"ping.threshold.good_ms": 50,
|
||||
"ping.threshold.warn_ms": 200,
|
||||
"plugins.index_url": "https://git.fabledsword.com/bvandeusen/FabledScryer-plugins/raw/branch/main/index.yaml",
|
||||
# OIDC single-sign-on
|
||||
"oidc.enabled": False,
|
||||
"oidc.discovery_url": "",
|
||||
"oidc.client_id": "",
|
||||
"oidc.client_secret": "",
|
||||
"oidc.scopes": "openid profile email",
|
||||
"oidc.username_claim": "preferred_username",
|
||||
"oidc.email_claim": "email",
|
||||
"oidc.groups_claim": "groups",
|
||||
"oidc.admin_group": "",
|
||||
"oidc.operator_group": "",
|
||||
# LDAP authentication
|
||||
"ldap.enabled": False,
|
||||
"ldap.host": "",
|
||||
"ldap.port": 389,
|
||||
"ldap.tls": False,
|
||||
"ldap.bind_dn": "",
|
||||
"ldap.bind_password": "",
|
||||
"ldap.base_dn": "",
|
||||
"ldap.user_filter": "(uid={username})",
|
||||
"ldap.admin_group_dn": "",
|
||||
"ldap.operator_group_dn": "",
|
||||
"ldap.attr_username": "uid",
|
||||
"ldap.attr_email": "mail",
|
||||
# Scheduled reports
|
||||
"reports.enabled": False,
|
||||
"reports.schedule_day": 6, # 0=Monday … 6=Sunday
|
||||
"reports.schedule_hour": 8, # UTC hour
|
||||
"reports.last_sent_at": "",
|
||||
}
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Async helpers (use inside request handlers / scheduled tasks)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
async def get_setting(session: AsyncSession, key: str) -> Any:
|
||||
"""Return the value for key, or the default if not set."""
|
||||
result = await session.execute(
|
||||
select(AppSetting).where(AppSetting.key == key)
|
||||
)
|
||||
row = result.scalar_one_or_none()
|
||||
if row is None:
|
||||
return DEFAULTS.get(key)
|
||||
return json.loads(row.value_json)
|
||||
|
||||
|
||||
async def set_setting(session: AsyncSession, key: str, value: Any) -> None:
|
||||
"""Upsert a setting. Call inside an active transaction."""
|
||||
result = await session.execute(
|
||||
select(AppSetting).where(AppSetting.key == key)
|
||||
)
|
||||
row = result.scalar_one_or_none()
|
||||
now = datetime.now(timezone.utc)
|
||||
if row is None:
|
||||
session.add(AppSetting(key=key, value_json=json.dumps(value), updated_at=now))
|
||||
else:
|
||||
row.value_json = json.dumps(value)
|
||||
row.updated_at = now
|
||||
|
||||
|
||||
async def get_all_settings(session: AsyncSession) -> dict[str, Any]:
|
||||
"""Return flat key→value dict with defaults filled in for missing keys."""
|
||||
result = await session.execute(select(AppSetting))
|
||||
stored = {row.key: json.loads(row.value_json) for row in result.scalars()}
|
||||
out: dict[str, Any] = {}
|
||||
for key, default in DEFAULTS.items():
|
||||
out[key] = stored.get(key, default)
|
||||
# Include any plugin.* keys stored in DB
|
||||
for key, value in stored.items():
|
||||
if key.startswith("plugin.") and key not in out:
|
||||
out[key] = value
|
||||
return out
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Structured config extractors (dict shapes expected by existing consumers)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def to_smtp_cfg(settings: dict[str, Any]) -> dict:
|
||||
return {
|
||||
"host": settings.get("smtp.host", ""),
|
||||
"port": settings.get("smtp.port", 587),
|
||||
"tls": settings.get("smtp.tls", True),
|
||||
"username": settings.get("smtp.username", ""),
|
||||
"password": settings.get("smtp.password", ""),
|
||||
"recipients": settings.get("smtp.recipients", []),
|
||||
}
|
||||
|
||||
|
||||
def to_webhook_cfg(settings: dict[str, Any]) -> dict:
|
||||
return {
|
||||
"url": settings.get("webhook.url", ""),
|
||||
"template": settings.get("webhook.template", _DEFAULT_WEBHOOK_TEMPLATE),
|
||||
}
|
||||
|
||||
|
||||
def to_ansible_cfg(settings: dict[str, Any]) -> dict:
|
||||
return {"sources": settings.get("ansible.sources", [])}
|
||||
|
||||
|
||||
def to_oidc_cfg(settings: dict[str, Any]) -> dict:
|
||||
return {k[len("oidc."):]: settings.get(k, DEFAULTS[k]) for k in DEFAULTS if k.startswith("oidc.")}
|
||||
|
||||
|
||||
def to_ldap_cfg(settings: dict[str, Any]) -> dict:
|
||||
return {k[len("ldap."):]: settings.get(k, DEFAULTS[k]) for k in DEFAULTS if k.startswith("ldap.")}
|
||||
|
||||
|
||||
def to_plugins_cfg(settings: dict[str, Any]) -> dict:
|
||||
"""Assemble {plugin_name: {...config}} from all plugin.* keys."""
|
||||
result = {}
|
||||
for key, value in settings.items():
|
||||
if key.startswith("plugin."):
|
||||
name = key[len("plugin."):]
|
||||
result[name] = value
|
||||
return result
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Synchronous loader — safe to call before the event loop starts
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def load_settings_sync(db_url: str) -> dict[str, Any]:
|
||||
"""Load all settings from DB synchronously via asyncio.run().
|
||||
|
||||
Safe to call in create_app() before the Quart event loop starts.
|
||||
Returns flat key→value dict with defaults filled in.
|
||||
"""
|
||||
async def _load() -> dict[str, Any]:
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
|
||||
engine = create_async_engine(db_url, echo=False)
|
||||
factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||
try:
|
||||
async with factory() as session:
|
||||
result = await session.execute(select(AppSetting))
|
||||
return {row.key: json.loads(row.value_json) for row in result.scalars()}
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
stored = asyncio.run(_load())
|
||||
out: dict[str, Any] = {}
|
||||
for key, default in DEFAULTS.items():
|
||||
out[key] = stored.get(key, default)
|
||||
for key, value in stored.items():
|
||||
if key.startswith("plugin.") and key not in out:
|
||||
out[key] = value
|
||||
return out
|
||||
@@ -0,0 +1,44 @@
|
||||
# fabledscryer/core/time_range.py
|
||||
"""Shared time-range utilities for scoping queries and sparkline data."""
|
||||
from __future__ import annotations
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import TypeVar
|
||||
|
||||
RANGES: dict[str, timedelta] = {
|
||||
"1h": timedelta(hours=1),
|
||||
"6h": timedelta(hours=6),
|
||||
"24h": timedelta(hours=24),
|
||||
"7d": timedelta(days=7),
|
||||
"30d": timedelta(days=30),
|
||||
}
|
||||
|
||||
RANGE_OPTIONS: list[str] = list(RANGES.keys())
|
||||
DEFAULT_RANGE = "24h"
|
||||
|
||||
|
||||
def parse_range(range_str: str | None) -> tuple[datetime, str]:
|
||||
"""Return (since_datetime, range_key) from a query-param string like '24h'."""
|
||||
key = range_str if range_str in RANGES else DEFAULT_RANGE
|
||||
since = datetime.now(timezone.utc) - RANGES[key]
|
||||
return since, key
|
||||
|
||||
|
||||
def bucket_seconds(since: datetime, target_points: int = 80) -> int:
|
||||
"""Return bucket width in seconds so that (now - since) / width ≈ target_points.
|
||||
|
||||
Minimum is 60s (one poll interval) so raw data is never re-averaged below
|
||||
the collection resolution.
|
||||
"""
|
||||
range_secs = (datetime.now(timezone.utc) - since).total_seconds()
|
||||
return max(60, int(range_secs / target_points))
|
||||
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
def subsample(seq: list[T], n: int = 80) -> list[T]:
|
||||
"""Return at most n evenly-distributed elements from seq (preserves order)."""
|
||||
if len(seq) <= n:
|
||||
return seq
|
||||
indices = sorted({int(round(i * (len(seq) - 1) / (n - 1))) for i in range(n)})
|
||||
return [seq[i] for i in indices]
|
||||
@@ -0,0 +1,298 @@
|
||||
# fabledscryer/core/widgets.py
|
||||
#
|
||||
# Central widget registry. Each entry describes one widget type that can be
|
||||
# placed on a dashboard. Plugin widgets declare which plugin must be enabled.
|
||||
#
|
||||
# "params" is a list of configurable parameters for the widget. Each param:
|
||||
# key - form field name (stored in config_json)
|
||||
# label - human-readable label
|
||||
# type - "select" (only type currently supported)
|
||||
# default - default value (int or str determines how it's parsed from the form)
|
||||
# options - list of {value, label} dicts
|
||||
from __future__ import annotations
|
||||
|
||||
WIDGET_REGISTRY: dict[str, dict] = {
|
||||
"ping": {
|
||||
"key": "ping",
|
||||
"label": "Ping",
|
||||
"description": "Live ping status and latency history for all monitored hosts",
|
||||
"hx_url": "/ping/rows",
|
||||
"detail_url": "/ping/",
|
||||
"plugin": None,
|
||||
"poll": True,
|
||||
"params": [],
|
||||
},
|
||||
"uptime_summary": {
|
||||
"key": "uptime_summary",
|
||||
"label": "Uptime / SLA",
|
||||
"description": "Per-host rolling uptime % for 24h, 7d, and 30d windows",
|
||||
"hx_url": "/hosts/uptime/widget",
|
||||
"detail_url": "/hosts/uptime",
|
||||
"plugin": None,
|
||||
"poll": False,
|
||||
"params": [],
|
||||
},
|
||||
"dns": {
|
||||
"key": "dns",
|
||||
"label": "DNS",
|
||||
"description": "DNS resolution status for all monitored hosts",
|
||||
"hx_url": "/dns/rows",
|
||||
"detail_url": "/dns/",
|
||||
"plugin": None,
|
||||
"poll": True,
|
||||
"params": [],
|
||||
},
|
||||
"traefik_routers": {
|
||||
"key": "traefik_routers",
|
||||
"label": "Traefik — Routers",
|
||||
"description": "Top routers by request rate with error highlights and expiring certs",
|
||||
"hx_url": "/plugins/traefik/widget",
|
||||
"detail_url": "/plugins/traefik/",
|
||||
"plugin": "traefik",
|
||||
"poll": True,
|
||||
"params": [],
|
||||
},
|
||||
"traefik_access_log": {
|
||||
"key": "traefik_access_log",
|
||||
"label": "Traefik — Access Log",
|
||||
"description": "Traffic origin summary — top IPs, countries, and request distribution",
|
||||
"hx_url": "/plugins/traefik/widget/access_log",
|
||||
"detail_url": "/plugins/traefik/",
|
||||
"plugin": "traefik",
|
||||
"poll": True,
|
||||
"params": [
|
||||
{
|
||||
"key": "ip_filter",
|
||||
"label": "IP Filter",
|
||||
"type": "select",
|
||||
"default": "all",
|
||||
"options": [
|
||||
{"value": "all", "label": "All traffic"},
|
||||
{"value": "internal", "label": "Internal only"},
|
||||
{"value": "external", "label": "External only"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"key": "limit",
|
||||
"label": "Top IPs shown",
|
||||
"type": "select",
|
||||
"default": 10,
|
||||
"options": [
|
||||
{"value": 10, "label": "10 entries"},
|
||||
{"value": 20, "label": "20 entries"},
|
||||
{"value": 50, "label": "50 entries"},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
"traefik_request_chart": {
|
||||
"key": "traefik_request_chart",
|
||||
"label": "Traefik — Request Chart",
|
||||
"description": "Request rate and error percentage over time (Chart.js)",
|
||||
"hx_url": "/plugins/traefik/widget/request_chart",
|
||||
"detail_url": "/plugins/traefik/",
|
||||
"plugin": "traefik",
|
||||
"poll": True,
|
||||
"params": [
|
||||
{
|
||||
"key": "hours",
|
||||
"label": "Time range",
|
||||
"type": "select",
|
||||
"default": 6,
|
||||
"options": [
|
||||
{"value": 1, "label": "Last 1 hour"},
|
||||
{"value": 6, "label": "Last 6 hours"},
|
||||
{"value": 24, "label": "Last 24 hours"},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
"unifi_overview": {
|
||||
"key": "unifi_overview",
|
||||
"label": "UniFi — Overview",
|
||||
"description": "WAN status, client counts, offline devices, and active alarms",
|
||||
"hx_url": "/plugins/unifi/widget",
|
||||
"detail_url": "/plugins/unifi/",
|
||||
"plugin": "unifi",
|
||||
"poll": True,
|
||||
"params": [],
|
||||
},
|
||||
"unifi_clients": {
|
||||
"key": "unifi_clients",
|
||||
"label": "UniFi — Top Clients",
|
||||
"description": "Active wireless and wired clients sorted by bandwidth",
|
||||
"hx_url": "/plugins/unifi/widget/clients",
|
||||
"detail_url": "/plugins/unifi/",
|
||||
"plugin": "unifi",
|
||||
"poll": True,
|
||||
"params": [
|
||||
{
|
||||
"key": "limit",
|
||||
"label": "Clients shown",
|
||||
"type": "select",
|
||||
"default": 5,
|
||||
"options": [
|
||||
{"value": 5, "label": "5 clients"},
|
||||
{"value": 10, "label": "10 clients"},
|
||||
{"value": 20, "label": "20 clients"},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
"unifi_devices": {
|
||||
"key": "unifi_devices",
|
||||
"label": "UniFi — Devices",
|
||||
"description": "Network infrastructure devices with online/offline state",
|
||||
"hx_url": "/plugins/unifi/widget/devices",
|
||||
"detail_url": "/plugins/unifi/",
|
||||
"plugin": "unifi",
|
||||
"poll": True,
|
||||
"params": [
|
||||
{
|
||||
"key": "type_filter",
|
||||
"label": "Device type",
|
||||
"type": "select",
|
||||
"default": "all",
|
||||
"options": [
|
||||
{"value": "all", "label": "All devices"},
|
||||
{"value": "wireless", "label": "Wireless APs"},
|
||||
{"value": "wired", "label": "Wired only"},
|
||||
{"value": "offline", "label": "Offline devices"},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
"ups_status": {
|
||||
"key": "ups_status",
|
||||
"label": "UPS — Status",
|
||||
"description": "Battery charge, runtime estimate, load percentage, and on-battery alerts",
|
||||
"hx_url": "/plugins/ups/widget",
|
||||
"detail_url": "/plugins/ups/",
|
||||
"plugin": "ups",
|
||||
"poll": True,
|
||||
"params": [],
|
||||
},
|
||||
"ups_history": {
|
||||
"key": "ups_history",
|
||||
"label": "UPS — History Chart",
|
||||
"description": "Battery %, load %, and input voltage over time (Chart.js)",
|
||||
"hx_url": "/plugins/ups/widget/history",
|
||||
"detail_url": "/plugins/ups/",
|
||||
"plugin": "ups",
|
||||
"poll": True,
|
||||
"params": [
|
||||
{
|
||||
"key": "hours",
|
||||
"label": "Time range",
|
||||
"type": "select",
|
||||
"default": 6,
|
||||
"options": [
|
||||
{"value": 1, "label": "Last 1 hour"},
|
||||
{"value": 6, "label": "Last 6 hours"},
|
||||
{"value": 24, "label": "Last 24 hours"},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
"docker_containers": {
|
||||
"key": "docker_containers",
|
||||
"label": "Docker — Containers",
|
||||
"description": "Container status overview — running/stopped counts and per-container CPU",
|
||||
"hx_url": "/plugins/docker/widget",
|
||||
"detail_url": "/plugins/docker/",
|
||||
"plugin": "docker",
|
||||
"poll": True,
|
||||
"params": [
|
||||
{
|
||||
"key": "show_stopped",
|
||||
"label": "Show stopped",
|
||||
"type": "select",
|
||||
"default": "no",
|
||||
"options": [
|
||||
{"value": "no", "label": "Running only"},
|
||||
{"value": "yes", "label": "All containers"},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
"http_monitors": {
|
||||
"key": "http_monitors",
|
||||
"label": "HTTP Monitors",
|
||||
"description": "Synthetic endpoint checks — up/down status and response times",
|
||||
"hx_url": "/plugins/http/widget",
|
||||
"detail_url": "/plugins/http/",
|
||||
"plugin": "http",
|
||||
"poll": True,
|
||||
"params": [
|
||||
{
|
||||
"key": "show_down_only",
|
||||
"label": "Display filter",
|
||||
"type": "select",
|
||||
"default": "no",
|
||||
"options": [
|
||||
{"value": "no", "label": "All monitors"},
|
||||
{"value": "yes", "label": "Failing only"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"key": "limit",
|
||||
"label": "Max shown",
|
||||
"type": "select",
|
||||
"default": 10,
|
||||
"options": [
|
||||
{"value": 5, "label": "5 monitors"},
|
||||
{"value": 10, "label": "10 monitors"},
|
||||
{"value": 20, "label": "20 monitors"},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
"docker_resources": {
|
||||
"key": "docker_resources",
|
||||
"label": "Docker — Resources",
|
||||
"description": "CPU and memory usage bars for running containers",
|
||||
"hx_url": "/plugins/docker/widget/resources",
|
||||
"detail_url": "/plugins/docker/",
|
||||
"plugin": "docker",
|
||||
"poll": True,
|
||||
"params": [
|
||||
{
|
||||
"key": "limit",
|
||||
"label": "Containers shown",
|
||||
"type": "select",
|
||||
"default": 10,
|
||||
"options": [
|
||||
{"value": 5, "label": "5 containers"},
|
||||
{"value": 10, "label": "10 containers"},
|
||||
{"value": 20, "label": "20 containers"},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
"snmp_devices": {
|
||||
"key": "snmp_devices",
|
||||
"label": "SNMP — Devices",
|
||||
"description": "Latest OID readings for all configured SNMP devices",
|
||||
"hx_url": "/plugins/snmp/widget",
|
||||
"detail_url": "/plugins/snmp/",
|
||||
"plugin": "snmp",
|
||||
"poll": True,
|
||||
"params": [],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def register_widget(definition: dict) -> None:
|
||||
"""Register a widget at runtime (used by external plugins)."""
|
||||
WIDGET_REGISTRY[definition["key"]] = definition
|
||||
|
||||
|
||||
def get_available_widgets(plugins_cfg: dict) -> list[dict]:
|
||||
"""Return widgets whose plugin dependency is satisfied."""
|
||||
result = []
|
||||
for w in WIDGET_REGISTRY.values():
|
||||
if w["plugin"] is None:
|
||||
result.append(w)
|
||||
elif plugins_cfg.get(w["plugin"], {}).get("enabled", False):
|
||||
result.append(w)
|
||||
return result
|
||||
Reference in New Issue
Block a user