feat: rename to FabledScryer, multi-dashboard system, plugin management, branding

- Rename package fablednetmon → fabledscryer throughout
- Multi-dashboard: ownership, per-user defaults, HTMX edit (add/remove/reorder)
- Read-only share tokens scoped to individual dashboards
- Dashboard edit is HTMX-driven (no page reloads)
- Plugin management system: remote catalog, download/install, hot-reload, in-app restart
- plugin_index.py: fetch/cache remote index.yaml; default URL → bvandeusen/fabledscryer-plugins
- plugin_manager.py: download_and_install_plugin, hot_reload_plugin, restart_app
  - ZIP extraction handles GitHub archive formats (name-v1.0.0/, name-main/)
- Settings split into tabbed sections: General, Notifications, Ansible, Plugins
- Plugins tab: catalog browser (HTMX), install/activate/update/restart actions
- UI/branding: dark palette (#07071a), crystal ball SVG logo, animated star field,
  Libertinus Serif applied to headings, nav, labels, and section titles
- Widget registry (core/widgets.py) for dashboard plugin integration
- UPS widget.html (dashboard card) and settings/_tabs.html include
- Migrations 0005–0008: dashboards, is_default, ownership, share tokens
- docs/plugins/: writing-a-plugin.md updated with publishing guide,
  index.yaml.example template for fabledscryer-plugins repo

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-22 18:27:56 -04:00
parent 165a202ba4
commit 230b542015
121 changed files with 4820 additions and 715 deletions
View File
+240
View File
@@ -0,0 +1,240 @@
from __future__ import annotations
import asyncio
import logging
import uuid
from datetime import datetime, timezone
from typing import TYPE_CHECKING
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from fabledscryer.models.alerts import (
AlertEvent,
AlertOperator,
AlertRule,
AlertState,
AlertStateEnum,
)
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()
# 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")
+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}
+77
View File
@@ -0,0 +1,77 @@
# 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
_cache: tuple[dict, float] | None = None # (raw_data, fetched_at monotonic)
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_catalog(index_url: str, force: bool = False) -> list[CatalogPlugin]:
"""Fetch and parse the remote plugin catalog.
Results are cached in-process for _CACHE_TTL seconds. Pass force=True to
bypass the cache. On network failure, stale cached data is returned if
available; otherwise an empty list is returned.
"""
global _cache
import httpx
import yaml
now = time.monotonic()
if not force and _cache is not None:
raw, fetched_at = _cache
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(index_url)
resp.raise_for_status()
raw = yaml.safe_load(resp.text) or {}
_cache = (raw, now)
logger.info(
"Plugin catalog fetched from %s: %d plugin(s)",
index_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", index_url)
if _cache is not None:
raw, _ = _cache
logger.warning("Returning stale cached catalog")
return [CatalogPlugin(p) for p in raw.get("plugins", [])]
return []
def clear_catalog_cache() -> None:
"""Invalidate the in-process catalog cache."""
global _cache
_cache = None
+333
View File
@@ -0,0 +1,333 @@
# 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()
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():
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():
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:
logger.exception("Plugin %r: failed to read plugin.yaml, skipping", name)
continue
if meta.get("name") != 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):
logger.error(
"Plugin %r: requires fabledscryer>=%s but running %s, skipping",
name, min_ver, fabledscryer.__version__,
)
continue
except Exception:
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)
defaults = meta.get("config", {})
user_overrides = {k: v for k, v in cfg.items() if k != "enabled"}
plugins_cfg[name] = {"enabled": True, **defaults, **user_overrides}
# Import the plugin package
try:
module = importlib.import_module(name)
except ImportError:
logger.exception("Plugin %r: import failed, skipping", name)
continue
# Validate required exports
if not hasattr(module, "setup"):
logger.error("Plugin %r: missing required export 'setup', skipping", name)
continue
if not hasattr(module, "get_scheduled_tasks"):
logger.error(
"Plugin %r: missing required export 'get_scheduled_tasks', skipping", name
)
continue
# Call setup
try:
module.setup(app)
except Exception:
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:
logger.exception("Plugin %r: get_blueprint() raised", name)
# 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:
logger.exception("Plugin %r: get_scheduled_tasks() raised", name)
_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
try:
mdir = plugin_dir / name / "migrations"
if mdir.exists():
from fabledscryer.core.migration_runner import run_plugin_migrations
run_plugin_migrations(app.config["DATABASE_URL"], [mdir])
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
defaults = meta.get("config", {})
stored_cfg = app.config.get("PLUGINS", {}).get(name, {})
user_overrides = {k: v for k, v in stored_cfg.items() if k != "enabled"}
merged = {"enabled": True, **defaults, **user_overrides}
app.config["PLUGINS"][name] = merged
# Import
try:
module = importlib.import_module(name)
except ImportError 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)
+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")
+164
View File
@@ -0,0 +1,164 @@
# 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://raw.githubusercontent.com/bvandeusen/fabledscryer-plugins/main/index.yaml",
}
# ─────────────────────────────────────────────────────────────────────────────
# 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_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]
+76
View File
@@ -0,0 +1,76 @@
# 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
# for the widget to be available.
#
# Forward-compatibility notes:
# - Step 8 (widget variants) will add multiple entries per plugin and a
# "variant" sub-key rather than one entry per plugin.
# - Step 9 (plugin management) will allow external plugins to register
# entries here at load time via register_widget().
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, # built-in; always available
"poll": True,
},
"dns": {
"key": "dns",
"label": "DNS",
"description": "DNS resolution status for all monitored hosts",
"hx_url": "/dns/rows",
"detail_url": "/dns/",
"plugin": None,
"poll": True,
},
"traefik": {
"key": "traefik",
"label": "Traefik",
"description": "Proxy request rates, service health, and recent errors",
"hx_url": "/plugins/traefik/widget",
"detail_url": "/plugins/traefik/",
"plugin": "traefik",
"poll": True,
},
"unifi": {
"key": "unifi",
"label": "UniFi Network",
"description": "WAN status, client counts, active alarms",
"hx_url": "/plugins/unifi/widget",
"detail_url": "/plugins/unifi/",
"plugin": "unifi",
"poll": True,
},
"ups": {
"key": "ups",
"label": "UPS",
"description": "Battery charge, runtime estimate, load percentage",
"hx_url": "/plugins/ups/widget",
"detail_url": "/plugins/ups/",
"plugin": "ups",
"poll": True,
},
}
def register_widget(definition: dict) -> None:
"""Register a widget at runtime (used by external plugins in step 9)."""
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