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:
@@ -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")
|
||||
@@ -0,0 +1,35 @@
|
||||
from __future__ import annotations
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import delete
|
||||
|
||||
from fabledscryer.models.monitors import DnsResult, PingResult
|
||||
from fabledscryer.models.metrics import PluginMetric
|
||||
from fabledscryer.models.ansible import AnsibleRun
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from quart import Quart
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def run_cleanup(app: "Quart") -> None:
|
||||
"""Delete rows older than DATA_RETENTION_DAYS from time-series tables."""
|
||||
retention_days: int = app.config.get("DATA_RETENTION_DAYS", 90)
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(days=retention_days)
|
||||
|
||||
async with app.db_sessionmaker() as session:
|
||||
async with session.begin():
|
||||
for model, ts_col in [
|
||||
(PingResult, PingResult.probed_at),
|
||||
(DnsResult, DnsResult.resolved_at),
|
||||
(PluginMetric, PluginMetric.recorded_at),
|
||||
(AnsibleRun, AnsibleRun.started_at),
|
||||
]:
|
||||
result = await session.execute(
|
||||
delete(model).where(ts_col < cutoff)
|
||||
)
|
||||
if result.rowcount:
|
||||
logger.info(f"Pruned {result.rowcount} rows from {model.__tablename__}")
|
||||
@@ -0,0 +1,82 @@
|
||||
# fabledscryer/core/migration_runner.py
|
||||
from __future__ import annotations
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def discover_all_plugin_migration_dirs(plugin_dir: Path) -> list[Path]:
|
||||
"""Scan plugin_dir for any migrations/ subdirs, regardless of enabled status.
|
||||
|
||||
Used by run_core_migrations so Alembic can resolve the full revision graph
|
||||
even when a plugin migration is already stamped in alembic_version.
|
||||
"""
|
||||
dirs: list[Path] = []
|
||||
if not plugin_dir.exists():
|
||||
return dirs
|
||||
for entry in sorted(plugin_dir.iterdir()):
|
||||
if not entry.is_dir():
|
||||
continue
|
||||
mdir = entry / "migrations"
|
||||
if mdir.exists():
|
||||
dirs.append(mdir)
|
||||
return dirs
|
||||
|
||||
|
||||
def run_core_migrations(db_url: str, plugin_dir: Path | None = None) -> None:
|
||||
"""Run core Alembic migrations.
|
||||
|
||||
Includes all discovered plugin migration dirs (if plugin_dir given) so
|
||||
Alembic can resolve any previously-applied plugin revisions in the graph.
|
||||
Called first so the app_settings table exists before loading settings.
|
||||
"""
|
||||
dirs = discover_all_plugin_migration_dirs(plugin_dir) if plugin_dir else []
|
||||
_run(db_url, plugin_migration_dirs=dirs)
|
||||
|
||||
|
||||
def run_plugin_migrations(db_url: str, plugin_migration_dirs: list[Path]) -> None:
|
||||
"""Run plugin Alembic migrations after settings are loaded."""
|
||||
if plugin_migration_dirs:
|
||||
_run(db_url, plugin_migration_dirs=plugin_migration_dirs)
|
||||
|
||||
|
||||
def run_migrations(db_url: str, plugin_migration_dirs: list[Path] | None = None) -> None:
|
||||
"""Run core + plugin Alembic migrations (single-phase convenience wrapper)."""
|
||||
_run(db_url, plugin_migration_dirs=plugin_migration_dirs or [])
|
||||
|
||||
|
||||
def _run(db_url: str, plugin_migration_dirs: list[Path]) -> None:
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
|
||||
core_migrations = Path(__file__).parent.parent / "migrations"
|
||||
core_versions = core_migrations / "versions"
|
||||
|
||||
cfg = Config()
|
||||
cfg.set_main_option("script_location", str(core_migrations))
|
||||
cfg.set_main_option("sqlalchemy.url", db_url)
|
||||
|
||||
version_locs = [str(core_versions)]
|
||||
for plugin_dir in plugin_migration_dirs:
|
||||
pv = plugin_dir / "versions"
|
||||
if pv.exists():
|
||||
version_locs.append(str(pv))
|
||||
|
||||
cfg.set_main_option("version_locations", " ".join(version_locs))
|
||||
|
||||
logger.info("Running migrations (version_locations: %s)", " ".join(version_locs))
|
||||
command.upgrade(cfg, "heads")
|
||||
logger.info("Migrations complete")
|
||||
|
||||
|
||||
def get_plugin_migration_dirs(plugin_dir: Path, plugins_cfg: dict) -> list[Path]:
|
||||
"""Return migration directories for enabled plugins (filesystem scan only)."""
|
||||
dirs = []
|
||||
for name, cfg in plugins_cfg.items():
|
||||
if not cfg.get("enabled", False):
|
||||
continue
|
||||
mdir = plugin_dir / name / "migrations"
|
||||
if mdir.exists():
|
||||
dirs.append(mdir)
|
||||
return dirs
|
||||
@@ -0,0 +1,24 @@
|
||||
from __future__ import annotations
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def get_core_head() -> str:
|
||||
"""Return the current head revision ID of the core migration scripts.
|
||||
|
||||
Called at migration authoring time (when running ``alembic revision``),
|
||||
not at application runtime. The returned ID should be captured statically
|
||||
in a plugin's ``depends_on`` field.
|
||||
"""
|
||||
from alembic.config import Config
|
||||
from alembic.script import ScriptDirectory
|
||||
|
||||
migrations_dir = Path(__file__).parent.parent / "migrations"
|
||||
cfg = Config()
|
||||
cfg.set_main_option("script_location", str(migrations_dir))
|
||||
script = ScriptDirectory.from_config(cfg)
|
||||
heads = script.get_heads()
|
||||
if len(heads) != 1:
|
||||
raise RuntimeError(
|
||||
f"Expected exactly 1 core migration head, got {len(heads)}: {heads}"
|
||||
)
|
||||
return heads[0]
|
||||
@@ -0,0 +1,100 @@
|
||||
from __future__ import annotations
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import smtplib
|
||||
import ssl
|
||||
import types
|
||||
from email.message import EmailMessage
|
||||
|
||||
import httpx
|
||||
from jinja2 import Template
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def dispatch_notifications(
|
||||
smtp_cfg: dict,
|
||||
webhook_cfg: dict,
|
||||
alert_data: dict,
|
||||
) -> dict:
|
||||
"""Dispatch all configured notification channels. Returns results dict."""
|
||||
results = {}
|
||||
|
||||
if smtp_cfg.get("host") and smtp_cfg.get("recipients"):
|
||||
results["email"] = await _send_email(smtp_cfg, alert_data)
|
||||
|
||||
if webhook_cfg.get("url"):
|
||||
results["webhook"] = await _send_webhook(webhook_cfg, alert_data)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
async def _send_email(cfg: dict, alert: dict) -> dict:
|
||||
try:
|
||||
msg = EmailMessage()
|
||||
msg["Subject"] = f"[Fabled Scryer] {alert['state']} — {alert['rule_name']}"
|
||||
msg["From"] = cfg.get("username", "fabledscryer@localhost")
|
||||
msg["To"] = ", ".join(cfg["recipients"])
|
||||
msg.set_content(
|
||||
f"Alert: {alert['state']}\n"
|
||||
f"Rule: {alert['rule_name']}\n"
|
||||
f"Resource: {alert['resource']}\n"
|
||||
f"Metric: {alert['metric']} = {alert['value']}\n"
|
||||
f"Threshold: {alert['threshold']}\n"
|
||||
f"Time: {alert['timestamp']}\n"
|
||||
)
|
||||
loop = asyncio.get_running_loop()
|
||||
await loop.run_in_executor(None, _smtp_send_sync, cfg, msg)
|
||||
return {"sent": True, "error": None}
|
||||
except Exception as exc:
|
||||
logger.error(f"Email notification failed: {exc}")
|
||||
return {"sent": False, "error": str(exc)}
|
||||
|
||||
|
||||
def _smtp_send_sync(cfg: dict, msg: EmailMessage) -> None:
|
||||
ctx = ssl.create_default_context() if cfg.get("tls", True) else None
|
||||
with smtplib.SMTP(cfg["host"], cfg.get("port", 587)) as smtp:
|
||||
if ctx:
|
||||
smtp.starttls(context=ctx)
|
||||
if cfg.get("username"):
|
||||
smtp.login(cfg["username"], cfg.get("password", ""))
|
||||
smtp.send_message(msg)
|
||||
|
||||
|
||||
async def _send_webhook(cfg: dict, alert: dict) -> dict:
|
||||
template_str = cfg.get(
|
||||
"template",
|
||||
'{"content": "{{ alert.state }} — {{ alert.rule_name }}"}',
|
||||
)
|
||||
|
||||
# Render Jinja2 template with alert as a namespace object (attribute access)
|
||||
try:
|
||||
alert_ns = types.SimpleNamespace(**alert)
|
||||
rendered = Template(template_str).render(alert=alert_ns)
|
||||
except Exception as exc:
|
||||
error = f"Template render failed: {exc}"
|
||||
logger.error(error)
|
||||
return {"sent": False, "error": error}
|
||||
|
||||
# Validate JSON before sending
|
||||
try:
|
||||
json.loads(rendered)
|
||||
except json.JSONDecodeError as exc:
|
||||
error = f"Rendered webhook template is not valid JSON: {exc}"
|
||||
logger.error(error)
|
||||
return {"sent": False, "error": error}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
response = await client.post(
|
||||
cfg["url"],
|
||||
content=rendered,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
response.raise_for_status()
|
||||
return {"sent": True, "error": None}
|
||||
except Exception as exc:
|
||||
error = str(exc)
|
||||
logger.error(f"Webhook notification failed: {error}")
|
||||
return {"sent": False, "error": error}
|
||||
@@ -0,0 +1,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
|
||||
@@ -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)
|
||||
@@ -0,0 +1,42 @@
|
||||
from __future__ import annotations
|
||||
import asyncio
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable, Coroutine
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScheduledTask:
|
||||
name: str
|
||||
coro_factory: Callable[[], Coroutine]
|
||||
interval_seconds: int
|
||||
run_on_startup: bool = False
|
||||
|
||||
|
||||
async def start_scheduler(tasks: list[ScheduledTask]) -> None:
|
||||
"""Run scheduled tasks in a loop. Call with asyncio.create_task()."""
|
||||
last_run: dict[str, float] = {}
|
||||
|
||||
for task in tasks:
|
||||
if task.run_on_startup:
|
||||
logger.info(f"Startup task: {task.name}")
|
||||
asyncio.create_task(_run_task(task))
|
||||
last_run[task.name] = asyncio.get_event_loop().time()
|
||||
|
||||
while True:
|
||||
now = asyncio.get_event_loop().time()
|
||||
for task in tasks:
|
||||
last = last_run.get(task.name, 0)
|
||||
if now - last >= task.interval_seconds:
|
||||
asyncio.create_task(_run_task(task))
|
||||
last_run[task.name] = now
|
||||
await asyncio.sleep(1)
|
||||
|
||||
|
||||
async def _run_task(task: ScheduledTask) -> None:
|
||||
try:
|
||||
await task.coro_factory()
|
||||
except Exception:
|
||||
logger.exception(f"Scheduled task {task.name!r} raised an exception")
|
||||
@@ -0,0 +1,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
|
||||
@@ -0,0 +1,44 @@
|
||||
# fabledscryer/core/time_range.py
|
||||
"""Shared time-range utilities for scoping queries and sparkline data."""
|
||||
from __future__ import annotations
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import TypeVar
|
||||
|
||||
RANGES: dict[str, timedelta] = {
|
||||
"1h": timedelta(hours=1),
|
||||
"6h": timedelta(hours=6),
|
||||
"24h": timedelta(hours=24),
|
||||
"7d": timedelta(days=7),
|
||||
"30d": timedelta(days=30),
|
||||
}
|
||||
|
||||
RANGE_OPTIONS: list[str] = list(RANGES.keys())
|
||||
DEFAULT_RANGE = "24h"
|
||||
|
||||
|
||||
def parse_range(range_str: str | None) -> tuple[datetime, str]:
|
||||
"""Return (since_datetime, range_key) from a query-param string like '24h'."""
|
||||
key = range_str if range_str in RANGES else DEFAULT_RANGE
|
||||
since = datetime.now(timezone.utc) - RANGES[key]
|
||||
return since, key
|
||||
|
||||
|
||||
def bucket_seconds(since: datetime, target_points: int = 80) -> int:
|
||||
"""Return bucket width in seconds so that (now - since) / width ≈ target_points.
|
||||
|
||||
Minimum is 60s (one poll interval) so raw data is never re-averaged below
|
||||
the collection resolution.
|
||||
"""
|
||||
range_secs = (datetime.now(timezone.utc) - since).total_seconds()
|
||||
return max(60, int(range_secs / target_points))
|
||||
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
def subsample(seq: list[T], n: int = 80) -> list[T]:
|
||||
"""Return at most n evenly-distributed elements from seq (preserves order)."""
|
||||
if len(seq) <= n:
|
||||
return seq
|
||||
indices = sorted({int(round(i * (len(seq) - 1) / (n - 1))) for i in range(n)})
|
||||
return [seq[i] for i in indices]
|
||||
@@ -0,0 +1,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
|
||||
Reference in New Issue
Block a user