230b542015
- 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>
165 lines
6.7 KiB
Python
165 lines
6.7 KiB
Python
# 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
|