refactor: rename package directory fabledscryer → roundtable

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-13 17:10:31 -04:00
parent fd9c6d941d
commit 8aad2ab43d
117 changed files with 0 additions and 0 deletions
View File
+261
View File
@@ -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")
+40
View File
@@ -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)
+35
View File
@@ -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__}")
+82
View File
@@ -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
+24
View File
@@ -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]
+100
View File
@@ -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}
+93
View File
@@ -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()
+436
View File
@@ -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)
+275
View File
@@ -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)
+42
View File
@@ -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")
+201
View File
@@ -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
+44
View File
@@ -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]
+298
View File
@@ -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