From 484da1f2638b5fdd678c5431d33abacb274dc18d Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 22 Mar 2026 18:42:50 -0400 Subject: [PATCH 01/46] feat: show update available when catalog version differs from installed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Catalog partial now compares catalog version vs installed plugin.yaml version. Gold border + "Update available — installed vX.Y.Z" badge when newer. Update button uses btn-warn styling. Route passes installed_versions dict. Co-Authored-By: Claude Sonnet 4.6 --- fabledscryer/settings/routes.py | 2 ++ .../templates/settings/_catalog_partial.html | 23 +++++++++++++++---- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/fabledscryer/settings/routes.py b/fabledscryer/settings/routes.py index ad2d4e9..f7af564 100644 --- a/fabledscryer/settings/routes.py +++ b/fabledscryer/settings/routes.py @@ -256,11 +256,13 @@ async def plugins_catalog(): discovered = _discover_plugins() installed_names = {p["_dir"] for p in discovered} + installed_versions = {p["_dir"]: p.get("version", "0.0.0") for p in discovered} return await render_template( "settings/_catalog_partial.html", catalog=catalog, installed_names=installed_names, + installed_versions=installed_versions, loaded_names=_LOADED_PLUGINS, error=error, ) diff --git a/fabledscryer/templates/settings/_catalog_partial.html b/fabledscryer/templates/settings/_catalog_partial.html index cc3afd8..06887ed 100644 --- a/fabledscryer/templates/settings/_catalog_partial.html +++ b/fabledscryer/templates/settings/_catalog_partial.html @@ -12,17 +12,27 @@ {% for plugin in catalog %} {% set is_installed = plugin.name in installed_names %} {% set is_loaded = plugin.name in loaded_names %} -
+ {% set installed_ver = installed_versions.get(plugin.name, "") %} + {# update_available: catalog version is strictly newer than what's on disk #} + {% set update_available = is_installed and installed_ver and installed_ver != plugin.version %} + +
{{ plugin.name }} v{{ plugin.version }} - {% if is_loaded %} + + {% if update_available %} + + Update available — installed v{{ installed_ver }} + + {% elif is_loaded %} Active {% elif is_installed %} Installed {% endif %} + {% for tag in plugin.tags %} {{ tag }} {% endfor %} @@ -46,8 +56,8 @@ class="btn btn-ghost btn-sm" style="font-size:0.78rem;">Source {% endif %} - {% if is_loaded %} - {# Already active — update available if versions differ #} + {% if update_available %} + {# Installed but outdated — offer update (download + restart) #}
- +
+ {% elif is_loaded %} + {# Active and up to date — no action needed #} + {% elif is_installed %} {# Installed but not active — offer hot-reload #}
Date: Mon, 23 Mar 2026 08:15:15 -0400 Subject: [PATCH 02/46] feat: OIDC/LDAP auth, audit log, maintenance windows, migrations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add OIDC (OpenID Connect) authentication with discovery URL support, group-based role mapping, and token introspection - Add LDAP authentication with bind credentials, group-based role mapping, and configurable attribute names - Add audit log (model, routes, templates) tracking settings changes, plugin enable/disable, login events - Add migrations 0009 (widget variants), 0010 (maintenance windows), 0011 (audit log) - Add optional dependency groups to pyproject.toml: [ldap], [snmp] - Add Settings → Auth tab with OIDC and LDAP configuration forms Co-Authored-By: Claude Sonnet 4.6 --- fabledscryer/audit/__init__.py | 0 fabledscryer/audit/routes.py | 42 +++++ fabledscryer/auth/ldap_auth.py | 98 ++++++++++ fabledscryer/auth/oidc.py | 109 +++++++++++ fabledscryer/auth/routes.py | 145 +++++++++++++- fabledscryer/core/audit.py | 40 ++++ fabledscryer/core/settings.py | 37 ++++ .../versions/0009_widget_variants.py | 26 +++ .../versions/0010_maintenance_windows.py | 32 ++++ .../migrations/versions/0011_audit_log.py | 35 ++++ fabledscryer/models/audit.py | 24 +++ fabledscryer/templates/audit/list.html | 78 ++++++++ fabledscryer/templates/auth/login.html | 20 +- fabledscryer/templates/settings/auth.html | 178 ++++++++++++++++++ pyproject.toml | 6 + 15 files changed, 863 insertions(+), 7 deletions(-) create mode 100644 fabledscryer/audit/__init__.py create mode 100644 fabledscryer/audit/routes.py create mode 100644 fabledscryer/auth/ldap_auth.py create mode 100644 fabledscryer/auth/oidc.py create mode 100644 fabledscryer/core/audit.py create mode 100644 fabledscryer/migrations/versions/0009_widget_variants.py create mode 100644 fabledscryer/migrations/versions/0010_maintenance_windows.py create mode 100644 fabledscryer/migrations/versions/0011_audit_log.py create mode 100644 fabledscryer/models/audit.py create mode 100644 fabledscryer/templates/audit/list.html create mode 100644 fabledscryer/templates/settings/auth.html diff --git a/fabledscryer/audit/__init__.py b/fabledscryer/audit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/fabledscryer/audit/routes.py b/fabledscryer/audit/routes.py new file mode 100644 index 0000000..a10eab4 --- /dev/null +++ b/fabledscryer/audit/routes.py @@ -0,0 +1,42 @@ +from __future__ import annotations +import json +from quart import Blueprint, render_template, request, current_app +from sqlalchemy import select +from fabledscryer.auth.middleware import require_role +from fabledscryer.models.audit import AuditEvent +from fabledscryer.models.users import UserRole + +audit_bp = Blueprint("audit", __name__, url_prefix="/audit") + + +@audit_bp.get("/") +@require_role(UserRole.admin) +async def list_events(): + limit = min(int(request.args.get("limit", 200)), 500) + action_filter = request.args.get("action", "").strip() + async with current_app.db_sessionmaker() as db: + stmt = select(AuditEvent).order_by(AuditEvent.timestamp.desc()).limit(limit) + if action_filter: + stmt = select(AuditEvent).where( + AuditEvent.action.like(f"{action_filter}%") + ).order_by(AuditEvent.timestamp.desc()).limit(limit) + result = await db.execute(stmt) + events = result.scalars().all() + + # Parse detail_json for display + parsed = [] + for e in events: + detail = {} + if e.detail_json: + try: + detail = json.loads(e.detail_json) + except (ValueError, TypeError): + detail = {"raw": e.detail_json} + parsed.append({"event": e, "detail": detail}) + + return await render_template( + "audit/list.html", + events=parsed, + limit=limit, + action_filter=action_filter, + ) diff --git a/fabledscryer/auth/ldap_auth.py b/fabledscryer/auth/ldap_auth.py new file mode 100644 index 0000000..098229f --- /dev/null +++ b/fabledscryer/auth/ldap_auth.py @@ -0,0 +1,98 @@ +"""fabledscryer/auth/ldap_auth.py + +LDAP authentication helper. Requires the optional `ldap3` package. +Falls back gracefully if ldap3 is not installed. +""" +from __future__ import annotations +import asyncio +import logging + +logger = logging.getLogger(__name__) + + +def _ldap_available() -> bool: + try: + import ldap3 # noqa: F401 + return True + except ImportError: + return False + + +def _ldap_authenticate_sync(cfg: dict, username: str, password: str) -> dict | None: + """Synchronous LDAP auth. Returns user info dict or None on failure. + + Called via run_in_executor so it doesn't block the event loop. + """ + import ldap3 + + host = cfg.get("host", "") + port = int(cfg.get("port", 389)) + use_tls = cfg.get("tls", False) + bind_dn = cfg.get("bind_dn", "") + bind_password = cfg.get("bind_password", "") + base_dn = cfg.get("base_dn", "") + user_filter = cfg.get("user_filter", "(uid={username})").format(username=username) + admin_group_dn = cfg.get("admin_group_dn", "") + operator_group_dn = cfg.get("operator_group_dn", "") + attr_username = cfg.get("attr_username", "uid") + attr_email = cfg.get("attr_email", "mail") + + tls = ldap3.Tls() if use_tls else None + server = ldap3.Server(host, port=port, tls=tls, get_info=ldap3.ALL) + + # Service account bind to search for user DN + try: + svc_conn = ldap3.Connection(server, user=bind_dn, password=bind_password, auto_bind=True) + except Exception as exc: + logger.error("LDAP service bind failed: %s", exc) + return None + + try: + svc_conn.search( + search_base=base_dn, + search_filter=user_filter, + attributes=[attr_username, attr_email, "memberOf", "dn"], + ) + if not svc_conn.entries: + logger.debug("LDAP user not found: %s", username) + return None + entry = svc_conn.entries[0] + user_dn = str(entry.entry_dn) + ldap_username = str(getattr(entry, attr_username, [username])[0]) if hasattr(entry, attr_username) else username + ldap_email = str(getattr(entry, attr_email, [""])[0]) if hasattr(entry, attr_email) else "" + member_of: list[str] = [str(g) for g in getattr(entry, "memberOf", [])] + except Exception as exc: + logger.error("LDAP search failed: %s", exc) + return None + finally: + svc_conn.unbind() + + # Bind as the user to verify password + try: + user_conn = ldap3.Connection(server, user=user_dn, password=password, auto_bind=True) + user_conn.unbind() + except Exception: + logger.debug("LDAP password verify failed for %s", username) + return None + + # Map role from group membership + role = "viewer" + if admin_group_dn and admin_group_dn in member_of: + role = "admin" + elif operator_group_dn and operator_group_dn in member_of: + role = "operator" + + return { + "username": ldap_username, + "email": ldap_email, + "role": role, + } + + +async def ldap_authenticate(cfg: dict, username: str, password: str) -> dict | None: + """Async wrapper around synchronous LDAP auth. Returns user info or None.""" + if not _ldap_available(): + logger.warning("ldap3 not installed — LDAP auth unavailable") + return None + loop = asyncio.get_running_loop() + return await loop.run_in_executor(None, _ldap_authenticate_sync, cfg, username, password) diff --git a/fabledscryer/auth/oidc.py b/fabledscryer/auth/oidc.py new file mode 100644 index 0000000..3cacf4f --- /dev/null +++ b/fabledscryer/auth/oidc.py @@ -0,0 +1,109 @@ +"""fabledscryer/auth/oidc.py + +OIDC Authorization Code flow helpers using httpx. +No extra dependencies — works with any OIDC provider (Authentik, Keycloak, etc.). + +JWT signature verification is intentionally skipped for homelab use; +we trust the HTTPS connection to the discovery-documented token/userinfo endpoints. +""" +from __future__ import annotations +import base64 +import hashlib +import json +import logging +import os +import time + +import httpx + +logger = logging.getLogger(__name__) + +# Module-level cache: {discovery_url: (fetched_at, doc)} +_discovery_cache: dict[str, tuple[float, dict]] = {} +_DISCOVERY_TTL = 300 # seconds + + +async def get_discovery(discovery_url: str) -> dict: + """Fetch and cache the OIDC provider discovery document.""" + now = time.monotonic() + if discovery_url in _discovery_cache: + fetched_at, doc = _discovery_cache[discovery_url] + if now - fetched_at < _DISCOVERY_TTL: + return doc + async with httpx.AsyncClient(timeout=10) as client: + resp = await client.get(discovery_url) + resp.raise_for_status() + doc = resp.json() + _discovery_cache[discovery_url] = (now, doc) + return doc + + +def build_authorize_url(doc: dict, client_id: str, redirect_uri: str, + scopes: str, state: str, nonce: str) -> str: + """Build the IdP authorization redirect URL.""" + import urllib.parse + params = { + "response_type": "code", + "client_id": client_id, + "redirect_uri": redirect_uri, + "scope": scopes, + "state": state, + "nonce": nonce, + } + return doc["authorization_endpoint"] + "?" + urllib.parse.urlencode(params) + + +async def exchange_code(doc: dict, client_id: str, client_secret: str, + code: str, redirect_uri: str) -> dict: + """Exchange authorization code for tokens. Returns token response dict.""" + async with httpx.AsyncClient(timeout=15) as client: + resp = await client.post( + doc["token_endpoint"], + data={ + "grant_type": "authorization_code", + "code": code, + "redirect_uri": redirect_uri, + "client_id": client_id, + "client_secret": client_secret, + }, + ) + resp.raise_for_status() + return resp.json() + + +async def get_userinfo(doc: dict, access_token: str) -> dict: + """Fetch user info from the IdP's userinfo endpoint.""" + async with httpx.AsyncClient(timeout=10) as client: + resp = await client.get( + doc["userinfo_endpoint"], + headers={"Authorization": f"Bearer {access_token}"}, + ) + resp.raise_for_status() + return resp.json() + + +def decode_id_token_payload(id_token: str) -> dict: + """Decode ID token payload without verifying signature (homelab only).""" + try: + parts = id_token.split(".") + if len(parts) < 2: + return {} + payload_b64 = parts[1] + # Add padding + payload_b64 += "=" * (4 - len(payload_b64) % 4) + return json.loads(base64.urlsafe_b64decode(payload_b64)) + except Exception: + return {} + + +def map_role(groups: list[str], admin_group: str, operator_group: str) -> str: + """Map IdP groups to a local role string.""" + if admin_group and admin_group in groups: + return "admin" + if operator_group and operator_group in groups: + return "operator" + return "viewer" + + +def generate_state() -> str: + return base64.urlsafe_b64encode(os.urandom(24)).decode().rstrip("=") diff --git a/fabledscryer/auth/routes.py b/fabledscryer/auth/routes.py index c9eb4c7..ba7d299 100644 --- a/fabledscryer/auth/routes.py +++ b/fabledscryer/auth/routes.py @@ -8,6 +8,33 @@ from fabledscryer.models.users import User, UserRole auth_bp = Blueprint("auth", __name__) +async def _provision_external_user(app, username: str, email: str, role_str: str): + """Create or update a user from an external auth provider (OIDC/LDAP). + + External users get a randomised unusable password so local login is blocked. + Their role is updated on every login to reflect current IdP group membership. + """ + import secrets + from fabledscryer.models.users import UserRole + async with app.db_sessionmaker() as db: + result = await db.execute(select(User).where(User.username == username)) + user = result.scalar_one_or_none() + new_role = UserRole(role_str) + async with db.begin(): + if user is None: + unusable_hash = bcrypt.hashpw(secrets.token_bytes(32), bcrypt.gensalt()).decode() + user = User(username=username, email=email or f"{username}@external", + password_hash=unusable_hash, role=new_role) + db.add(user) + else: + if user.role != new_role: + user.role = new_role + # Re-fetch committed user + async with app.db_sessionmaker() as db: + result = await db.execute(select(User).where(User.username == username)) + return result.scalar_one() + + async def get_user_count(app) -> int: async with app.db_sessionmaker() as db: result = await db.execute(select(func.count()).select_from(User)) @@ -25,21 +52,124 @@ async def login(): from quart import current_app if await get_user_count(current_app) == 0: return redirect(url_for("auth.setup")) - return await render_template("auth/login.html") + oidc_cfg = current_app.config.get("OIDC", {}) + return await render_template("auth/login.html", + oidc_enabled=oidc_cfg.get("enabled", False)) @auth_bp.post("/login") async def login_post(): from quart import current_app + from fabledscryer.core.audit import log_audit form = await request.form username = form.get("username", "").strip() - password = form.get("password", "").encode() + password_str = form.get("password", "") + password = password_str.encode() + + # Try local auth first user = await get_user_by_username(current_app, username) - if not user or not user.is_active: - return await render_template("auth/login.html", error="Invalid credentials"), 400 - if not bcrypt.checkpw(password, user.password_hash.encode()): - return await render_template("auth/login.html", error="Invalid credentials"), 400 + if user and user.is_active and bcrypt.checkpw(password, user.password_hash.encode()): + login_user(user) + await log_audit(current_app, user.id, user.username, "auth.login", + detail={"method": "local", "role": user.role.value}) + return redirect(url_for("dashboard.index")) + + # Try LDAP fallback if enabled + ldap_cfg = current_app.config.get("LDAP", {}) + if ldap_cfg.get("enabled"): + from fabledscryer.auth.ldap_auth import ldap_authenticate + ldap_info = await ldap_authenticate(ldap_cfg, username, password_str) + if ldap_info: + user = await _provision_external_user( + current_app, ldap_info["username"], ldap_info["email"], ldap_info["role"] + ) + login_user(user) + await log_audit(current_app, user.id, user.username, "auth.login", + detail={"method": "ldap", "role": user.role.value}) + return redirect(url_for("dashboard.index")) + + oidc_cfg = current_app.config.get("OIDC", {}) + return await render_template( + "auth/login.html", + error="Invalid credentials", + oidc_enabled=oidc_cfg.get("enabled", False), + ), 400 + + +@auth_bp.get("/login/oidc") +async def login_oidc(): + from quart import current_app + from fabledscryer.auth.oidc import get_discovery, build_authorize_url, generate_state + oidc_cfg = current_app.config.get("OIDC", {}) + if not oidc_cfg.get("enabled") or not oidc_cfg.get("discovery_url"): + return redirect(url_for("auth.login")) + try: + doc = await get_discovery(oidc_cfg["discovery_url"]) + except Exception as exc: + return await render_template("auth/login.html", error=f"OIDC discovery failed: {exc}", + oidc_enabled=True), 502 + state = generate_state() + nonce = generate_state() + session["oidc_state"] = state + session["oidc_nonce"] = nonce + redirect_uri = url_for("auth.login_oidc_callback", _external=True) + url = build_authorize_url( + doc, oidc_cfg["client_id"], redirect_uri, + oidc_cfg.get("scopes", "openid profile email"), state, nonce, + ) + return redirect(url) + + +@auth_bp.get("/login/oidc/callback") +async def login_oidc_callback(): + from quart import current_app + from fabledscryer.auth.oidc import get_discovery, exchange_code, get_userinfo, map_role + from fabledscryer.core.audit import log_audit + + oidc_cfg = current_app.config.get("OIDC", {}) + error = request.args.get("error") + if error: + desc = request.args.get("error_description", error) + return await render_template("auth/login.html", error=f"SSO error: {desc}", + oidc_enabled=True), 400 + + code = request.args.get("code", "") + state = request.args.get("state", "") + if not code or state != session.pop("oidc_state", None): + return await render_template("auth/login.html", error="Invalid SSO state — try again.", + oidc_enabled=True), 400 + + try: + doc = await get_discovery(oidc_cfg["discovery_url"]) + redirect_uri = url_for("auth.login_oidc_callback", _external=True) + tokens = await exchange_code( + doc, oidc_cfg["client_id"], oidc_cfg["client_secret"], code, redirect_uri + ) + userinfo = await get_userinfo(doc, tokens["access_token"]) + except Exception as exc: + return await render_template("auth/login.html", error=f"SSO login failed: {exc}", + oidc_enabled=True), 502 + + username_claim = oidc_cfg.get("username_claim", "preferred_username") + email_claim = oidc_cfg.get("email_claim", "email") + groups_claim = oidc_cfg.get("groups_claim", "groups") + + username = userinfo.get(username_claim) or userinfo.get("sub", "") + email = userinfo.get(email_claim, "") or f"{username}@oidc" + groups = userinfo.get(groups_claim, []) + if not isinstance(groups, list): + groups = [] + role = map_role(groups, oidc_cfg.get("admin_group", ""), oidc_cfg.get("operator_group", "")) + + if not username: + return await render_template("auth/login.html", + error="SSO returned no username.", + oidc_enabled=True), 400 + + user = await _provision_external_user(current_app, username, email, role) login_user(user) + await log_audit(current_app, user.id, user.username, "auth.login", + detail={"method": "oidc", "role": role}) return redirect(url_for("dashboard.index")) @@ -60,6 +190,7 @@ async def setup(): @auth_bp.post("/setup") async def setup_post(): from quart import current_app + from fabledscryer.core.audit import log_audit if await get_user_count(current_app) > 0: return redirect(url_for("auth.login")) form = await request.form @@ -74,4 +205,6 @@ async def setup_post(): async with db.begin(): db.add(user) login_user(user) + await log_audit(current_app, user.id, user.username, "auth.setup", + detail={"username": username}) return redirect(url_for("dashboard.index")) diff --git a/fabledscryer/core/audit.py b/fabledscryer/core/audit.py new file mode 100644 index 0000000..63ad5e2 --- /dev/null +++ b/fabledscryer/core/audit.py @@ -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) diff --git a/fabledscryer/core/settings.py b/fabledscryer/core/settings.py index 920cbbe..25a954c 100644 --- a/fabledscryer/core/settings.py +++ b/fabledscryer/core/settings.py @@ -51,6 +51,35 @@ DEFAULTS: dict[str, Any] = { "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": "", } @@ -123,6 +152,14 @@ 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 = {} diff --git a/fabledscryer/migrations/versions/0009_widget_variants.py b/fabledscryer/migrations/versions/0009_widget_variants.py new file mode 100644 index 0000000..286a3e5 --- /dev/null +++ b/fabledscryer/migrations/versions/0009_widget_variants.py @@ -0,0 +1,26 @@ +"""Widget variants — add config_json and title to dashboard_widgets + +Revision ID: 0009_widget_variants +Revises: 0008_share_tokens +Create Date: 2026-03-22 +""" +from typing import Sequence, Union +from alembic import op +import sqlalchemy as sa + +revision: str = "0009_widget_variants" +down_revision: Union[str, None] = "0008_share_tokens" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column("dashboard_widgets", + sa.Column("title", sa.String(128), nullable=True)) + op.add_column("dashboard_widgets", + sa.Column("config_json", sa.Text, nullable=True)) + + +def downgrade() -> None: + op.drop_column("dashboard_widgets", "config_json") + op.drop_column("dashboard_widgets", "title") diff --git a/fabledscryer/migrations/versions/0010_maintenance_windows.py b/fabledscryer/migrations/versions/0010_maintenance_windows.py new file mode 100644 index 0000000..309cd15 --- /dev/null +++ b/fabledscryer/migrations/versions/0010_maintenance_windows.py @@ -0,0 +1,32 @@ +"""Maintenance windows for alert suppression + +Revision ID: 0010_maintenance_windows +Revises: 0009_widget_variants +Create Date: 2026-03-22 +""" +from typing import Sequence, Union +from alembic import op +import sqlalchemy as sa + +revision: str = "0010_maintenance_windows" +down_revision: Union[str, None] = "0009_widget_variants" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "maintenance_windows", + sa.Column("id", sa.String(36), primary_key=True), + sa.Column("name", sa.String(128), nullable=False), + sa.Column("start_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("end_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("scope_source", sa.String(64), nullable=True), + sa.Column("scope_resource", sa.String(255), nullable=True), + sa.Column("created_by", sa.String(36), sa.ForeignKey("users.id", ondelete="RESTRICT"), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + ) + + +def downgrade() -> None: + op.drop_table("maintenance_windows") diff --git a/fabledscryer/migrations/versions/0011_audit_log.py b/fabledscryer/migrations/versions/0011_audit_log.py new file mode 100644 index 0000000..c3e3fdb --- /dev/null +++ b/fabledscryer/migrations/versions/0011_audit_log.py @@ -0,0 +1,35 @@ +"""Audit log table + +Revision ID: 0011_audit_log +Revises: 0010_maintenance_windows +Create Date: 2026-03-22 +""" +from typing import Sequence, Union +from alembic import op +import sqlalchemy as sa + +revision: str = "0011_audit_log" +down_revision: Union[str, None] = "0010_maintenance_windows" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "audit_events", + sa.Column("id", sa.String(36), primary_key=True), + sa.Column("timestamp", sa.DateTime(timezone=True), nullable=False), + sa.Column("user_id", sa.String(36), + sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), + sa.Column("username", sa.String(64), nullable=False, server_default="system"), + sa.Column("action", sa.String(64), nullable=False), + sa.Column("entity_type", sa.String(64), nullable=True), + sa.Column("entity_id", sa.String(255), nullable=True), + sa.Column("detail_json", sa.Text, nullable=True), + ) + op.create_index("ix_audit_events_timestamp", "audit_events", ["timestamp"]) + + +def downgrade() -> None: + op.drop_index("ix_audit_events_timestamp", "audit_events") + op.drop_table("audit_events") diff --git a/fabledscryer/models/audit.py b/fabledscryer/models/audit.py new file mode 100644 index 0000000..2d53d50 --- /dev/null +++ b/fabledscryer/models/audit.py @@ -0,0 +1,24 @@ +from __future__ import annotations +import uuid +from datetime import datetime, timezone +from sqlalchemy import DateTime, ForeignKey, String, Text +from sqlalchemy.orm import Mapped, mapped_column +from .base import Base + + +class AuditEvent(Base): + __tablename__ = "audit_events" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + timestamp: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc) + ) + user_id: Mapped[str | None] = mapped_column( + String(36), ForeignKey("users.id", ondelete="SET NULL"), nullable=True + ) + # Denormalized so display survives user deletion + username: Mapped[str] = mapped_column(String(64), nullable=False, default="system") + action: Mapped[str] = mapped_column(String(64), nullable=False) + entity_type: Mapped[str | None] = mapped_column(String(64), nullable=True) + entity_id: Mapped[str | None] = mapped_column(String(255), nullable=True) + detail_json: Mapped[str | None] = mapped_column(Text, nullable=True) diff --git a/fabledscryer/templates/audit/list.html b/fabledscryer/templates/audit/list.html new file mode 100644 index 0000000..693b467 --- /dev/null +++ b/fabledscryer/templates/audit/list.html @@ -0,0 +1,78 @@ +{% extends "base.html" %} +{% block title %}Audit Log — Fabled Scryer{% endblock %} +{% block content %} +
+

Audit Log

+ + + + {% if action_filter %} + Clear + {% endif %} + +
+ +{% if events %} +
+ + + + + + + + + + + + {% for item in events %} + {% set e = item.event %} + {% set d = item.detail %} + + + + + + + + {% endfor %} + +
Time (UTC)UserActionEntityDetail
+ {{ e.timestamp.strftime("%Y-%m-%d %H:%M:%S") }} + {{ e.username }} + {% set cat = e.action.split('.')[0] %} + + {{ e.action }} + + + {% if e.entity_type %} + {{ e.entity_type }}/{{ e.entity_id or "" }} + {% endif %} + + {% if d %} + {% for k, v in d.items() %} + {{ k }}: + {{ v }} + {% if not loop.last %}  ·  {% endif %} + {% endfor %} + {% endif %} +
+
+

+ Showing last {{ limit }} events. + {% if events | length == limit %} + Load more + {% endif %} +

+{% else %} +
+

No audit events recorded yet.

+
+{% endif %} +{% endblock %} diff --git a/fabledscryer/templates/auth/login.html b/fabledscryer/templates/auth/login.html index 9c8f9a9..55be0e7 100644 --- a/fabledscryer/templates/auth/login.html +++ b/fabledscryer/templates/auth/login.html @@ -4,6 +4,22 @@

Sign In

+ {% if error %} +
+ {{ error }} +
+ {% endif %} + {% if oidc_enabled %} + + Sign in with SSO + +
+
+ or +
+
+ {% endif %}
@@ -13,7 +29,9 @@
- +
diff --git a/fabledscryer/templates/settings/auth.html b/fabledscryer/templates/settings/auth.html new file mode 100644 index 0000000..a40d7d0 --- /dev/null +++ b/fabledscryer/templates/settings/auth.html @@ -0,0 +1,178 @@ +{# fabledscryer/templates/settings/auth.html #} +{% extends "base.html" %} +{% block title %}Settings — Auth — Fabled Scryer{% endblock %} +{% block content %} +{% set active_tab = "auth" %} +{% include "settings/_tabs.html" %} + +
+
+ + {# ── OIDC ─────────────────────────────────────────────────────────────────── #} +
+

OIDC / SSO

+

+ Supports any OIDC provider — Authentik, Keycloak, Authelia, etc. Uses Authorization Code flow. + Local admin login always works regardless of OIDC status. +

+ +
+ + +
+ +
+ + +

+ Authentik: https://<host>/application/o/<slug>/.well-known/openid-configuration +

+
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + +

Members → admin role

+
+
+ + +

Members → operator role; others → viewer

+
+
+
+ + {# ── LDAP ─────────────────────────────────────────────────────────────────── #} +
+

LDAP

+

+ Binds user credentials against an LDAP/AD directory. Requires the + ldap3 Python package (pip install ldap3). + If both LDAP and OIDC are enabled, the login form tries LDAP; OIDC uses the SSO button. +

+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +

+ {username} is replaced with the login username. AD example: (sAMAccountName={username}) +

+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+
+ +
+
+ + + Changes take effect immediately. + +
+
+{% endblock %} diff --git a/pyproject.toml b/pyproject.toml index 2d97477..288784a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,6 +20,12 @@ dependencies = [ ] [project.optional-dependencies] +ldap = [ + "ldap3>=2.9", +] +snmp = [ + "pysnmp-lextudio>=6.2", +] dev = [ "pytest>=8.0", "pytest-asyncio>=0.23", From ced1eebe1d8f1e60ea2d4010373e5d07cac828b9 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 23 Mar 2026 08:15:30 -0400 Subject: [PATCH 03/46] feat: maintenance windows, reports, uptime widget, alert improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add maintenance window scheduling to suppress alerts during planned downtime (model, routes, templates) - Add weekly email/webhook reports with host summary (core/reports.py, Settings → Reports tab) - Add host uptime history widget with per-check sparkline view - Expand alert rules: dynamic metric catalog for plugin sources, per-rule HTMX field loading, maintenance window awareness - Register additional dashboard widgets (uptime, SNMP, UniFi, UPS) - Add Settings → Reports tab configuration Co-Authored-By: Claude Sonnet 4.6 --- fabledscryer/app.py | 18 +- fabledscryer/core/alerts.py | 23 +- fabledscryer/core/reports.py | 275 ++++++++++++++++++ fabledscryer/core/widgets.py | 266 +++++++++++++++-- fabledscryer/hosts/routes.py | 92 +++++- fabledscryer/models/alerts.py | 18 ++ fabledscryer/models/dashboard.py | 4 +- .../templates/alerts/_rule_fields.html | 29 ++ fabledscryer/templates/alerts/list.html | 34 ++- .../templates/alerts/maintenance.html | 76 +++++ .../templates/alerts/maintenance_form.html | 86 ++++++ fabledscryer/templates/alerts/rules_form.html | 180 +++++++++--- fabledscryer/templates/hosts/list.html | 27 +- fabledscryer/templates/hosts/uptime.html | 104 +++++++ .../templates/hosts/uptime_widget.html | 45 +++ fabledscryer/templates/settings/_tabs.html | 2 + fabledscryer/templates/settings/reports.html | 91 ++++++ 17 files changed, 1286 insertions(+), 84 deletions(-) create mode 100644 fabledscryer/core/reports.py create mode 100644 fabledscryer/templates/alerts/_rule_fields.html create mode 100644 fabledscryer/templates/alerts/maintenance.html create mode 100644 fabledscryer/templates/alerts/maintenance_form.html create mode 100644 fabledscryer/templates/hosts/uptime.html create mode 100644 fabledscryer/templates/hosts/uptime_widget.html create mode 100644 fabledscryer/templates/settings/reports.html diff --git a/fabledscryer/app.py b/fabledscryer/app.py index 5eb92a2..30c5534 100644 --- a/fabledscryer/app.py +++ b/fabledscryer/app.py @@ -49,7 +49,7 @@ def create_app( if not testing: from .core.settings import ( load_settings_sync, to_smtp_cfg, to_webhook_cfg, - to_ansible_cfg, to_plugins_cfg, + to_ansible_cfg, to_plugins_cfg, to_oidc_cfg, to_ldap_cfg, ) _settings = load_settings_sync(app.config["DATABASE_URL"]) app.config.update( @@ -60,6 +60,8 @@ def create_app( WEBHOOK=to_webhook_cfg(_settings), ANSIBLE=to_ansible_cfg(_settings), PLUGINS=to_plugins_cfg(_settings), + OIDC=to_oidc_cfg(_settings), + LDAP=to_ldap_cfg(_settings), ) else: app.config.update( @@ -70,6 +72,8 @@ def create_app( WEBHOOK={}, ANSIBLE={}, PLUGINS={}, + OIDC={"enabled": False}, + LDAP={"enabled": False}, ) # ── 5. Plugin migrations ─────────────────────────────────────────────────── @@ -95,6 +99,7 @@ def create_app( from .alerts.routes import alerts_bp from .ansible.routes import ansible_bp from .settings.routes import settings_bp + from .audit.routes import audit_bp app.register_blueprint(auth_bp) app.register_blueprint(dashboard_bp) @@ -104,6 +109,7 @@ def create_app( app.register_blueprint(alerts_bp) app.register_blueprint(ansible_bp) app.register_blueprint(settings_bp) + app.register_blueprint(audit_bp) # ── 8. Build task registry ───────────────────────────────────────────────── app._task_registry = [] @@ -199,7 +205,17 @@ def _register_core_tasks(app: Quart) -> None: from .core.cleanup import run_cleanup as _cleanup await _cleanup(app) + async def run_report_check(): + from .core.reports import check_and_send + await check_and_send(app) + app._task_registry.extend([ + ScheduledTask( + name="weekly_report_check", + coro_factory=run_report_check, + interval_seconds=3600, + run_on_startup=False, + ), ScheduledTask( name="ping_monitor", coro_factory=run_ping_monitors, diff --git a/fabledscryer/core/alerts.py b/fabledscryer/core/alerts.py index a003bfa..0cc3dcd 100644 --- a/fabledscryer/core/alerts.py +++ b/fabledscryer/core/alerts.py @@ -5,7 +5,7 @@ import uuid from datetime import datetime, timezone from typing import TYPE_CHECKING -from sqlalchemy import select +from sqlalchemy import and_, or_, select from sqlalchemy.ext.asyncio import AsyncSession from fabledscryer.models.alerts import ( @@ -14,6 +14,7 @@ from fabledscryer.models.alerts import ( AlertRule, AlertState, AlertStateEnum, + MaintenanceWindow, ) from fabledscryer.models.metrics import PluginMetric @@ -60,6 +61,26 @@ async def record_metric( 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( diff --git a/fabledscryer/core/reports.py b/fabledscryer/core/reports.py new file mode 100644 index 0000000..4efe5ea --- /dev/null +++ b/fabledscryer/core/reports.py @@ -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) diff --git a/fabledscryer/core/widgets.py b/fabledscryer/core/widgets.py index b66e178..daf9adc 100644 --- a/fabledscryer/core/widgets.py +++ b/fabledscryer/core/widgets.py @@ -1,14 +1,14 @@ # 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. +# Central widget registry. Each entry describes one widget type that can be +# placed on a dashboard. Plugin widgets declare which plugin must be enabled. # -# 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(). +# "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] = { @@ -18,8 +18,19 @@ WIDGET_REGISTRY: dict[str, dict] = { "description": "Live ping status and latency history for all monitored hosts", "hx_url": "/ping/rows", "detail_url": "/ping/", - "plugin": None, # built-in; always available + "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", @@ -29,39 +40,250 @@ WIDGET_REGISTRY: dict[str, dict] = { "detail_url": "/dns/", "plugin": None, "poll": True, + "params": [], }, - "traefik": { - "key": "traefik", - "label": "Traefik", - "description": "Proxy request rates, service health, and recent errors", + "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": [], }, - "unifi": { - "key": "unifi", - "label": "UniFi Network", - "description": "WAN status, client counts, active alarms", + "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": [], }, - "ups": { - "key": "ups", - "label": "UPS", - "description": "Battery charge, runtime estimate, load percentage", + "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 in step 9).""" + """Register a widget at runtime (used by external plugins).""" WIDGET_REGISTRY[definition["key"]] = definition diff --git a/fabledscryer/hosts/routes.py b/fabledscryer/hosts/routes.py index c4e3b78..fdb5dc2 100644 --- a/fabledscryer/hosts/routes.py +++ b/fabledscryer/hosts/routes.py @@ -1,14 +1,62 @@ from __future__ import annotations -from quart import Blueprint, render_template, request, redirect, url_for, current_app -from sqlalchemy import select, func +from datetime import datetime, timedelta, timezone +from quart import Blueprint, render_template, request, redirect, url_for, current_app, session +from sqlalchemy import and_, case, select, func from fabledscryer.auth.middleware import require_role +from fabledscryer.core.audit import log_audit from fabledscryer.models.hosts import Host, ProbeType -from fabledscryer.models.monitors import PingResult, DnsResult +from fabledscryer.models.monitors import PingResult, DnsResult, PingStatus from fabledscryer.models.users import UserRole hosts_bp = Blueprint("hosts", __name__, url_prefix="/hosts") +async def _compute_uptime(db) -> dict[str, dict]: + """Return per-host uptime % for 24h, 7d, 30d windows. + + Returns {host_id: {"24h": float|None, "7d": float|None, "30d": float|None}}. + """ + now = datetime.now(timezone.utc) + cutoff_30d = now - timedelta(days=30) + cutoff_7d = now - timedelta(days=7) + cutoff_24h = now - timedelta(hours=24) + + result = await db.execute( + select( + PingResult.host_id, + # 30d window — all rows in query qualify + func.count(PingResult.id).label("total_30d"), + func.sum(case((PingResult.status == PingStatus.up, 1), else_=0)).label("up_30d"), + # 7d sub-window + func.sum(case((PingResult.probed_at >= cutoff_7d, 1), else_=0)).label("total_7d"), + func.sum(case( + (and_(PingResult.probed_at >= cutoff_7d, PingResult.status == PingStatus.up), 1), + else_=0, + )).label("up_7d"), + # 24h sub-window + func.sum(case((PingResult.probed_at >= cutoff_24h, 1), else_=0)).label("total_24h"), + func.sum(case( + (and_(PingResult.probed_at >= cutoff_24h, PingResult.status == PingStatus.up), 1), + else_=0, + )).label("up_24h"), + ) + .where(PingResult.probed_at >= cutoff_30d) + .group_by(PingResult.host_id) + ) + + def _pct(up, total): + return round(float(up) / float(total) * 100, 2) if total else None + + stats: dict[str, dict] = {} + for row in result: + stats[row.host_id] = { + "24h": _pct(row.up_24h, row.total_24h), + "7d": _pct(row.up_7d, row.total_7d), + "30d": _pct(row.up_30d, row.total_30d), + } + return stats + + @hosts_bp.get("/") @require_role(UserRole.viewer) async def list_hosts(): @@ -45,12 +93,14 @@ async def list_hosts(): ) ) latest_dns = {r.host_id: r for r in dr.scalars()} + uptime = await _compute_uptime(db) return await render_template( "hosts/list.html", hosts=hosts, latest_pings=latest_pings, latest_dns=latest_dns, + uptime=uptime, ) @@ -77,6 +127,9 @@ async def create_host(): async with current_app.db_sessionmaker() as db: async with db.begin(): db.add(host) + await log_audit(current_app, session.get("user_id"), session.get("username", ""), + "host.created", entity_type="host", entity_id=host.name, + detail={"address": host.address}) return redirect(url_for("hosts.list_hosts")) @@ -115,10 +168,43 @@ async def update_host(host_id: str): @hosts_bp.post("//delete") @require_role(UserRole.admin) async def delete_host(host_id: str): + host_name = None async with current_app.db_sessionmaker() as db: async with db.begin(): result = await db.execute(select(Host).where(Host.id == host_id)) host = result.scalar_one_or_none() if host: + host_name = host.name await db.delete(host) + if host_name: + await log_audit(current_app, session.get("user_id"), session.get("username", ""), + "host.deleted", entity_type="host", entity_id=host_name) return redirect(url_for("hosts.list_hosts")) + + +@hosts_bp.get("/uptime") +@require_role(UserRole.viewer) +async def uptime_page(): + async with current_app.db_sessionmaker() as db: + result = await db.execute(select(Host).order_by(Host.name)) + hosts = result.scalars().all() + uptime = await _compute_uptime(db) + return await render_template("hosts/uptime.html", hosts=hosts, uptime=uptime) + + +@hosts_bp.get("/uptime/widget") +@require_role(UserRole.viewer) +async def uptime_widget(): + share_token = request.args.get("s") + async with current_app.db_sessionmaker() as db: + result = await db.execute( + select(Host).where(Host.ping_enabled.is_(True)).order_by(Host.name) + ) + hosts = result.scalars().all() + uptime = await _compute_uptime(db) + return await render_template( + "hosts/uptime_widget.html", + hosts=hosts, + uptime=uptime, + share_token=share_token, + ) diff --git a/fabledscryer/models/alerts.py b/fabledscryer/models/alerts.py index fcb061a..88e85e6 100644 --- a/fabledscryer/models/alerts.py +++ b/fabledscryer/models/alerts.py @@ -62,6 +62,24 @@ class AlertState(Base): acknowledged_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) +class MaintenanceWindow(Base): + __tablename__ = "maintenance_windows" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + name: Mapped[str] = mapped_column(String(128), nullable=False) + start_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + end_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + # Scope — null means "all". scope_resource requires scope_source to be set. + scope_source: Mapped[str | None] = mapped_column(String(64), nullable=True) + scope_resource: Mapped[str | None] = mapped_column(String(255), nullable=True) + created_by: Mapped[str] = mapped_column( + String(36), ForeignKey("users.id", ondelete="RESTRICT"), nullable=False + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc) + ) + + class AlertEvent(Base): __tablename__ = "alert_events" diff --git a/fabledscryer/models/dashboard.py b/fabledscryer/models/dashboard.py index c334dfa..34368c1 100644 --- a/fabledscryer/models/dashboard.py +++ b/fabledscryer/models/dashboard.py @@ -1,6 +1,6 @@ from __future__ import annotations from datetime import datetime, timezone -from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String +from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text from sqlalchemy.orm import Mapped, mapped_column from .base import Base @@ -51,3 +51,5 @@ class DashboardWidget(Base): ) widget_key: Mapped[str] = mapped_column(String(64), nullable=False) position: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + title: Mapped[str | None] = mapped_column(String(128), nullable=True) + config_json: Mapped[str | None] = mapped_column(Text, nullable=True) diff --git a/fabledscryer/templates/alerts/_rule_fields.html b/fabledscryer/templates/alerts/_rule_fields.html new file mode 100644 index 0000000..b45ba49 --- /dev/null +++ b/fabledscryer/templates/alerts/_rule_fields.html @@ -0,0 +1,29 @@ +{# alerts/_rule_fields.html — HTMX partial: resource + metric selects for a given source_module #} +
+ + {% if resources %} + + {% else %} + +

+ Resource names populate from live metric data. Start the scheduler and try again. +

+ {% endif %} +
+ +
+ + +
diff --git a/fabledscryer/templates/alerts/list.html b/fabledscryer/templates/alerts/list.html index be058bc..99385df 100644 --- a/fabledscryer/templates/alerts/list.html +++ b/fabledscryer/templates/alerts/list.html @@ -49,7 +49,10 @@

Rules

- New Rule +
{% if rules %}
@@ -58,24 +61,33 @@ Name Condition - Consec. - Enabled + Consec. {% for rule in rules %} - - {{ rule.name }} - - {{ rule.source_module }}/{{ rule.resource_name }}:{{ rule.metric_name }} - {{ rule.operator.value }} {{ rule.threshold }} - - {{ rule.consecutive_failures_required }} + - {% if rule.enabled %}yes{% else %}no{% endif %} + {{ rule.name }} + {% if not rule.enabled %} + disabled + {% endif %} + + {{ rule.source_module }}/{{ rule.resource_name }} + + :{{ rule.metric_name }} {{ rule.operator.value }} {{ rule.threshold }} + + + {{ rule.consecutive_failures_required }} + Edit +
+ +
diff --git a/fabledscryer/templates/alerts/maintenance.html b/fabledscryer/templates/alerts/maintenance.html new file mode 100644 index 0000000..3fc5484 --- /dev/null +++ b/fabledscryer/templates/alerts/maintenance.html @@ -0,0 +1,76 @@ +{% extends "base.html" %} +{% block title %}Maintenance Windows — Fabled Scryer{% endblock %} +{% block content %} +
+

Maintenance Windows

+ +
+ +

+ During an active window, alert rule evaluation is suppressed for the matching scope. + Metrics are still recorded — only notifications are silenced. +

+ +{% if windows %} +
+ + + + + + + + + + + + + {% for w in windows %} + {% set is_active = w.start_at <= now and w.end_at >= now %} + {% set is_upcoming = w.start_at > now %} + + + + + + + + + {% endfor %} + +
NameScopeStartEndStatus
{{ w.name }} + {% if w.scope_source is none %} + All alerts + {% elif w.scope_resource is none %} + {{ w.scope_source }}/* + {% else %} + {{ w.scope_source }}/{{ w.scope_resource }} + {% endif %} + + {{ w.start_at.strftime("%Y-%m-%d %H:%M") }} UTC + + {{ w.end_at.strftime("%Y-%m-%d %H:%M") }} UTC + + {% if is_active %} + active + {% elif is_upcoming %} + upcoming + {% else %} + expired + {% endif %} + + + + +
+
+{% else %} +
+

No maintenance windows configured.

+
+{% endif %} +{% endblock %} diff --git a/fabledscryer/templates/alerts/maintenance_form.html b/fabledscryer/templates/alerts/maintenance_form.html new file mode 100644 index 0000000..961a127 --- /dev/null +++ b/fabledscryer/templates/alerts/maintenance_form.html @@ -0,0 +1,86 @@ +{% extends "base.html" %} +{% block title %}New Maintenance Window — Fabled Scryer{% endblock %} +{% block content %} +
+

New Maintenance Window

+
+
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ + {# ── Scope ─────────────────────────────────────────────────────────────── #} +
+ + +
+ + + + + +
+ + Cancel +
+
+
+ +
+
How scope works
+
+
All alerts — every alert rule is suppressed
+
By source — e.g. source=ping suppresses all ping alerts
+
By source + resource — e.g. ping/my-server suppresses only that host's ping alerts
+
+
+
+ + +{% endblock %} diff --git a/fabledscryer/templates/alerts/rules_form.html b/fabledscryer/templates/alerts/rules_form.html index 8ead9dc..0352998 100644 --- a/fabledscryer/templates/alerts/rules_form.html +++ b/fabledscryer/templates/alerts/rules_form.html @@ -1,49 +1,141 @@ {% extends "base.html" %} -{% block title %}New Alert Rule — Fabled Scryer{% endblock %} +{% block title %}{% if rule %}Edit Rule{% else %}New Alert Rule{% endif %} — Fabled Scryer{% endblock %} {% block content %} -
-

New Alert Rule

-
-
-
- - +
+

{% if rule %}Edit Rule{% else %}New Alert Rule{% endif %}

+
+ + +
+ + +
+ + {# ── Source module ───────────────────────────────────────────────────── #} +
+ + +
+ + {# ── Dynamic resource + metric ────────────────────────────────────────── #} +
+ {# Inline the same partial logic so the page is fully usable without JS #} +
+ + {% if resources %} + + {% else %} + +

+ Resource names populate from live metric data. Start the scheduler and try again. +

+ {% endif %} +
+ +
+ + +
+
+ + {# ── Condition ───────────────────────────────────────────────────────── #} +
+
+ + +
+
+ + +
+
+ +
+ + +
+ + {# ── Metric reference ────────────────────────────────────────────────── #} +
+ + Metric reference + +
+ {% set descs = { + "ping": [("up", "1.0 = reachable, 0.0 = unreachable"), + ("response_time_ms", "round-trip latency in milliseconds")], + "dns": [("resolved", "1.0 = resolved, 0.0 = failed"), + ("ip_changed", "1.0 when resolved IP differs from expected")], + "traefik": [("request_rate", "requests/sec for this router"), + ("error_rate", "fraction of 5xx responses (0–1)"), + ("latency_p50_ms", "median response time ms"), + ("latency_p95_ms", "p95 response time ms"), + ("latency_p99_ms", "p99 response time ms"), + ("response_bytes_rate", "bytes/sec of responses"), + ("cert_expiry_days", "days until TLS cert expires")], + "unifi": [("is_up", "1.0 = WAN up, 0.0 = down"), + ("latency_ms", "WAN latency in ms"), + ("total_clients", "number of connected clients")], + "ups": [("battery_charge_pct", "battery charge 0–100"), + ("on_battery", "1.0 when running on battery"), + ("battery_runtime_secs", "estimated runtime remaining in seconds")], + "docker": [("cpu_pct", "container CPU usage %"), + ("mem_pct", "container memory usage %")], + "http": [("is_up", "1.0 = check passed, 0.0 = failed"), + ("response_ms", "HTTP response time in ms")], + } %} + {% for src, pairs in descs.items() %} +
+
{{ src }}
+ {% for metric, desc in pairs %} +
+ {{ metric }} + {{ desc }}
-
- - -
-
- - -
-
- - -
-
-
- - -
-
- - -
-
-
- - -
-
- - Cancel -
- -
+ {% endfor %} +
+ {% endfor %} +
+ + +
+ + Cancel +
+ +
{% endblock %} diff --git a/fabledscryer/templates/hosts/list.html b/fabledscryer/templates/hosts/list.html index 9bf73a6..d52b998 100644 --- a/fabledscryer/templates/hosts/list.html +++ b/fabledscryer/templates/hosts/list.html @@ -3,7 +3,10 @@ {% block content %}

Hosts

- Add Host +
+ SLA + Add Host +
{% if hosts %}
@@ -16,6 +19,9 @@ Ping Latency DNS + 24h + 7d + 30d @@ -23,6 +29,7 @@ {% for host in hosts %} {% set ping = latest_pings.get(host.id) %} {% set dns = latest_dns.get(host.id) %} + {% set ut = uptime.get(host.id, {}) %} {{ host.name }} {{ host.address }} @@ -64,6 +71,24 @@ {% endif %} + {% for window in ["24h", "7d", "30d"] %} + {% set pct = ut.get(window) %} + + {% if not host.ping_enabled %} + + {% elif pct is none %} + + {% elif pct >= 99.9 %} + {{ "%.1f"|format(pct) }}% + {% elif pct >= 99 %} + {{ "%.1f"|format(pct) }}% + {% elif pct >= 95 %} + {{ "%.1f"|format(pct) }}% + {% else %} + {{ "%.1f"|format(pct) }}% + {% endif %} + + {% endfor %} Edit
diff --git a/fabledscryer/templates/hosts/uptime.html b/fabledscryer/templates/hosts/uptime.html new file mode 100644 index 0000000..553a2c5 --- /dev/null +++ b/fabledscryer/templates/hosts/uptime.html @@ -0,0 +1,104 @@ +{% extends "base.html" %} +{% block title %}Uptime / SLA — Fabled Scryer{% endblock %} +{% block content %} +
+

Uptime / SLA

+ ← Hosts +
+ +{# ── Summary pills ──────────────────────────────────────────────────────────── #} +{% set ping_hosts = hosts | selectattr("ping_enabled") | list %} +{% set total = ping_hosts | length %} +{% if total %} +{% set full_30d = ping_hosts | selectattr("id", "in", uptime.keys()) + | selectattr("id", "defined") | list %} +{% set healthy = namespace(count=0) %} +{% for h in ping_hosts %} + {% set pct = uptime.get(h.id, {}).get("30d") %} + {% if pct is not none and pct >= 99 %}{% set healthy.count = healthy.count + 1 %}{% endif %} +{% endfor %} + +
+
+
Hosts monitored
+ {{ total }} +
+
+
≥ 99% (30d)
+ + {{ healthy.count }} + +
+
+{% endif %} + +{# ── SLA table ──────────────────────────────────────────────────────────────── #} +{% if hosts %} +
+ + + + + + + + + + + + + {% for host in hosts %} + {% set ut = uptime.get(host.id, {}) %} + + + + + {% for window in ["24h", "7d", "30d"] %} + {% set pct = ut.get(window) %} + + {% endfor %} + + {# 30-day visual bar #} + {% set pct30 = ut.get("30d") %} + + + {% endfor %} + +
HostAddress24 h7 d30 d30-day bar
{{ host.name }}{{ host.address }} + {% if not host.ping_enabled %} + + {% elif pct is none %} + + {% elif pct >= 99.9 %} + {{ "%.2f"|format(pct) }}% + {% elif pct >= 99 %} + {{ "%.2f"|format(pct) }}% + {% elif pct >= 95 %} + {{ "%.2f"|format(pct) }}% + {% else %} + {{ "%.2f"|format(pct) }}% + {% endif %} + + {% if not host.ping_enabled %} + ping off + {% elif pct30 is none %} + no data + {% else %} +
+
+
+
+
+ {% endif %} +
+
+{% else %} +
+

No hosts configured. Add one.

+
+{% endif %} + +

+ Uptime is computed from ping probe results only. Hosts with ping disabled show —. +

+{% endblock %} diff --git a/fabledscryer/templates/hosts/uptime_widget.html b/fabledscryer/templates/hosts/uptime_widget.html new file mode 100644 index 0000000..0f4df37 --- /dev/null +++ b/fabledscryer/templates/hosts/uptime_widget.html @@ -0,0 +1,45 @@ +{# hosts/uptime_widget.html — dashboard widget: SLA uptime summary #} +{% if not hosts %} +
+ No ping-enabled hosts configured. +
+{% else %} + +
+
+ Host + 24h + 7d + 30d +
+ {% for host in hosts %} + {% set ut = uptime.get(host.id, {}) %} +
+ {{ host.name }} + {% for window in ["24h", "7d", "30d"] %} + {% set pct = ut.get(window) %} + + {% if pct is none %} + + {% elif pct >= 99.9 %} + {{ "%.1f"|format(pct) }}% + {% elif pct >= 99 %} + {{ "%.1f"|format(pct) }}% + {% elif pct >= 95 %} + {{ "%.1f"|format(pct) }}% + {% else %} + {{ "%.1f"|format(pct) }}% + {% endif %} + + {% endfor %} +
+ {% endfor %} +
+ +{% if share_token %} + +{% endif %} + +{% endif %} diff --git a/fabledscryer/templates/settings/_tabs.html b/fabledscryer/templates/settings/_tabs.html index c73a70a..252391e 100644 --- a/fabledscryer/templates/settings/_tabs.html +++ b/fabledscryer/templates/settings/_tabs.html @@ -4,6 +4,8 @@ {% set tabs = [ ("general", "General", "/settings/general/"), ("notifications", "Notifications", "/settings/notifications/"), + ("reports", "Reports", "/settings/reports/"), + ("auth", "Auth", "/settings/auth/"), ("ansible", "Ansible", "/settings/ansible/"), ("plugins", "Plugins", "/settings/plugins/"), ] %} diff --git a/fabledscryer/templates/settings/reports.html b/fabledscryer/templates/settings/reports.html new file mode 100644 index 0000000..7615a6c --- /dev/null +++ b/fabledscryer/templates/settings/reports.html @@ -0,0 +1,91 @@ +{# fabledscryer/templates/settings/reports.html #} +{% extends "base.html" %} +{% block title %}Settings — Reports — Fabled Scryer{% endblock %} +{% block content %} +{% set active_tab = "reports" %} +{% include "settings/_tabs.html" %} + +{% if flash %} +
+ {{ flash.message }} +
+{% endif %} + +
+ + {# ── Schedule ─────────────────────────────────────────────────────────────── #} + +
+

Weekly Digest

+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +

+ Requires SMTP configured in Notifications. + {% if settings['reports.last_sent_at'] %} + Last sent: {{ settings['reports.last_sent_at'][:16] }} UTC. + {% endif %} +

+ +
+ +
+
+ + + {# ── Report contents ─────────────────────────────────────────────────────── #} +
+

Report contents

+
+
Uptime summary — 7-day % per ping-enabled host
+
Hosts currently down — highlighted at top if any
+
Active alerts — firing, acknowledged, pending rules
+
Alert events (7d) — fired and resolved counts
+
Top Traefik routers — avg req/s over last 24h (if plugin enabled)
+
+
+ + {# ── Send now ─────────────────────────────────────────────────────────────── #} +
+

Send Now

+

+ Send a report immediately regardless of schedule. Useful for testing. +

+
+ +
+
+ +
+{% endblock %} From 93d5ed8fb0f6b42147d6fc278c14295cb6838631 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 23 Mar 2026 08:15:48 -0400 Subject: [PATCH 04/46] feat: managed NUT setup, plugin config improvements, migration fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add managed NUT mode: configure NUT daemon from Settings → Plugins (UPS plugin), writes /data/nut_managed.json read by entrypoint on restart — no env vars or USB passthrough required - entrypoint.sh: start NUT from /data/nut_managed.json or NUT_MANAGED=1 env var; drop to app user via gosu after NUT daemons start - nut_setup.py: support --from-file in addition to env vars - Dockerfile: add nut, nut-client, gosu packages and entrypoint - docker-compose.yml: document optional NUT_MANAGED env var block - Fix plugin hot-reload migration failure: pass all plugin migration dirs to Alembic so previously-stamped revisions from other plugins remain resolvable (fixes UPS and any plugin with depends_on) - Fix plugin list-type config (e.g. SNMP devices) rendering as broken text input — now shows a read-only note to edit plugin.yaml instead - Fix _sync_nut_managed_config: only write JSON when nut_ups_host is non-empty, preventing NUT startup loop on container restart Co-Authored-By: Claude Sonnet 4.6 --- Dockerfile | 18 ++- docker-compose.yml | 13 ++ entrypoint.sh | 45 ++++++ fabledscryer/core/plugin_manager.py | 104 +++++++++++-- fabledscryer/nut_setup.py | 121 +++++++++++++++ fabledscryer/settings/routes.py | 152 ++++++++++++++++++- fabledscryer/templates/settings/plugins.html | 12 +- 7 files changed, 444 insertions(+), 21 deletions(-) create mode 100644 entrypoint.sh create mode 100644 fabledscryer/nut_setup.py diff --git a/Dockerfile b/Dockerfile index 488a646..0d48243 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,13 @@ FROM python:3.13-slim RUN DEBIAN_FRONTEND=noninteractive apt-get update \ - && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends iputils-ping \ + && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + iputils-ping \ + # NUT (Network UPS Tools) — only used when NUT_MANAGED=1 + nut \ + nut-client \ + # gosu — minimal privilege-drop tool used by entrypoint.sh + gosu \ && rm -rf /var/lib/apt/lists/* # Upgrade pip to suppress the version notice @@ -14,14 +20,18 @@ COPY fabledscryer/ fabledscryer/ RUN pip install --no-cache-dir . COPY alembic.ini . +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh -# Runtime directories — owned by app user +# Runtime directories — owned by app user. +# The container runs as root so the entrypoint can start NUT daemons (USB +# access requires root); it drops to 'app' (uid 1000) via gosu before +# launching fabledscryer. RUN useradd -m -u 1000 app \ && mkdir -p /data/playbook_cache /data/plugins \ && chown -R app:app /data /app -USER app - EXPOSE 5000 +ENTRYPOINT ["/entrypoint.sh"] CMD ["fabledscryer", "--host", "0.0.0.0", "--port", "5000"] diff --git a/docker-compose.yml b/docker-compose.yml index 0149817..27782d6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,6 +10,19 @@ services: - /mnt/Data/traefik/log:/var/log/traefik:ro environment: - FABLEDSCRYER_DATABASE_URL=postgresql+asyncpg://fabledscryer:fabledscryer@db/fabledscryer + # ── Managed NUT (optional) ─────────────────────────────────────────────── + # Set NUT_MANAGED=1 to run NUT inside this container and connect to your + # UPS over the network (SNMP or XML/HTTP). No USB or special privileges + # required — just network access to the UPS management card. + # + # - NUT_MANAGED=1 + # - NUT_UPS_HOST=192.168.1.x # IP/hostname of UPS management card (required) + # - NUT_DRIVER=snmp-ups # snmp-ups (default) or netxml-ups for Eaton + # - NUT_UPS_NAME=ups # must match 'ups_name' in UPS plugin settings + # - NUT_SNMP_COMMUNITY=public # SNMP community string (snmp-ups only) + # - NUT_SNMP_VERSION=v1 # v1 or v2c (snmp-ups only) + # - NUT_USERNAME= # upsd auth username (blank = no auth) + # - NUT_PASSWORD= # upsd auth password depends_on: db: condition: service_healthy diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..bf10c85 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,45 @@ +#!/bin/sh +# FabledScryer container entrypoint. +# +# When NUT_MANAGED=1: +# - Generates /etc/nut/*.conf from environment variables +# - Starts NUT driver (upsdrvctl) and network daemon (upsd) +# - Connects to the UPS over the network — no USB passthrough required +# - Then drops to 'app' user via gosu to run fabledscryer +# +# When NUT_MANAGED=0 (default): +# - Drops directly to 'app' user and runs the command +# +# Required env vars when NUT_MANAGED=1: +# NUT_UPS_HOST IP or hostname of the UPS management card (required) +# NUT_DRIVER NUT driver — snmp-ups (default) or netxml-ups for Eaton +# NUT_UPS_NAME UPS name in NUT (default: ups) — must match UPS plugin setting +# NUT_SNMP_COMMUNITY SNMP community string (default: public) [snmp-ups only] +# NUT_SNMP_VERSION SNMP version: v1, v2c (default: v1) [snmp-ups only] +# NUT_USERNAME Optional upsd auth username +# NUT_PASSWORD Optional upsd auth password + +set -e + +NUT_MANAGED_JSON="/data/nut_managed.json" + +if [ "${NUT_MANAGED:-0}" = "1" ] || [ -f "$NUT_MANAGED_JSON" ]; then + if [ -f "$NUT_MANAGED_JSON" ]; then + echo "[entrypoint] Found $NUT_MANAGED_JSON — configuring NUT from settings..." + python3 /app/fabledscryer/nut_setup.py --from-file "$NUT_MANAGED_JSON" + else + echo "[entrypoint] NUT_MANAGED=1 — configuring NUT from environment..." + python3 /app/fabledscryer/nut_setup.py + fi + + echo "[entrypoint] Starting NUT driver..." + /sbin/upsdrvctl start || echo "[entrypoint] Warning: upsdrvctl returned non-zero" + + echo "[entrypoint] Starting NUT daemon..." + /sbin/upsd || echo "[entrypoint] Warning: upsd returned non-zero" + + sleep 1 + echo "[entrypoint] NUT ready on 127.0.0.1:3493." +fi + +exec gosu app "$@" diff --git a/fabledscryer/core/plugin_manager.py b/fabledscryer/core/plugin_manager.py index 1537d97..da50403 100644 --- a/fabledscryer/core/plugin_manager.py +++ b/fabledscryer/core/plugin_manager.py @@ -23,6 +23,40 @@ logger = logging.getLogger(__name__) _LOADED_PLUGINS: set[str] = set() +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. @@ -88,15 +122,27 @@ def load_plugins(app: "Quart") -> None: 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) + # 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"} - plugins_cfg[name] = {"enabled": True, **defaults, **user_overrides} + 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 + # Import the plugin package (file-path load avoids stdlib name collisions) try: - module = importlib.import_module(name) - except ImportError: + module = _import_plugin(name, plugin_path) + except Exception: logger.exception("Plugin %r: import failed, skipping", name) continue @@ -223,12 +269,18 @@ async def download_and_install_plugin( logger.error("Plugin %r: %s", name, msg) return False, msg - # Run migrations for the newly installed plugin + # 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 - run_plugin_migrations(app.config["DATABASE_URL"], [mdir]) + 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" @@ -281,17 +333,41 @@ def hot_reload_plugin(app: "Quart", name: str) -> tuple[bool, str]: except Exception: return False, f"Invalid min_app_version: {min_ver!r}" - # Merge config defaults with any stored user config + # 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, {}) - user_overrides = {k: v for k, v in stored_cfg.items() if k != "enabled"} - merged = {"enabled": True, **defaults, **user_overrides} + 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 - # Import + # 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 = importlib.import_module(name) - except ImportError as exc: + 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"): diff --git a/fabledscryer/nut_setup.py b/fabledscryer/nut_setup.py new file mode 100644 index 0000000..6a48a3a --- /dev/null +++ b/fabledscryer/nut_setup.py @@ -0,0 +1,121 @@ +"""Generate NUT (Network UPS Tools) configuration files. + +Called by entrypoint.sh before the NUT daemons start. Reads config either from +environment variables (legacy NUT_MANAGED=1 path) or from a JSON file written +by the FabledScryer settings page (/data/nut_managed.json). + +Environment variables (used when no --from-file is given): + NUT_DRIVER NUT driver (default: snmp-ups) + snmp-ups — any UPS with SNMP management card (APC, Eaton, + CyberPower, Tripp Lite, Liebert, ...) + netxml-ups — Eaton Network Management Card (XML/HTTP) + NUT_UPS_NAME Name exposed by upsd (default: ups) + Must match 'ups_name' in the UPS plugin settings. + NUT_UPS_HOST IP address or hostname of the UPS management card (required) + NUT_SNMP_COMMUNITY SNMP community string (default: public) [snmp-ups only] + NUT_SNMP_VERSION SNMP version: v1, v2c (default: v1) [snmp-ups only] + NUT_USERNAME Optional upsd username for authenticated access + NUT_PASSWORD Optional upsd password + +JSON file format (--from-file path): + { + "ups_host": "192.168.1.x", // required + "driver": "snmp-ups", + "ups_name": "ups", + "snmp_community": "public", + "snmp_version": "v1", + "username": "", + "password": "" + } +""" +import json +import os +import sys +from pathlib import Path + +NUT_CONF_DIR = Path("/etc/nut") +NUT_MANAGED_JSON = Path("/data/nut_managed.json") + + +def _env(key: str, default: str = "") -> str: + return os.environ.get(key, default).strip() + + +def write_configs(cfg: dict | None = None) -> None: + """Write NUT config files. If cfg is given, use it; otherwise fall back to env vars.""" + NUT_CONF_DIR.mkdir(parents=True, exist_ok=True) + + if cfg is not None: + driver = cfg.get("driver", "snmp-ups").strip() + ups_name = cfg.get("ups_name", "ups").strip() + ups_host = cfg.get("ups_host", "").strip() + community = cfg.get("snmp_community", "public").strip() + snmp_ver = cfg.get("snmp_version", "v1").strip() + username = cfg.get("username", "").strip() + password = cfg.get("password", "").strip() + else: + driver = _env("NUT_DRIVER", "snmp-ups") + ups_name = _env("NUT_UPS_NAME", "ups") + ups_host = _env("NUT_UPS_HOST") + community = _env("NUT_SNMP_COMMUNITY", "public") + snmp_ver = _env("NUT_SNMP_VERSION", "v1") + username = _env("NUT_USERNAME") + password = _env("NUT_PASSWORD") + + if not ups_host: + print("[nut_setup] ERROR: NUT_UPS_HOST is required", file=sys.stderr) + sys.exit(1) + + # ── nut.conf ─────────────────────────────────────────────────────────────── + (NUT_CONF_DIR / "nut.conf").write_text("MODE=standalone\n") + + # ── ups.conf ─────────────────────────────────────────────────────────────── + ups_block = [f"[{ups_name}]"] + ups_block.append(f" driver = {driver}") + + if driver == "netxml-ups": + # Eaton: port is a URL + ups_block.append(f" port = http://{ups_host}") + else: + # snmp-ups and others: port is the IP/hostname + ups_block.append(f" port = {ups_host}") + if driver == "snmp-ups": + ups_block.append(f" community = {community}") + ups_block.append(f" snmp_version = {snmp_ver}") + + ups_block.append(f' desc = "Network UPS at {ups_host}"') + (NUT_CONF_DIR / "ups.conf").write_text("\n".join(ups_block) + "\n") + + # ── upsd.conf ────────────────────────────────────────────────────────────── + (NUT_CONF_DIR / "upsd.conf").write_text("LISTEN 127.0.0.1 3493\n") + + # ── upsd.users ───────────────────────────────────────────────────────────── + if username and password: + users_content = ( + f"[{username}]\n" + f" password = {password}\n" + f" upsmon primary\n" + ) + else: + users_content = "# No users configured — unauthenticated access\n" + users_path = NUT_CONF_DIR / "upsd.users" + users_path.write_text(users_content) + users_path.chmod(0o640) + + print( + f"[nut_setup] Config written: driver={driver} host={ups_host} ups_name={ups_name}" + ) + + +if __name__ == "__main__": + try: + cfg = None + if "--from-file" in sys.argv: + idx = sys.argv.index("--from-file") + json_path = Path(sys.argv[idx + 1]) + with json_path.open() as f: + cfg = json.load(f) + write_configs(cfg) + except Exception as exc: + print(f"[nut_setup] ERROR: {exc}", file=sys.stderr) + sys.exit(1) diff --git a/fabledscryer/settings/routes.py b/fabledscryer/settings/routes.py index f7af564..6a9a723 100644 --- a/fabledscryer/settings/routes.py +++ b/fabledscryer/settings/routes.py @@ -3,13 +3,15 @@ from __future__ import annotations import json import logging from pathlib import Path -from quart import Blueprint, current_app, render_template, request, redirect, url_for +from quart import Blueprint, current_app, render_template, request, redirect, url_for, session from fabledscryer.auth.middleware import require_role +from fabledscryer.core.audit import log_audit from fabledscryer.models.users import UserRole from fabledscryer.core.settings import ( DEFAULTS, get_all_settings, set_setting, to_smtp_cfg, to_webhook_cfg, to_ansible_cfg, to_plugins_cfg, + to_oidc_cfg, to_ldap_cfg, ) settings_bp = Blueprint("settings", __name__, url_prefix="/settings") @@ -58,6 +60,8 @@ async def _reload_app_config() -> None: WEBHOOK=to_webhook_cfg(fresh), ANSIBLE=to_ansible_cfg(fresh), PLUGINS=to_plugins_cfg(fresh), + OIDC=to_oidc_cfg(fresh), + LDAP=to_ldap_cfg(fresh), ) @@ -92,6 +96,8 @@ async def save_general(): await set_setting(db, "monitors.poll_interval_seconds", int(form.get("monitors.poll_interval_seconds", 60))) await _reload_app_config() + await log_audit(current_app, session.get("user_id"), session.get("username", ""), + "settings.saved", detail={"section": "general"}) return redirect(url_for("settings.general")) @@ -123,6 +129,8 @@ async def save_notifications(): await set_setting(db, "webhook.url", form.get("webhook.url", "")) await set_setting(db, "webhook.template", form.get("webhook.template", "")) await _reload_app_config() + await log_audit(current_app, session.get("user_id"), session.get("username", ""), + "settings.saved", detail={"section": "notifications"}) return redirect(url_for("settings.notifications")) @@ -154,6 +162,95 @@ async def save_ansible(): return redirect(url_for("settings.ansible")) +# ── Auth (OIDC / LDAP) ──────────────────────────────────────────────────────── + +@settings_bp.get("/auth/") +@require_role(UserRole.admin) +async def auth_settings(): + async with current_app.db_sessionmaker() as db: + settings = await get_all_settings(db) + return await render_template("settings/auth.html", settings=settings) + + +@settings_bp.post("/auth/") +@require_role(UserRole.admin) +async def save_auth_settings(): + form = await request.form + async with current_app.db_sessionmaker() as db: + async with db.begin(): + # OIDC + await set_setting(db, "oidc.enabled", "oidc.enabled" in form) + await set_setting(db, "oidc.discovery_url", form.get("oidc.discovery_url", "").strip()) + await set_setting(db, "oidc.client_id", form.get("oidc.client_id", "").strip()) + if form.get("oidc.client_secret"): + await set_setting(db, "oidc.client_secret", form.get("oidc.client_secret").strip()) + await set_setting(db, "oidc.scopes", form.get("oidc.scopes", "openid profile email").strip()) + await set_setting(db, "oidc.username_claim", form.get("oidc.username_claim", "preferred_username").strip()) + await set_setting(db, "oidc.email_claim", form.get("oidc.email_claim", "email").strip()) + await set_setting(db, "oidc.groups_claim", form.get("oidc.groups_claim", "groups").strip()) + await set_setting(db, "oidc.admin_group", form.get("oidc.admin_group", "").strip()) + await set_setting(db, "oidc.operator_group", form.get("oidc.operator_group", "").strip()) + # LDAP + await set_setting(db, "ldap.enabled", "ldap.enabled" in form) + await set_setting(db, "ldap.host", form.get("ldap.host", "").strip()) + await set_setting(db, "ldap.port", int(form.get("ldap.port") or 389)) + await set_setting(db, "ldap.tls", "ldap.tls" in form) + await set_setting(db, "ldap.bind_dn", form.get("ldap.bind_dn", "").strip()) + if form.get("ldap.bind_password"): + await set_setting(db, "ldap.bind_password", form.get("ldap.bind_password").strip()) + await set_setting(db, "ldap.base_dn", form.get("ldap.base_dn", "").strip()) + await set_setting(db, "ldap.user_filter", form.get("ldap.user_filter", "(uid={username})").strip()) + await set_setting(db, "ldap.admin_group_dn", form.get("ldap.admin_group_dn", "").strip()) + await set_setting(db, "ldap.operator_group_dn", form.get("ldap.operator_group_dn", "").strip()) + await set_setting(db, "ldap.attr_username", form.get("ldap.attr_username", "uid").strip()) + await set_setting(db, "ldap.attr_email", form.get("ldap.attr_email", "mail").strip()) + await _reload_app_config() + await log_audit(current_app, session.get("user_id"), session.get("username", ""), + "settings.saved", detail={"section": "auth"}) + # Clear OIDC discovery cache so new settings take effect + from fabledscryer.auth.oidc import _discovery_cache + _discovery_cache.clear() + return redirect(url_for("settings.auth_settings")) + + +# ── Reports ─────────────────────────────────────────────────────────────────── + +@settings_bp.get("/reports/") +@require_role(UserRole.admin) +async def reports(): + async with current_app.db_sessionmaker() as db: + settings = await get_all_settings(db) + return await render_template("settings/reports.html", settings=settings, flash=None) + + +@settings_bp.post("/reports/") +@require_role(UserRole.admin) +async def save_reports(): + form = await request.form + async with current_app.db_sessionmaker() as db: + async with db.begin(): + await set_setting(db, "reports.enabled", "reports.enabled" in form) + await set_setting(db, "reports.schedule_day", + int(form.get("reports.schedule_day", 6))) + await set_setting(db, "reports.schedule_hour", + int(form.get("reports.schedule_hour", 8))) + return redirect(url_for("settings.reports")) + + +@settings_bp.post("/reports/send-now/") +@require_role(UserRole.admin) +async def send_report_now(): + from fabledscryer.core.reports import send_report + async with current_app.db_sessionmaker() as db: + settings = await get_all_settings(db) + ok, message = await send_report(current_app._get_current_object()) + return await render_template( + "settings/reports.html", + settings=settings, + flash={"ok": ok, "message": message}, + ) + + # ── Plugins ─────────────────────────────────────────────────────────────────── @settings_bp.get("/plugins/") @@ -175,6 +272,11 @@ async def plugins(): async def save_plugins(): form = await request.form discovered = _discover_plugins() + # Snapshot old enabled states for audit diff + async with current_app.db_sessionmaker() as db: + old_settings = await get_all_settings(db) + old_plugins_cfg = to_plugins_cfg(old_settings) + async with current_app.db_sessionmaker() as db: async with db.begin(): # Save plugin index URL if provided @@ -188,7 +290,9 @@ async def save_plugins(): for cfg_key in plugin.get("config", {}).keys(): field_name = f"plugin.{name}.{cfg_key}" default_val = plugin["config"][cfg_key] - if isinstance(default_val, dict): + if isinstance(default_val, list): + pass # list configs (e.g. SNMP devices) must be set in plugin.yaml + elif isinstance(default_val, dict): sub_dict = {} for sub_key, sub_default in default_val.items(): sub_field = f"plugin.{name}.{cfg_key}.{sub_key}" @@ -221,9 +325,53 @@ async def save_plugins(): await _reload_app_config() from fabledscryer.core.plugin_index import clear_catalog_cache clear_catalog_cache() + # Audit plugin enable/disable changes + auto-hot-reload newly enabled plugins + from fabledscryer.core.plugin_manager import hot_reload_plugin + for plugin in discovered: + name = plugin["_dir"] + old_enabled = old_plugins_cfg.get(name, {}).get("enabled", False) + new_enabled = f"plugin.{name}.enabled" in form + if old_enabled != new_enabled: + action = "plugin.enabled" if new_enabled else "plugin.disabled" + await log_audit(current_app, session.get("user_id"), session.get("username", ""), + action, entity_type="plugin", entity_id=name) + if new_enabled and not old_enabled: + # Attempt to activate without requiring a manual reload click + hot_reload_plugin(current_app._get_current_object(), name) + # Write /data/nut_managed.json so entrypoint.sh can start NUT on next restart + _sync_nut_managed_config(form) return redirect(url_for("settings.plugins")) +def _sync_nut_managed_config(form) -> None: + """Write or remove /data/nut_managed.json based on UPS plugin managed NUT settings.""" + import json + nut_managed_path = Path("/data/nut_managed.json") + nut_managed = "plugin.ups.nut_managed" in form + ups_host = form.get("plugin.ups.nut_ups_host", "").strip() + # Only write the file when managed mode is on AND a host is configured. + # Without a host, NUT can't start and the container would loop on errors. + if nut_managed and ups_host: + cfg = { + "ups_host": ups_host, + "driver": form.get("plugin.ups.nut_driver", "snmp-ups").strip(), + "ups_name": form.get("plugin.ups.ups_name", "ups").strip(), + "snmp_community": form.get("plugin.ups.nut_snmp_community", "public").strip(), + "snmp_version": form.get("plugin.ups.nut_snmp_version", "v1").strip(), + "username": form.get("plugin.ups.nut_username", "").strip(), + "password": form.get("plugin.ups.nut_password", "").strip(), + } + try: + nut_managed_path.write_text(json.dumps(cfg, indent=2)) + except OSError as exc: + logger.warning("Could not write %s: %s", nut_managed_path, exc) + else: + try: + nut_managed_path.unlink(missing_ok=True) + except OSError as exc: + logger.warning("Could not remove %s: %s", nut_managed_path, exc) + + # ── Plugin catalog (HTMX partial) ───────────────────────────────────────────── @settings_bp.get("/plugins/catalog/") diff --git a/fabledscryer/templates/settings/plugins.html b/fabledscryer/templates/settings/plugins.html index 3571ca5..f592cbf 100644 --- a/fabledscryer/templates/settings/plugins.html +++ b/fabledscryer/templates/settings/plugins.html @@ -68,7 +68,17 @@ {% for cfg_key, cfg_default in plugin.config.items() %} - {% if cfg_default is mapping %} + {% if cfg_default is iterable and cfg_default is not string and cfg_default is not mapping %} + {# List — cannot be edited via a flat form; must be set in plugin.yaml #} +
+ 📋 + {{ cfg_key }} — list config, edit in + plugin.yaml ({{ cfg_default | length }} item{{ 's' if cfg_default | length != 1 }}). +
+ + {% elif cfg_default is mapping %} {# Nested dict group #}
{{ cfg_key }}
From edc9b752d2f11d7bd50ca1ce7cd1b48400100570 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 23 Mar 2026 08:16:04 -0400 Subject: [PATCH 05/46] feat: drag-and-drop dashboard editing, alert dynamic fields - Replace up/down arrow reorder buttons on dashboard edit page with SortableJS drag handles; add POST /d//edit/reorder endpoint accepting ordered widget ID array - Add {% block extra_scripts %} hook to base.html for page-specific JS - Add dynamic alert rule field loading via HTMX partial (_rule_fields) so metric/threshold fields update when source is changed - Add docs/plugins/index.yaml.example showing catalog index format Co-Authored-By: Claude Sonnet 4.6 --- docs/plugins/index.yaml.example | 31 +++ fabledscryer/alerts/routes.py | 230 +++++++++++++++++- fabledscryer/dashboard/routes.py | 79 ++++-- fabledscryer/templates/base.html | 5 + .../templates/dashboard/_edit_panels.html | 91 +++++-- fabledscryer/templates/dashboard/edit.html | 34 +++ fabledscryer/templates/dashboard/index.html | 4 +- .../templates/dashboard/share_view.html | 7 +- 8 files changed, 423 insertions(+), 58 deletions(-) diff --git a/docs/plugins/index.yaml.example b/docs/plugins/index.yaml.example index ec0c6d2..62e6783 100644 --- a/docs/plugins/index.yaml.example +++ b/docs/plugins/index.yaml.example @@ -34,6 +34,37 @@ updated: "2026-03-22" plugins: + - name: http + version: "1.0.0" + description: "Synthetic HTTP endpoint monitoring — status code, response time, content match, TLS expiry" + author: "FabledScryer" + license: "MIT" + min_app_version: "0.1.0" + repository_url: "https://git.fabledsword.com/bvandeusen/FabledScryer-plugins" + homepage: "https://git.fabledsword.com/bvandeusen/FabledScryer-plugins/src/branch/main/http" + download_url: "https://git.fabledsword.com/bvandeusen/FabledScryer-plugins/releases/download/http-v1.0.0/http.zip" + checksum_sha256: "" + tags: + - monitoring + - http + - synthetic + - uptime + + - name: docker + version: "1.0.0" + description: "Docker container status, resource usage, and restart tracking via Docker socket" + author: "FabledScryer" + license: "MIT" + min_app_version: "0.1.0" + repository_url: "https://git.fabledsword.com/bvandeusen/FabledScryer-plugins" + homepage: "https://git.fabledsword.com/bvandeusen/FabledScryer-plugins/src/branch/main/docker" + download_url: "https://git.fabledsword.com/bvandeusen/FabledScryer-plugins/releases/download/docker-v1.0.0/docker.zip" + checksum_sha256: "" + tags: + - containers + - docker + - infrastructure + - name: traefik version: "1.0.0" description: "Traefik reverse proxy metrics and access log integration" diff --git a/fabledscryer/alerts/routes.py b/fabledscryer/alerts/routes.py index 368338e..db025c7 100644 --- a/fabledscryer/alerts/routes.py +++ b/fabledscryer/alerts/routes.py @@ -1,15 +1,57 @@ from __future__ import annotations from datetime import datetime, timezone from quart import Blueprint, render_template, request, redirect, url_for, current_app, session +from fabledscryer.core.audit import log_audit from sqlalchemy import select from fabledscryer.auth.middleware import require_role -from fabledscryer.models.alerts import AlertRule, AlertState, AlertStateEnum, AlertOperator +from fabledscryer.models.alerts import AlertRule, AlertState, AlertStateEnum, AlertOperator, MaintenanceWindow +from fabledscryer.models.hosts import Host +from fabledscryer.models.metrics import PluginMetric from fabledscryer.models.users import UserRole alerts_bp = Blueprint("alerts", __name__, url_prefix="/alerts") _OPERATORS = [(op.value, op.value) for op in AlertOperator] +# Static catalog: source_module → available metric names +METRIC_CATALOG: dict[str, list[str]] = { + "ping": ["up", "response_time_ms"], + "dns": ["resolved", "ip_changed"], + "traefik": ["request_rate", "error_rate", "latency_p50_ms", "latency_p95_ms", + "latency_p99_ms", "response_bytes_rate", "cert_expiry_days"], + "unifi": ["is_up", "latency_ms", "total_clients"], + "ups": ["battery_charge_pct", "on_battery", "battery_runtime_secs"], + "docker": ["cpu_pct", "mem_pct"], + "http": ["is_up", "response_ms"], + # snmp metrics are user-defined OID labels — populated dynamically from plugin_metrics + "snmp": [], +} + +# Ordered list for source module picker +_SOURCE_MODULES = list(METRIC_CATALOG.keys()) + + +async def _get_resources(db, source_module: str) -> list[str]: + """Return sorted distinct resource names for a source module. + + For ping/dns, supplements with host names so the list is populated + even before any metrics have been recorded. + """ + result = await db.execute( + select(PluginMetric.resource_name) + .where(PluginMetric.source_module == source_module) + .distinct() + .order_by(PluginMetric.resource_name) + ) + resources: set[str] = {r for (r,) in result} + + if source_module in ("ping", "dns"): + host_result = await db.execute(select(Host.name).order_by(Host.name)) + for (name,) in host_result: + resources.add(name) + + return sorted(resources) + @alerts_bp.get("/") @require_role(UserRole.viewer) @@ -33,10 +75,69 @@ async def list_alerts(): return await render_template("alerts/list.html", active=active, rules=rules, operators=_OPERATORS) +@alerts_bp.get("/api/rule_fields") +@require_role(UserRole.viewer) +async def rule_fields_api(): + source = request.args.get("source_module", "") + current_resource = request.args.get("current_resource", "") + current_metric = request.args.get("current_metric", "") + async with current_app.db_sessionmaker() as db: + resources = await _get_resources(db, source) if source else [] + metrics = METRIC_CATALOG.get(source, []) + # Dynamic sources (e.g. snmp) have empty static catalog — query live metric names + if source and not metrics: + async with current_app.db_sessionmaker() as db: + m_result = await db.execute( + select(PluginMetric.metric_name) + .where(PluginMetric.source_module == source) + .distinct() + .order_by(PluginMetric.metric_name) + ) + metrics = [r for (r,) in m_result] + return await render_template( + "alerts/_rule_fields.html", + resources=resources, + metrics=metrics, + current_resource=current_resource, + current_metric=current_metric, + ) + + @alerts_bp.get("/rules/new") @require_role(UserRole.operator) async def new_rule(): - return await render_template("alerts/rules_form.html", rule=None, operators=_OPERATORS) + first_source = _SOURCE_MODULES[0] + async with current_app.db_sessionmaker() as db: + resources = await _get_resources(db, first_source) + return await render_template( + "alerts/rules_form.html", + rule=None, + operators=_OPERATORS, + source_modules=_SOURCE_MODULES, + initial_source=first_source, + resources=resources, + metrics=METRIC_CATALOG.get(first_source, []), + ) + + +@alerts_bp.get("/rules//edit") +@require_role(UserRole.operator) +async def edit_rule(rule_id: str): + async with current_app.db_sessionmaker() as db: + result = await db.execute(select(AlertRule).where(AlertRule.id == rule_id)) + rule = result.scalar_one_or_none() + if rule is None: + return "Not found", 404 + resources = await _get_resources(db, rule.source_module) + return await render_template( + "alerts/rules_form.html", + rule=rule, + operators=_OPERATORS, + source_modules=_SOURCE_MODULES, + initial_source=rule.source_module, + resources=resources, + metrics=METRIC_CATALOG.get(rule.source_module, []), + ) @alerts_bp.post("/rules") @@ -67,21 +168,146 @@ async def create_rule(): await db.flush() state.rule_id = rule.id db.add(state) + await log_audit(current_app, user_id, session.get("username", ""), "alert_rule.created", + entity_type="alert_rule", entity_id=rule.name, + detail={"source": rule.source_module, "resource": rule.resource_name, + "metric": rule.metric_name}) + return redirect(url_for("alerts.list_alerts")) + + +@alerts_bp.post("/rules/") +@require_role(UserRole.operator) +async def update_rule(rule_id: str): + form = await request.form + async with current_app.db_sessionmaker() as db: + async with db.begin(): + result = await db.execute(select(AlertRule).where(AlertRule.id == rule_id)) + rule = result.scalar_one_or_none() + if rule is None: + return "Not found", 404 + rule.name = form["name"].strip() + rule.source_module = form["source_module"].strip() + rule.resource_name = form["resource_name"].strip() + rule.metric_name = form["metric_name"].strip() + rule.operator = AlertOperator(form["operator"]) + rule.threshold = float(form["threshold"]) + rule.consecutive_failures_required = int(form.get("consecutive_failures_required") or 1) + await log_audit(current_app, session.get("user_id"), session.get("username", ""), + "alert_rule.updated", entity_type="alert_rule", entity_id=rule.name) + return redirect(url_for("alerts.list_alerts")) + + +@alerts_bp.post("/rules//toggle") +@require_role(UserRole.operator) +async def toggle_rule(rule_id: str): + async with current_app.db_sessionmaker() as db: + async with db.begin(): + result = await db.execute(select(AlertRule).where(AlertRule.id == rule_id)) + rule = result.scalar_one_or_none() + if rule is None: + return "Not found", 404 + rule.enabled = not rule.enabled + action = "alert_rule.enabled" if rule.enabled else "alert_rule.disabled" + await log_audit(current_app, session.get("user_id"), session.get("username", ""), + action, entity_type="alert_rule", entity_id=rule.name) return redirect(url_for("alerts.list_alerts")) @alerts_bp.post("/rules//delete") @require_role(UserRole.admin) async def delete_rule(rule_id: str): + rule_name = None async with current_app.db_sessionmaker() as db: async with db.begin(): result = await db.execute(select(AlertRule).where(AlertRule.id == rule_id)) rule = result.scalar_one_or_none() if rule: + rule_name = rule.name await db.delete(rule) + if rule_name: + await log_audit(current_app, session.get("user_id"), session.get("username", ""), + "alert_rule.deleted", entity_type="alert_rule", entity_id=rule_name) return redirect(url_for("alerts.list_alerts")) +@alerts_bp.get("/maintenance") +@require_role(UserRole.viewer) +async def list_maintenance(): + now = datetime.now(timezone.utc) + async with current_app.db_sessionmaker() as db: + result = await db.execute( + select(MaintenanceWindow).order_by(MaintenanceWindow.start_at.desc()) + ) + windows = result.scalars().all() + return await render_template( + "alerts/maintenance.html", + windows=windows, + now=now, + source_modules=_SOURCE_MODULES, + ) + + +@alerts_bp.get("/maintenance/new") +@require_role(UserRole.operator) +async def new_maintenance(): + return await render_template( + "alerts/maintenance_form.html", + window=None, + source_modules=_SOURCE_MODULES, + ) + + +@alerts_bp.post("/maintenance") +@require_role(UserRole.operator) +async def create_maintenance(): + form = await request.form + user_id = session.get("user_id") + start_at = datetime.fromisoformat(form["start_at"]).replace(tzinfo=timezone.utc) + end_at = datetime.fromisoformat(form["end_at"]).replace(tzinfo=timezone.utc) + scope_source = form.get("scope_source", "").strip() or None + scope_resource = form.get("scope_resource", "").strip() or None + if scope_source is None: + scope_resource = None # can't have resource without source + window = MaintenanceWindow( + name=form["name"].strip(), + start_at=start_at, + end_at=end_at, + scope_source=scope_source, + scope_resource=scope_resource, + created_by=user_id, + ) + async with current_app.db_sessionmaker() as db: + async with db.begin(): + db.add(window) + scope_desc = "all" if not scope_source else ( + f"{scope_source}/{scope_resource}" if scope_resource else f"{scope_source}/*" + ) + await log_audit(current_app, user_id, session.get("username", ""), + "maintenance_window.created", entity_type="maintenance_window", + entity_id=window.name, detail={"scope": scope_desc}) + return redirect(url_for("alerts.list_maintenance")) + + +@alerts_bp.post("/maintenance//delete") +@require_role(UserRole.operator) +async def delete_maintenance(window_id: str): + window_name = None + async with current_app.db_sessionmaker() as db: + async with db.begin(): + result = await db.execute( + select(MaintenanceWindow).where(MaintenanceWindow.id == window_id) + ) + window = result.scalar_one_or_none() + if window: + window_name = window.name + await db.delete(window) + if window_name: + await log_audit(current_app, session.get("user_id"), session.get("username", ""), + "maintenance_window.deleted", entity_type="maintenance_window", + entity_id=window_name) + return redirect(url_for("alerts.list_maintenance")) + + @alerts_bp.post("//acknowledge") @require_role(UserRole.operator) async def acknowledge(rule_id: str): diff --git a/fabledscryer/dashboard/routes.py b/fabledscryer/dashboard/routes.py index 071fd7c..faeba86 100644 --- a/fabledscryer/dashboard/routes.py +++ b/fabledscryer/dashboard/routes.py @@ -1,7 +1,9 @@ from __future__ import annotations import hashlib +import json import secrets from datetime import datetime, timezone, timedelta +from urllib.parse import urlencode from quart import Blueprint, render_template, current_app, abort, redirect, request, session from sqlalchemy import select, func, update from fabledscryer.auth.middleware import require_role, current_user_id @@ -146,7 +148,22 @@ def _resolve_widgets(widgets: list[DashboardWidget]) -> list[dict]: continue if defn["plugin"] and not plugins_cfg.get(defn["plugin"], {}).get("enabled", False): continue - out.append({"db": w, "defn": defn}) + # Build stored config (falls back to empty; defaults live in the registry) + config: dict = {} + if w.config_json: + try: + config = json.loads(w.config_json) + except (ValueError, TypeError): + pass + # Always append wid so widget templates can create unique canvas IDs + url_params = {**config, "wid": w.id} + hx_url = defn["hx_url"] + "?" + urlencode(url_params) + out.append({ + "db": w, + "defn": defn, + "hx_url": hx_url, + "title": w.title or defn["label"], + }) return out @@ -160,13 +177,12 @@ async def _get_panels_data(db, dashboard_id: int) -> tuple: widgets = await _get_widgets(db, dashboard_id) plugins_cfg = current_app.config.get("PLUGINS", {}) available = get_available_widgets(plugins_cfg) - placed_keys = {w.widget_key for w in widgets} - addable = [w for w in available if w["key"] not in placed_keys] + # Multiple instances of the same widget type are allowed — show all available resolved = [ - {"db": w, "defn": WIDGET_REGISTRY[w.widget_key]} + {"db": w, "defn": WIDGET_REGISTRY[w.widget_key], "title": w.title or WIDGET_REGISTRY[w.widget_key]["label"]} for w in widgets if w.widget_key in WIDGET_REGISTRY ] - return dash, resolved, addable + return dash, resolved, available async def _panels_response(dashboard_id: int): @@ -367,20 +383,36 @@ async def edit(dash_id: int): async def add_widget(dash_id: int, widget_key: str): user_id = current_user_id() user_role = session.get("user_role", "viewer") - if widget_key not in WIDGET_REGISTRY: + defn = WIDGET_REGISTRY.get(widget_key) + if defn is None: abort(400) + form = await request.form + title = form.get("title", "").strip() or None + # Parse configured params + config: dict = {} + for p in defn.get("params", []): + val = form.get(f"param_{p['key']}") + if val is not None: + if isinstance(p["default"], int): + try: + config[p["key"]] = int(val) + except ValueError: + config[p["key"]] = p["default"] + else: + config[p["key"]] = val + config_json = json.dumps(config) if config else None async with current_app.db_sessionmaker() as db: async with db.begin(): result = await db.execute(select(Dashboard).where(Dashboard.id == dash_id)) dash = result.scalar_one_or_none() if dash is None or not _can_edit(dash, user_id, user_role): abort(403) - widgets = await _get_widgets(db, dash_id) - if not any(w.widget_key == widget_key for w in widgets): - next_pos = max((w.position for w in widgets), default=-1) + 1 - db.add(DashboardWidget( - dashboard_id=dash_id, widget_key=widget_key, position=next_pos, - )) + widgets = await _get_widgets(db, dash_id) + next_pos = max((w.position for w in widgets), default=-1) + 1 + db.add(DashboardWidget( + dashboard_id=dash_id, widget_key=widget_key, position=next_pos, + title=title, config_json=config_json, + )) return await _panels_response(dash_id) @@ -405,28 +437,27 @@ async def remove_widget(dash_id: int, widget_id: int): return await _panels_response(dash_id) -@dashboard_bp.post("/d//edit/move//") +@dashboard_bp.post("/d//edit/reorder") @require_role(UserRole.viewer) -async def move_widget(dash_id: int, widget_id: int, direction: str): +async def reorder_widgets(dash_id: int): user_id = current_user_id() user_role = session.get("user_role", "viewer") - if direction not in ("up", "down"): + data = await request.get_json(silent=True) + if not data or not isinstance(data.get("order"), list): abort(400) + order: list[int] = data["order"] async with current_app.db_sessionmaker() as db: async with db.begin(): result = await db.execute(select(Dashboard).where(Dashboard.id == dash_id)) dash = result.scalar_one_or_none() if dash is None or not _can_edit(dash, user_id, user_role): abort(403) - widgets = await _get_widgets(db, dash_id) - idx = next((i for i, w in enumerate(widgets) if w.id == widget_id), None) - if idx is not None: - swap_idx = (idx - 1) if direction == "up" else (idx + 1) - if 0 <= swap_idx < len(widgets): - widgets[idx].position, widgets[swap_idx].position = ( - widgets[swap_idx].position, widgets[idx].position - ) - return await _panels_response(dash_id) + widgets = await _get_widgets(db, dash_id) + widget_map = {w.id: w for w in widgets} + for pos, wid in enumerate(order): + if wid in widget_map: + widget_map[wid].position = pos + return ("", 204) # ── Share tokens ────────────────────────────────────────────────────────────── diff --git a/fabledscryer/templates/base.html b/fabledscryer/templates/base.html index 585852f..d03d7eb 100644 --- a/fabledscryer/templates/base.html +++ b/fabledscryer/templates/base.html @@ -5,6 +5,7 @@ {% block title %}Fabled Scryer{% endblock %} + @@ -214,12 +215,14 @@ textarea { resize: vertical; } Dashboard Hosts + Uptime Ping DNS Alerts Ansible {% if session.user_role == 'admin' %} Settings + Audit {% endif %} {{ session.username }} @@ -351,5 +354,7 @@ function setTimeRange(val) { }()); +{% block extra_scripts %}{% endblock %} + diff --git a/fabledscryer/templates/dashboard/_edit_panels.html b/fabledscryer/templates/dashboard/_edit_panels.html index 2800361..ddc48fd 100644 --- a/fabledscryer/templates/dashboard/_edit_panels.html +++ b/fabledscryer/templates/dashboard/_edit_panels.html @@ -1,29 +1,21 @@ {# dashboard/_edit_panels.html — returned by all mutation routes and included by edit.html #} -
+
{# ── Current layout ───────────────────────────────────────────────────── #}
Current Layout
{% if widgets %} -
+
{% for item in widgets %} -
+
-
- - -
+
-
{{ item.defn.label }}
+
{{ item.title }}
{{ item.defn.description }}
@@ -47,22 +39,67 @@ {% if addable %}
{% for w in addable %} -
-
-
-
{{ w.label }}
-
{{ w.description }}
-
- -
+
+
+ +
+
{{ w.label }}
+
{{ w.description }}
+
+ Add ▾ +
+ +
+
+ +
+ + +
+ + {% for p in w.params %} +
+ + {% if p.type == "select" %} + + {% endif %} +
+ {% endfor %} + + +
+
+
{% endfor %}
{% else %}
- All available widgets are on this dashboard. + No plugin widgets available. Enable plugins in Settings → Plugins.
{% endif %}
diff --git a/fabledscryer/templates/dashboard/edit.html b/fabledscryer/templates/dashboard/edit.html index 3140102..1ef9d61 100644 --- a/fabledscryer/templates/dashboard/edit.html +++ b/fabledscryer/templates/dashboard/edit.html @@ -7,3 +7,37 @@
{% include "dashboard/_edit_panels.html" %} {% endblock %} +{% block extra_scripts %} + + + +{% endblock %} diff --git a/fabledscryer/templates/dashboard/index.html b/fabledscryer/templates/dashboard/index.html index 7247466..598f9d4 100644 --- a/fabledscryer/templates/dashboard/index.html +++ b/fabledscryer/templates/dashboard/index.html @@ -66,11 +66,11 @@ {% for item in widgets %}
- {{ item.defn.label }} + {{ item.title }} Details →
diff --git a/fabledscryer/templates/dashboard/share_view.html b/fabledscryer/templates/dashboard/share_view.html index 9bc3cb9..198cf6a 100644 --- a/fabledscryer/templates/dashboard/share_view.html +++ b/fabledscryer/templates/dashboard/share_view.html @@ -5,6 +5,7 @@ {{ dashboard.name }} — Fabled Scryer + @@ -75,11 +76,11 @@ {% for item in widgets %}
- {{ item.defn.label }} + {{ item.title }}
- {# Pass share token as ?s= param so require_role allows through #} + {# Pass share token as &s= param so require_role allows through #}
From 56283df5a1efce4978b89ffe5e3774c317749c08 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 23 Mar 2026 17:24:35 -0400 Subject: [PATCH 06/46] =?UTF-8?q?feat:=20Ansible=20source=20manager=20UI?= =?UTF-8?q?=20=E2=80=94=20replace=20raw=20JSON=20textarea?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace the raw JSON textarea in Settings → Ansible with a proper per-source card UI: add, inline-edit, remove, and git-sync each source independently via HTMX without a page reload - Each source card shows type badge (git/local), URL or path, branch, and pull interval; git sources get a Sync button that triggers git pull and returns inline status - Add source form uses a type-aware select that shows URL/branch/interval fields for git or path field for local, collapsed in a details accordion - Add new settings routes: sources/add, sources//remove, sources//save, sources//sync - Update browse.html and run_detail.html to use CSS vars throughout (removed hardcoded hex colors); link empty state to Settings page Co-Authored-By: Claude Sonnet 4.6 --- fabledscryer/settings/routes.py | 119 ++++++++-- fabledscryer/templates/ansible/browse.html | 12 +- .../templates/ansible/run_detail.html | 28 +-- .../templates/settings/_ansible_sources.html | 206 ++++++++++++++++++ fabledscryer/templates/settings/ansible.html | 21 +- 5 files changed, 338 insertions(+), 48 deletions(-) create mode 100644 fabledscryer/templates/settings/_ansible_sources.html diff --git a/fabledscryer/settings/routes.py b/fabledscryer/settings/routes.py index 6a9a723..ca07098 100644 --- a/fabledscryer/settings/routes.py +++ b/fabledscryer/settings/routes.py @@ -136,30 +136,117 @@ async def save_notifications(): # ── Ansible Sources ─────────────────────────────────────────────────────────── -@settings_bp.get("/ansible/") -@require_role(UserRole.admin) -async def ansible(): +async def _get_ansible_sources() -> list[dict]: async with current_app.db_sessionmaker() as db: settings = await get_all_settings(db) - return await render_template("settings/ansible.html", settings=settings) + sources = settings.get("ansible.sources", []) + return sources if isinstance(sources, list) else [] -@settings_bp.post("/ansible/") -@require_role(UserRole.admin) -async def save_ansible(): - form = await request.form - sources_raw = form.get("ansible.sources", "[]") - try: - sources = json.loads(sources_raw) - if not isinstance(sources, list): - sources = [] - except json.JSONDecodeError: - sources = [] +async def _save_ansible_sources(sources: list[dict]) -> None: async with current_app.db_sessionmaker() as db: async with db.begin(): await set_setting(db, "ansible.sources", sources) await _reload_app_config() - return redirect(url_for("settings.ansible")) + + +async def _ansible_sources_partial(sources: list[dict]): + return await render_template("settings/_ansible_sources.html", sources=sources) + + +@settings_bp.get("/ansible/") +@require_role(UserRole.admin) +async def ansible(): + sources = await _get_ansible_sources() + return await render_template("settings/ansible.html", sources=sources) + + +@settings_bp.post("/ansible/sources/add") +@require_role(UserRole.admin) +async def ansible_add_source(): + form = await request.form + name = form.get("name", "").strip() + src_type = form.get("type", "local").strip() + if not name: + sources = await _get_ansible_sources() + return await _ansible_sources_partial(sources) + entry: dict = {"name": name, "type": src_type} + if src_type == "git": + entry["url"] = form.get("url", "").strip() + entry["branch"] = form.get("branch", "main").strip() or "main" + try: + entry["pull_interval_seconds"] = int(form.get("pull_interval_seconds", 3600)) + except ValueError: + entry["pull_interval_seconds"] = 3600 + else: + entry["path"] = form.get("path", "").strip() + sources = await _get_ansible_sources() + sources.append(entry) + await _save_ansible_sources(sources) + return await _ansible_sources_partial(sources) + + +@settings_bp.post("/ansible/sources//remove") +@require_role(UserRole.admin) +async def ansible_remove_source(idx: int): + sources = await _get_ansible_sources() + if 0 <= idx < len(sources): + sources.pop(idx) + await _save_ansible_sources(sources) + return await _ansible_sources_partial(sources) + + +@settings_bp.post("/ansible/sources//save") +@require_role(UserRole.admin) +async def ansible_save_source(idx: int): + sources = await _get_ansible_sources() + if not (0 <= idx < len(sources)): + return await _ansible_sources_partial(sources) + form = await request.form + src_type = form.get("type", "local").strip() + entry: dict = { + "name": form.get("name", sources[idx].get("name", "")).strip(), + "type": src_type, + } + if src_type == "git": + entry["url"] = form.get("url", "").strip() + entry["branch"] = form.get("branch", "main").strip() or "main" + try: + entry["pull_interval_seconds"] = int(form.get("pull_interval_seconds", 3600)) + except ValueError: + entry["pull_interval_seconds"] = 3600 + else: + entry["path"] = form.get("path", "").strip() + sources[idx] = entry + await _save_ansible_sources(sources) + return await _ansible_sources_partial(sources) + + +@settings_bp.post("/ansible/sources//sync") +@require_role(UserRole.admin) +async def ansible_sync_source(idx: int): + """Trigger a git pull for a git source and return a status badge.""" + sources = await _get_ansible_sources() + if not (0 <= idx < len(sources)): + return ('Source not found', 404) + source = sources[idx] + if source.get("type") != "git": + return 'Not a git source' + from fabledscryer.ansible.sources import git_pull + from fabledscryer.core.settings import to_ansible_cfg + async with current_app.db_sessionmaker() as db: + settings = await get_all_settings(db) + ansible_cfg = to_ansible_cfg(settings) + from fabledscryer.ansible.sources import get_sources + resolved = next((s for s in get_sources(ansible_cfg) if s["name"] == source["name"]), None) + if resolved is None: + return 'Could not resolve source path' + try: + await git_pull(resolved) + return 'Synced' + except Exception as exc: + logger.exception("git pull failed for source %r", source["name"]) + return f'Sync failed: {exc}' # ── Auth (OIDC / LDAP) ──────────────────────────────────────────────────────── diff --git a/fabledscryer/templates/ansible/browse.html b/fabledscryer/templates/ansible/browse.html index 6479252..8f969ab 100644 --- a/fabledscryer/templates/ansible/browse.html +++ b/fabledscryer/templates/ansible/browse.html @@ -3,23 +3,23 @@ {% block content %}

Browse Playbooks

- ← Run History + ← Run History
{% if view_contents is defined %}
-
{{ view_contents }}
+
{{ view_contents }}
{% endif %} {% if not source_data %} {% if view_contents is not defined %}
-

No Ansible sources configured. Add sources under ansible.sources in config.yaml.

+

No Ansible sources configured. Add sources in Settings → Ansible.

{% endif %} {% else %} @@ -27,8 +27,8 @@

{{ sd.source.name }}

- {{ sd.source.type }} - {{ sd.source.path }} + {{ sd.source.type }} + {{ sd.source.path }}
{% if not sd.playbooks %} diff --git a/fabledscryer/templates/ansible/run_detail.html b/fabledscryer/templates/ansible/run_detail.html index 8194d9d..c3001f9 100644 --- a/fabledscryer/templates/ansible/run_detail.html +++ b/fabledscryer/templates/ansible/run_detail.html @@ -2,33 +2,33 @@ {% block title %}Run {{ run.id[:8] }} — Fabled Scryer{% endblock %} {% block content %}
- ← Runs + ← Runs

Run Detail

-{% set status_color = {"running": "#a0a000", "success": "#40a040", "failed": "#a04040", "interrupted": "#806040"}.get(run.status.value, "#808080") %} +{% set status_color = {"running": "var(--yellow)", "success": "var(--green)", "failed": "var(--red)", "interrupted": "var(--orange)"}.get(run.status.value, "var(--text-muted)") %}
- ID{{ run.id }} - Playbook{{ run.playbook_path }} - Inventory{{ run.inventory_path }} - Source{{ run.source_name }} - Status{{ run.status.value.upper() }} - Started{{ run.started_at.strftime("%Y-%m-%d %H:%M:%S UTC") }} + ID{{ run.id }} + Playbook{{ run.playbook_path }} + Inventory{{ run.inventory_path }} + Source{{ run.source_name }} + Status{{ run.status.value.upper() }} + Started{{ run.started_at.strftime("%Y-%m-%d %H:%M:%S UTC") }} {% if run.finished_at %} - Finished{{ run.finished_at.strftime("%Y-%m-%d %H:%M:%S UTC") }} + Finished{{ run.finished_at.strftime("%Y-%m-%d %H:%M:%S UTC") }} {% endif %}
-
- Output +
+ Output
{% if run.status.value == "running" %}
{{ run.output or "" }}
-
+ style="margin:0;padding:1rem;background:var(--bg);font-size:0.78rem;color:var(--green);max-height:600px;overflow-y:auto;white-space:pre-wrap;">{{ run.output or "" }} +
Streaming…
{% else %} -
{{ run.output or "(no output)" }}
+
{{ run.output or "(no output)" }}
{% endif %}
{% endblock %} diff --git a/fabledscryer/templates/settings/_ansible_sources.html b/fabledscryer/templates/settings/_ansible_sources.html new file mode 100644 index 0000000..9d07c17 --- /dev/null +++ b/fabledscryer/templates/settings/_ansible_sources.html @@ -0,0 +1,206 @@ +{# settings/_ansible_sources.html — HTMX partial for Ansible source management #} +
+ + {% if sources %} +
+ {% for src in sources %} + {% set idx = loop.index0 %} + +
+ + {# ── View row ──────────────────────────────────────────────────────── #} +
+ + + {{ src.type }} + + +
+
{{ src.name }}
+
+ {% if src.type == 'git' %} + {{ src.url or '(no URL)' }} + branch: {{ src.branch or 'main' }} + {% if src.pull_interval_seconds %}· pull every {{ src.pull_interval_seconds }}s{% endif %} + {% else %} + {{ src.path or '(no path)' }} + {% endif %} +
+
+ +
+ Browse + + {% if src.type == 'git' %} + + + {% endif %} + + + + +
+
+ + {# ── Inline edit form ──────────────────────────────────────────────── #} + + +
+ {% endfor %} +
+ {% else %} +
+ No sources configured. Add one below. +
+ {% endif %} + + {# ── Add source ────────────────────────────────────────────────────────── #} +
+ + Add Source + New ▾ + + +
+
+ +
+ + +
+ +
+ + +
+ + + +
+
+ + +
+
+ +
+ +
+
+ +
+ + diff --git a/fabledscryer/templates/settings/ansible.html b/fabledscryer/templates/settings/ansible.html index 3a8ee9e..2660902 100644 --- a/fabledscryer/templates/settings/ansible.html +++ b/fabledscryer/templates/settings/ansible.html @@ -5,18 +5,15 @@ {% set active_tab = "ansible" %} {% include "settings/_tabs.html" %} -
-
-

Ansible Sources

-

- JSON array of source objects. Each requires name, type - (git or local), and either url or path. +

+
+
Ansible Sources
+ Run History → +
+

+ Sources are directories of playbooks — either a local path or a git repository + cloned automatically. Add as many as you need; each can be browsed and run independently.

- + {% include "settings/_ansible_sources.html" %}
-
- -
- {% endblock %} From 8558d15eeb4d43b98cb2d1f6e4e4f3aba9cf7252 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 23 Mar 2026 18:04:53 -0400 Subject: [PATCH 07/46] feat: plugin fault isolation notices + NUT managed mode fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plugin failures: - Track load-time failures in _FAILED_PLUGINS dict with human-readable reasons - Inject plugin_failures into all templates via context processor - Show admin-only warning banner in base.html when any plugin fails to load - Show inline failure notice per plugin card in Settings → Plugins NUT managed mode: - entrypoint.sh: make nut_setup.py failure non-fatal so the container starts even if nut_ups_host isn't set yet (allows fixing config via UI) - save_plugins: when nut_managed is enabled, auto-set nut_host=localhost so the plugin connects to the managed NUT instance without extra manual config Co-Authored-By: Claude Sonnet 4.6 --- entrypoint.sh | 25 +++++++++---- fabledscryer/app.py | 8 +++- fabledscryer/core/plugin_manager.py | 39 +++++++++++++++++--- fabledscryer/settings/routes.py | 6 +++ fabledscryer/templates/base.html | 14 +++++++ fabledscryer/templates/settings/plugins.html | 14 +++++++ 6 files changed, 91 insertions(+), 15 deletions(-) diff --git a/entrypoint.sh b/entrypoint.sh index bf10c85..f467600 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -24,22 +24,31 @@ set -e NUT_MANAGED_JSON="/data/nut_managed.json" if [ "${NUT_MANAGED:-0}" = "1" ] || [ -f "$NUT_MANAGED_JSON" ]; then + NUT_OK=1 if [ -f "$NUT_MANAGED_JSON" ]; then echo "[entrypoint] Found $NUT_MANAGED_JSON — configuring NUT from settings..." - python3 /app/fabledscryer/nut_setup.py --from-file "$NUT_MANAGED_JSON" + python3 /app/fabledscryer/nut_setup.py --from-file "$NUT_MANAGED_JSON" || { + echo "[entrypoint] Warning: NUT config failed — UPS host may not be set yet. Skipping NUT startup." + NUT_OK=0 + } else echo "[entrypoint] NUT_MANAGED=1 — configuring NUT from environment..." - python3 /app/fabledscryer/nut_setup.py + python3 /app/fabledscryer/nut_setup.py || { + echo "[entrypoint] Warning: NUT config failed — check NUT_UPS_HOST. Skipping NUT startup." + NUT_OK=0 + } fi - echo "[entrypoint] Starting NUT driver..." - /sbin/upsdrvctl start || echo "[entrypoint] Warning: upsdrvctl returned non-zero" + if [ "$NUT_OK" = "1" ]; then + echo "[entrypoint] Starting NUT driver..." + /sbin/upsdrvctl start || echo "[entrypoint] Warning: upsdrvctl returned non-zero" - echo "[entrypoint] Starting NUT daemon..." - /sbin/upsd || echo "[entrypoint] Warning: upsd returned non-zero" + echo "[entrypoint] Starting NUT daemon..." + /sbin/upsd || echo "[entrypoint] Warning: upsd returned non-zero" - sleep 1 - echo "[entrypoint] NUT ready on 127.0.0.1:3493." + sleep 1 + echo "[entrypoint] NUT ready on 127.0.0.1:3493." + fi fi exec gosu app "$@" diff --git a/fabledscryer/app.py b/fabledscryer/app.py index 30c5534..8cbfe64 100644 --- a/fabledscryer/app.py +++ b/fabledscryer/app.py @@ -122,7 +122,13 @@ def create_app( from .core.plugin_manager import load_plugins load_plugins(app) - # ── 10. Share-token middleware ───────────────────────────────────────────── + # ── 10. Template context: inject plugin_failures into every response ─────── + @app.context_processor + def _inject_plugin_failures(): + from .core.plugin_manager import get_plugin_failures + return {"plugin_failures": get_plugin_failures()} + + # ── 11. Share-token middleware ───────────────────────────────────────────── @app.before_request async def _validate_share_token(): """If ?s= is present, validate it and set g.share_viewer.""" diff --git a/fabledscryer/core/plugin_manager.py b/fabledscryer/core/plugin_manager.py index da50403..7ea0127 100644 --- a/fabledscryer/core/plugin_manager.py +++ b/fabledscryer/core/plugin_manager.py @@ -22,6 +22,14 @@ logger = logging.getLogger(__name__) # 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. @@ -86,23 +94,29 @@ def load_plugins(app: "Quart") -> None: 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: + 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"), @@ -113,12 +127,16 @@ def load_plugins(app: "Quart") -> None: 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: + 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 @@ -142,15 +160,18 @@ def load_plugins(app: "Quart") -> None: # Import the plugin package (file-path load avoids stdlib name collisions) try: module = _import_plugin(name, plugin_path) - except Exception: + 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 ) @@ -159,7 +180,8 @@ def load_plugins(app: "Quart") -> None: # Call setup try: module.setup(app) - except Exception: + except Exception as exc: + _FAILED_PLUGINS[name] = f"setup() failed: {exc}" logger.exception("Plugin %r: setup() raised, skipping", name) continue @@ -169,17 +191,22 @@ def load_plugins(app: "Quart") -> None: 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: + 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: + 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", "?")) diff --git a/fabledscryer/settings/routes.py b/fabledscryer/settings/routes.py index ca07098..310888b 100644 --- a/fabledscryer/settings/routes.py +++ b/fabledscryer/settings/routes.py @@ -408,6 +408,12 @@ async def save_plugins(): plugin_cfg[cfg_key] = default_val else: plugin_cfg[cfg_key] = raw_val + # Managed NUT: when nut_managed is on, the NUT daemon runs in this + # container — force nut_host/port to localhost so the plugin + # connects to it automatically without manual config. + if name == "ups" and plugin_cfg.get("nut_managed"): + plugin_cfg["nut_host"] = "localhost" + plugin_cfg["nut_port"] = 3493 await set_setting(db, f"plugin.{name}", plugin_cfg) await _reload_app_config() from fabledscryer.core.plugin_index import clear_catalog_cache diff --git a/fabledscryer/templates/base.html b/fabledscryer/templates/base.html index d03d7eb..e1e7eb1 100644 --- a/fabledscryer/templates/base.html +++ b/fabledscryer/templates/base.html @@ -243,6 +243,20 @@ function setTimeRange(val) {
+ {% if plugin_failures and session.user_role == 'admin' %} +
+ + + + {{ plugin_failures | length }} plugin{{ 's' if plugin_failures | length != 1 }} failed to load. + + View details → + +
+ {% endif %} {% if error %}
{{ error }}
{% endif %} diff --git a/fabledscryer/templates/settings/plugins.html b/fabledscryer/templates/settings/plugins.html index f592cbf..b7961f3 100644 --- a/fabledscryer/templates/settings/plugins.html +++ b/fabledscryer/templates/settings/plugins.html @@ -62,6 +62,20 @@
+ {# Failure notice — shown when this plugin failed to load #} + {% set fail_reason = plugin_failures.get(plugin._dir) %} + {% if fail_reason %} +
+ + Failed to load + — {{ fail_reason }} + +
+ {% endif %} + {# Config fields — only shown when there are config keys #} {% if plugin.get('config') %}
From 00dc4cdc9cef7b5c907a033b621dadb83ce63bd6 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 23 Mar 2026 18:41:31 -0400 Subject: [PATCH 08/46] feat: per-plugin settings pages and multi-repo plugin catalog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plugin settings UI: - Settings → Plugins now shows a clean list (status dot, name, enabled badge) with a Settings button per plugin linking to its own detail page - Per-plugin detail page at /settings/plugins// shows enable toggle, all config fields, load status, and failure notice if the plugin errored - Config saving now scoped per-plugin via POST /settings/plugins//; removes the monolithic save-all-plugins form Plugin repositories: - Replace single Plugin Index URL field with a managed list of repositories (HTMX add/edit/remove per entry, same pattern as Ansible sources) - plugin_index.py: fetch_catalog() now accepts a list of URLs, caches per-URL, and merges results deduplicating by plugin name (first repo wins) - Legacy plugins.index_url is auto-migrated to the new list format on first access so existing installs don't lose their configured URL Co-Authored-By: Claude Sonnet 4.6 --- fabledscryer/core/plugin_index.py | 64 +++-- fabledscryer/settings/routes.py | 257 +++++++++++++----- .../templates/settings/_plugin_repos.html | 99 +++++++ .../templates/settings/plugin_detail.html | 134 +++++++++ fabledscryer/templates/settings/plugins.html | 173 ++++-------- 5 files changed, 509 insertions(+), 218 deletions(-) create mode 100644 fabledscryer/templates/settings/_plugin_repos.html create mode 100644 fabledscryer/templates/settings/plugin_detail.html diff --git a/fabledscryer/core/plugin_index.py b/fabledscryer/core/plugin_index.py index 0113bc2..fc292e8 100644 --- a/fabledscryer/core/plugin_index.py +++ b/fabledscryer/core/plugin_index.py @@ -8,7 +8,8 @@ from typing import Any logger = logging.getLogger(__name__) _CACHE_TTL = 300 # seconds -_cache: tuple[dict, float] | None = None # (raw_data, fetched_at monotonic) +# Per-URL cache: url → (raw_data, fetched_at monotonic) +_url_cache: dict[str, tuple[dict, float]] = {} class CatalogPlugin: @@ -34,44 +35,59 @@ class CatalogPlugin: 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 +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 _cache is not None: - raw, fetched_at = _cache + 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(index_url) + resp = await client.get(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", [])), - ) + _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", index_url) - if _cache is not None: - raw, _ = _cache - logger.warning("Returning stale cached catalog") + 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 the in-process catalog cache.""" - global _cache - _cache = None + """Invalidate all in-process catalog caches.""" + _url_cache.clear() diff --git a/fabledscryer/settings/routes.py b/fabledscryer/settings/routes.py index 310888b..b9a5c4f 100644 --- a/fabledscryer/settings/routes.py +++ b/fabledscryer/settings/routes.py @@ -338,7 +338,81 @@ async def send_report_now(): ) -# ── Plugins ─────────────────────────────────────────────────────────────────── +# ── Plugin repository helpers ───────────────────────────────────────────────── + +def _get_plugin_repos(settings: dict) -> list[dict]: + """Return the list of plugin repositories from settings. + + Migrates the legacy plugins.index_url single-URL field to the list format + on first access if plugins.repositories is not yet set. + """ + repos = settings.get("plugins.repositories") + if repos and isinstance(repos, list): + return repos + # Migrate legacy single URL + legacy = settings.get("plugins.index_url", "").strip() + if legacy: + return [{"name": "Default", "url": legacy}] + return [] + + +async def _save_plugin_repos(db, repos: list[dict]) -> None: + await set_setting(db, "plugins.repositories", repos) + + +async def _plugin_repos_partial(settings: dict): + repos = _get_plugin_repos(settings) + return await render_template("settings/_plugin_repos.html", repos=repos) + + +def _build_plugin_cfg_from_form(plugin: dict, form) -> dict: + """Extract config values for one plugin from a form submission.""" + name = plugin["_dir"] + enabled = f"plugin.{name}.enabled" in form + plugin_cfg: dict = {"enabled": enabled} + for cfg_key in plugin.get("config", {}).keys(): + field_name = f"plugin.{name}.{cfg_key}" + default_val = plugin["config"][cfg_key] + if isinstance(default_val, list): + pass # list configs must be set in plugin.yaml + elif isinstance(default_val, dict): + sub_dict = {} + for sub_key, sub_default in default_val.items(): + sub_field = f"plugin.{name}.{cfg_key}.{sub_key}" + if isinstance(sub_default, bool): + sub_dict[sub_key] = sub_field in form + elif sub_field in form: + raw_sub = form[sub_field] + if isinstance(sub_default, int): + try: + sub_dict[sub_key] = int(raw_sub) + except ValueError: + sub_dict[sub_key] = sub_default + else: + sub_dict[sub_key] = raw_sub + else: + sub_dict[sub_key] = sub_default + plugin_cfg[cfg_key] = sub_dict + elif field_name in form: + raw_val = form[field_name] + if isinstance(default_val, bool): + plugin_cfg[cfg_key] = raw_val.lower() in ("1", "true", "yes", "on") + elif isinstance(default_val, int): + try: + plugin_cfg[cfg_key] = int(raw_val) + except ValueError: + plugin_cfg[cfg_key] = default_val + else: + plugin_cfg[cfg_key] = raw_val + # Managed NUT: when nut_managed is on, the NUT daemon runs in this container + # — force nut_host/port to localhost so the plugin connects automatically. + if name == "ups" and plugin_cfg.get("nut_managed"): + plugin_cfg["nut_host"] = "localhost" + plugin_cfg["nut_port"] = 3493 + return plugin_cfg + + +# ── Plugins list ────────────────────────────────────────────────────────────── @settings_bp.get("/plugins/") @require_role(UserRole.admin) @@ -347,93 +421,129 @@ async def plugins(): settings = await get_all_settings(db) discovered = _discover_plugins() _merge_plugin_config(discovered, to_plugins_cfg(settings)) + repos = _get_plugin_repos(settings) return await render_template( "settings/plugins.html", discovered_plugins=discovered, + repos=repos, settings=settings, ) -@settings_bp.post("/plugins/") +# ── Per-plugin detail (settings) ────────────────────────────────────────────── + +@settings_bp.get("/plugins//") @require_role(UserRole.admin) -async def save_plugins(): +async def plugin_detail(name: str): + async with current_app.db_sessionmaker() as db: + settings = await get_all_settings(db) + discovered = _discover_plugins() + plugin = next((p for p in discovered if p["_dir"] == name), None) + if plugin is None: + return redirect(url_for("settings.plugins")) + _merge_plugin_config([plugin], to_plugins_cfg(settings)) + from fabledscryer.core.plugin_manager import _LOADED_PLUGINS, get_plugin_failures + return await render_template( + "settings/plugin_detail.html", + plugin=plugin, + is_loaded=name in _LOADED_PLUGINS, + fail_reason=get_plugin_failures().get(name), + ) + + +@settings_bp.post("/plugins//") +@require_role(UserRole.admin) +async def save_plugin_detail(name: str): form = await request.form discovered = _discover_plugins() - # Snapshot old enabled states for audit diff + plugin = next((p for p in discovered if p["_dir"] == name), None) + if plugin is None: + return redirect(url_for("settings.plugins")) + async with current_app.db_sessionmaker() as db: old_settings = await get_all_settings(db) - old_plugins_cfg = to_plugins_cfg(old_settings) + old_enabled = to_plugins_cfg(old_settings).get(name, {}).get("enabled", False) + + plugin_cfg = _build_plugin_cfg_from_form(plugin, form) async with current_app.db_sessionmaker() as db: async with db.begin(): - # Save plugin index URL if provided - index_url = form.get("plugins.index_url", "").strip() - await set_setting(db, "plugins.index_url", index_url) + await set_setting(db, f"plugin.{name}", plugin_cfg) - for plugin in discovered: - name = plugin["_dir"] - enabled = f"plugin.{name}.enabled" in form - plugin_cfg: dict = {"enabled": enabled} - for cfg_key in plugin.get("config", {}).keys(): - field_name = f"plugin.{name}.{cfg_key}" - default_val = plugin["config"][cfg_key] - if isinstance(default_val, list): - pass # list configs (e.g. SNMP devices) must be set in plugin.yaml - elif isinstance(default_val, dict): - sub_dict = {} - for sub_key, sub_default in default_val.items(): - sub_field = f"plugin.{name}.{cfg_key}.{sub_key}" - if isinstance(sub_default, bool): - sub_dict[sub_key] = sub_field in form - elif sub_field in form: - raw_sub = form[sub_field] - if isinstance(sub_default, int): - try: - sub_dict[sub_key] = int(raw_sub) - except ValueError: - sub_dict[sub_key] = sub_default - else: - sub_dict[sub_key] = raw_sub - else: - sub_dict[sub_key] = sub_default - plugin_cfg[cfg_key] = sub_dict - elif field_name in form: - raw_val = form[field_name] - if isinstance(default_val, bool): - plugin_cfg[cfg_key] = raw_val.lower() in ("1", "true", "yes", "on") - elif isinstance(default_val, int): - try: - plugin_cfg[cfg_key] = int(raw_val) - except ValueError: - plugin_cfg[cfg_key] = default_val - else: - plugin_cfg[cfg_key] = raw_val - # Managed NUT: when nut_managed is on, the NUT daemon runs in this - # container — force nut_host/port to localhost so the plugin - # connects to it automatically without manual config. - if name == "ups" and plugin_cfg.get("nut_managed"): - plugin_cfg["nut_host"] = "localhost" - plugin_cfg["nut_port"] = 3493 - await set_setting(db, f"plugin.{name}", plugin_cfg) await _reload_app_config() from fabledscryer.core.plugin_index import clear_catalog_cache clear_catalog_cache() - # Audit plugin enable/disable changes + auto-hot-reload newly enabled plugins - from fabledscryer.core.plugin_manager import hot_reload_plugin - for plugin in discovered: - name = plugin["_dir"] - old_enabled = old_plugins_cfg.get(name, {}).get("enabled", False) - new_enabled = f"plugin.{name}.enabled" in form - if old_enabled != new_enabled: - action = "plugin.enabled" if new_enabled else "plugin.disabled" - await log_audit(current_app, session.get("user_id"), session.get("username", ""), - action, entity_type="plugin", entity_id=name) - if new_enabled and not old_enabled: - # Attempt to activate without requiring a manual reload click - hot_reload_plugin(current_app._get_current_object(), name) - # Write /data/nut_managed.json so entrypoint.sh can start NUT on next restart - _sync_nut_managed_config(form) - return redirect(url_for("settings.plugins")) + + new_enabled = plugin_cfg.get("enabled", False) + if old_enabled != new_enabled: + action = "plugin.enabled" if new_enabled else "plugin.disabled" + await log_audit(current_app, session.get("user_id"), session.get("username", ""), + action, entity_type="plugin", entity_id=name) + if new_enabled and not old_enabled: + from fabledscryer.core.plugin_manager import hot_reload_plugin + hot_reload_plugin(current_app._get_current_object(), name) + + if name == "ups": + _sync_nut_managed_config(form) + + return redirect(url_for("settings.plugin_detail", name=name)) + + +# ── Plugin repository HTMX routes ───────────────────────────────────────────── + +@settings_bp.post("/plugins/repos/add") +@require_role(UserRole.admin) +async def plugin_repos_add(): + form = await request.form + async with current_app.db_sessionmaker() as db: + settings = await get_all_settings(db) + repos = _get_plugin_repos(settings) + url = form.get("url", "").strip() + repo_name = form.get("name", "").strip() or url + if url: + repos.append({"name": repo_name, "url": url}) + async with current_app.db_sessionmaker() as db: + async with db.begin(): + await _save_plugin_repos(db, repos) + from fabledscryer.core.plugin_index import clear_catalog_cache + clear_catalog_cache() + return await _plugin_repos_partial({"plugins.repositories": repos}) + + +@settings_bp.post("/plugins/repos//remove") +@require_role(UserRole.admin) +async def plugin_repos_remove(idx: int): + async with current_app.db_sessionmaker() as db: + settings = await get_all_settings(db) + repos = _get_plugin_repos(settings) + if 0 <= idx < len(repos): + repos.pop(idx) + async with current_app.db_sessionmaker() as db: + async with db.begin(): + await _save_plugin_repos(db, repos) + from fabledscryer.core.plugin_index import clear_catalog_cache + clear_catalog_cache() + return await _plugin_repos_partial({"plugins.repositories": repos}) + + +@settings_bp.post("/plugins/repos//save") +@require_role(UserRole.admin) +async def plugin_repos_save(idx: int): + form = await request.form + async with current_app.db_sessionmaker() as db: + settings = await get_all_settings(db) + repos = _get_plugin_repos(settings) + if 0 <= idx < len(repos): + repos[idx] = { + "name": form.get("name", "").strip() or repos[idx]["name"], + "url": form.get("url", "").strip() or repos[idx]["url"], + } + async with current_app.db_sessionmaker() as db: + async with db.begin(): + await _save_plugin_repos(db, repos) + from fabledscryer.core.plugin_index import clear_catalog_cache + clear_catalog_cache() + return await _plugin_repos_partial({"plugins.repositories": repos}) def _sync_nut_managed_config(form) -> None: @@ -473,15 +583,16 @@ async def plugins_catalog(): """HTMX partial: list plugins available in the remote catalog.""" async with current_app.db_sessionmaker() as db: settings = await get_all_settings(db) - index_url = settings.get("plugins.index_url", "").strip() + repos = _get_plugin_repos(settings) + repo_urls = [r["url"] for r in repos if r.get("url")] - if not index_url: + if not repo_urls: return await render_template( "settings/_catalog_partial.html", catalog=[], installed_names=set(), loaded_names=set(), - error="No plugin index URL configured.", + error="No plugin repositories configured.", ) from fabledscryer.core.plugin_index import fetch_catalog @@ -489,7 +600,7 @@ async def plugins_catalog(): force = request.args.get("refresh") == "1" try: - catalog = await fetch_catalog(index_url, force=force) + catalog = await fetch_catalog(repo_urls, force=force) error = None if catalog else "Catalog is empty or could not be fetched." except Exception as exc: catalog = [] diff --git a/fabledscryer/templates/settings/_plugin_repos.html b/fabledscryer/templates/settings/_plugin_repos.html new file mode 100644 index 0000000..8b990a5 --- /dev/null +++ b/fabledscryer/templates/settings/_plugin_repos.html @@ -0,0 +1,99 @@ +{# settings/_plugin_repos.html — HTMX partial for plugin repository management #} +
+ + {% if repos %} +
+ {% for repo in repos %} + {% set idx = loop.index0 %} + +
+ + {# ── View row ────────────────────────────────────────────────────────── #} +
+
+
{{ repo.name or repo.url }}
+
{{ repo.url }}
+
+
+ + +
+
+ + {# ── Inline edit form ──────────────────────────────────────────────── #} + + +
+ {% endfor %} +
+ {% else %} +
+ No repositories configured. Add one below. +
+ {% endif %} + + {# ── Add repository ────────────────────────────────────────────────────────── #} +
+ + Add Repository + New ▾ + +
+
+
+ + +
+
+ + +
+
+ +
+
+ +
+ + diff --git a/fabledscryer/templates/settings/plugin_detail.html b/fabledscryer/templates/settings/plugin_detail.html new file mode 100644 index 0000000..f1c88a3 --- /dev/null +++ b/fabledscryer/templates/settings/plugin_detail.html @@ -0,0 +1,134 @@ +{# fabledscryer/templates/settings/plugin_detail.html #} +{% extends "base.html" %} +{% block title %}{{ plugin.get('name', plugin._dir) }} Settings — Fabled Scryer{% endblock %} +{% block content %} +{% set active_tab = "plugins" %} +{% include "settings/_tabs.html" %} + +
+ ← Plugins +

{{ plugin.get('name', plugin._dir) }}

+ v{{ plugin.get('version', '?') }} + {% if fail_reason %} + Error + {% elif is_loaded %} + Loaded + {% elif plugin._enabled %} + Enabled (restart required) + {% else %} + Disabled + {% endif %} +
+ +{% if fail_reason %} +
+ + Failed to load + — {{ fail_reason }} + +
+{% endif %} + +{% if plugin.get('description') %} +

{{ plugin.description }}

+{% endif %} + +
+ + {# ── Enable toggle ────────────────────────────────────────────────────────── #} +
+
+ + +
+
+ Enable/disable changes take effect after a restart. +
+
+ + {# ── Config fields ─────────────────────────────────────────────────────────── #} + {% if plugin.get('config') %} +
+
Configuration
+
+ + {% for cfg_key, cfg_default in plugin.config.items() %} + + {% if cfg_default is iterable and cfg_default is not string and cfg_default is not mapping %} +
+ 📋 + {{ cfg_key }} — list config, edit in + plugin.yaml ({{ cfg_default | length }} item{{ 's' if cfg_default | length != 1 }}). +
+ + {% elif cfg_default is mapping %} +
+
{{ cfg_key }}
+ {% set sub_cfg = plugin._config.get(cfg_key, cfg_default) if plugin._config.get(cfg_key, cfg_default) is mapping else cfg_default %} +
+ {% for sub_key, sub_default in cfg_default.items() %} + {% if sub_default is sameas false or sub_default is sameas true %} +
+ + +
+ {% else %} +
+ + +
+ {% endif %} + {% endfor %} +
+
+ + {% elif cfg_default is sameas false or cfg_default is sameas true %} +
+ + +
+ + {% else %} +
+ + +
+ {% endif %} + + {% endfor %} +
+
+ {% endif %} + + + +
+{% endblock %} diff --git a/fabledscryer/templates/settings/plugins.html b/fabledscryer/templates/settings/plugins.html index b7961f3..b6ef5f4 100644 --- a/fabledscryer/templates/settings/plugins.html +++ b/fabledscryer/templates/settings/plugins.html @@ -5,7 +5,7 @@ {% set active_tab = "plugins" %} {% include "settings/_tabs.html" %} -{# ── Restart banner (replaced by _restart_pending.html after action) #} +{# ── Restart banner ────────────────────────────────────────────────────────── #}
{# ── Installed Plugins ─────────────────────────────────────────────────────── #} @@ -22,141 +22,73 @@
{% if not discovered_plugins %} -
+
No plugins found in the plugin directory.
{% else %} -
-
- - {# Plugin index URL #} -
-
Plugin Index URL
-
- -
-
- URL to the remote index.yaml used by the plugin catalog below. -
-
- +
{% for plugin in discovered_plugins %} -
+ {% set fail_reason = plugin_failures.get(plugin._dir) %} +
- {# Plugin header: enable toggle + name/description #} -
- - -
- - {# Failure notice — shown when this plugin failed to load #} - {% set fail_reason = plugin_failures.get(plugin._dir) %} + {# Status indicator #} {% if fail_reason %} -
- - Failed to load - — {{ fail_reason }} - -
+ + {% elif plugin._enabled %} + + {% else %} + {% endif %} - {# Config fields — only shown when there are config keys #} - {% if plugin.get('config') %} -
- - {% for cfg_key, cfg_default in plugin.config.items() %} - - {% if cfg_default is iterable and cfg_default is not string and cfg_default is not mapping %} - {# List — cannot be edited via a flat form; must be set in plugin.yaml #} -
- 📋 - {{ cfg_key }} — list config, edit in - plugin.yaml ({{ cfg_default | length }} item{{ 's' if cfg_default | length != 1 }}). -
- - {% elif cfg_default is mapping %} - {# Nested dict group #} -
-
{{ cfg_key }}
- {% set sub_cfg = plugin._config.get(cfg_key, cfg_default) if plugin._config.get(cfg_key, cfg_default) is mapping else cfg_default %} -
- {% for sub_key, sub_default in cfg_default.items() %} - {% if sub_default is sameas false or sub_default is sameas true %} -
- - -
- {% else %} -
- - -
- {% endif %} - {% endfor %} -
-
- - {% elif cfg_default is sameas false or cfg_default is sameas true %} -
- - -
- + {# Name + version + description #} +
+
+ {{ plugin.get('name', plugin._dir) }} + v{{ plugin.get('version', '?') }} + {% if fail_reason %} + Error + {% elif plugin._enabled %} + Enabled {% else %} -
- - -
+ Disabled {% endif %} - - {% endfor %} +
+ {% if plugin.get('description') %} +
{{ plugin.description }}
+ {% endif %} + {% if fail_reason %} +
{{ fail_reason }}
+ {% endif %}
- {% endif %} + {# Settings button #} + Settings →
{% endfor %} -
-
- - Enable/disable changes take effect after a restart. -
- {% endif %} +{# ── Plugin Repositories ───────────────────────────────────────────────────── #} +
+
Plugin Repositories
+

+ Repositories are remote index.yaml files that Scryer checks for available + and updated plugins. Add repos from other developers to expand the plugin catalog. +

+ {% include "settings/_plugin_repos.html" %} +
+ {# ── Plugin Catalog ────────────────────────────────────────────────────────── #} -
+
Plugin Catalog
- {% set index_url = settings.get('plugins.index_url', '') %} - {% if index_url %} + {% if repos %}
{% else %}
- Configure a Plugin Index URL above to browse available plugins. + Add a plugin repository above to browse available plugins.
{% endif %}
From 39074252d16e656889c5f4d3d1aea7f492280468 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 13 Apr 2026 14:30:40 -0400 Subject: [PATCH 09/46] =?UTF-8?q?docs:=20rebrand=20design=20spec=20(Fabled?= =?UTF-8?q?Scryer=20=E2=86=92=20Roundtable)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../specs/2026-04-13-rebrand-design.md | 161 ++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-13-rebrand-design.md diff --git a/docs/superpowers/specs/2026-04-13-rebrand-design.md b/docs/superpowers/specs/2026-04-13-rebrand-design.md new file mode 100644 index 0000000..72e3d6a --- /dev/null +++ b/docs/superpowers/specs/2026-04-13-rebrand-design.md @@ -0,0 +1,161 @@ +# Roundtable Rebrand — Design + +**Date:** 2026-04-13 +**Status:** Approved for planning + +## 1. Identity + +**Name:** Roundtable (dropping the "Fabled" prefix). + +**Why the change:** The old name `FabledScryer` framed the app as a single seer peering into one crystal ball. The actual product is a gathering point — hosts, metrics, alerts, plugins, and dashboards from across the homelab converge here. "Roundtable" captures that: a place where the realm's tools and information meet. + +**Name conflict check:** +- *Roundtable Software* — a Progress ABL tooling vendor. Different demographic, no overlap. +- *Fabled.gg* — a virtual tabletop product. Closest concern, and the reason the "Fabled" prefix is dropped entirely to avoid confusion inside the tabletop/fantasy space. + +**Umbrella:** `fabledsword.com` remains the family umbrella domain; `git.fabledsword.com` remains the plugin catalog host. Roundtable becomes one product under that umbrella. + +**Tone:** Heraldic / Arthurian register. The roundtable is the gathering place of those who keep watch over the realm. + +## 2. Visual System + +### Palette — Pewter & Gold + +Design tokens (map to CSS custom properties in `base.html` `:root`): + +| Token | Value | Role | +|---|---|---| +| `--bg-0` | `#131315` | Page background (near-black, no blue cast) | +| `--bg-1` | `#1d1d20` | Card / panel background | +| `--bg-2` | `#24242a` | Hover / elevated surface | +| `--border` | `#30303a` | Default border | +| `--border-accent` | `#c8a840` | Gold accent border (focus, active) | +| `--text-0` | `#d4d0c0` | Primary text (warm parchment) | +| `--text-1` | `#b8b8b0` | Secondary text | +| `--text-2` | `#8a8a92` | Tertiary / pewter text | +| `--gold` | `#c8a840` | Brand gold | +| `--pewter` | `#8a8a92` | Brand pewter | + +No purple. No steel blue. Backgrounds are neutral near-black; text is warm parchment; the only chromatic accents are gold and pewter. + +### Buttons — full action palette + +| Role | Color | Use | +|---|---|---| +| Primary | Gold (`#c8a840`) | Main CTA, submit, confirm | +| Success | Green | Positive state changes, "all clear" | +| Danger | Crimson | Destructive / warning actions | +| Secondary | Pewter (`#8a8a92`) | Cancel, neutral | + +Exact green/crimson hues finalized in implementation; must sit harmoniously next to gold and against the near-black background. + +### Logo — Heraldic Seal (crown & table) + +Locked SVG (24×24 viewBox, solid gold fills only): + +```html + + + + + + + + + + +``` + +Reads as a heraldic seal: gold rim, faint pewter wash, five-point crown, solid-gold elliptical table with two legs. No inner detail to clutter at small sizes. Legible from 22px nav icon to 200px marketing size. + +### Typography + +**EB Garamond** via Google Fonts. Used for the wordmark, headings, and body. Warm, classical, matches the heraldic register without being costume-y. Replaces Libertinus Serif. + +### Background + +Candlelit glow — a soft, warm radial wash behind the main content area. Replaces the animated star field. Static (no animation) to stay calm and low-distraction. + +### Wordmark + +"Roundtable" in EB Garamond, gold (`#c8a840`), paired with the seal mark to the left in the nav header. + +## 3. Voice & Copy + +**Strategy:** plain, functional UI copy. Themed flavor appears only at the edges — places where a little atmosphere costs nothing and rewards exploration. + +**Plain (no flavor):** +- Host list, alert list, plugin settings, forms, tables, error validation +- Button labels ("Save", "Delete", "Add host") +- Column headers, tooltips, help text + +**Themed (heraldic flavor):** +- **Login screen** — brief atmospheric tagline below the wordmark +- **Empty states** — "No hosts yet. Summon one to the table." style, one line each +- **404 / 500 pages** — themed error messages with clear recovery actions +- **Dashboard hero title** — **"Under Watch, Under Care"** (optional, can be disabled) + +**Not used:** themed language in validation errors, confirmation dialogs, or anywhere the user is trying to accomplish a task. Flavor must never get in the way of understanding what's happening. + +## 4. Rename Mechanics + +**Code & packaging** +- `fabledscryer/` package directory → `roundtable/` +- All `from fabledscryer...` / `import fabledscryer...` → `roundtable` +- `pyproject.toml`: project name, CLI entry point (`fabledscryer = "fabledscryer.cli:main"` → `roundtable = "roundtable.cli:main"`), tool sections +- `__init__.py` package metadata + +**Runtime config** +- Env vars: `FABLEDSCRYER_*` → `ROUNDTABLE_*` +- Residual `FABLEDNETMON_*` env vars removed +- Config dir: `~/.config/fabledscryer` → `~/.config/roundtable` (one-shot copy on first boot) +- Log file names, systemd unit names if any + +**Container & deploy** +- `Dockerfile` labels, workdir +- `docker-compose.yml` service name, image tag, volume names, `container_name` +- Published image: `fabledscryer:latest` → `roundtable:latest` + +**Database** +- Alembic `script_location` and any customized version table +- Table prefixes (if any — confirm via grep during implementation) +- Rename migration only if prefixed tables exist + +**Templates & UI** +- `base.html` wordmark, ``, meta tags +- Hardcoded "FabledScryer" strings in templates, flash messages, emails +- Favicon / app icon assets + +**Docs & repo** +- README, CONTRIBUTING, `docs/` +- Forgejo repo rename (redirect preserves old URL) +- Local directory rename (optional) + +**Preserved (not renamed)** +- `fabledsword.com` umbrella domain +- `git.fabledsword.com` plugin catalog host +- Plugin repo names + +## 5. Sequencing + +Staged so `main` stays runnable between PRs. + +**PR 1 — Visual reskin (no rename)** +New palette tokens, locked logo SVG, EB Garamond font swap, candlelit glow background, themed edge copy (login, empty states, 404/500, dashboard hero "Under Watch, Under Care"). Still named FabledScryer internally. Ships independently, low risk. + +**PR 2 — Python package rename** +`fabledscryer/` → `roundtable/`, all imports, `pyproject.toml` name + entry point, `__init__.py` metadata. Docker/config untouched. Package installable as `roundtable` locally. + +**PR 3 — Config & env vars** +`FABLEDSCRYER_*` → `ROUNDTABLE_*` with a short-lived fallback reader (accept both, warn on old) so self-hosters don't break mid-upgrade. Config dir migration on first boot. Drop `FABLEDNETMON_*` residue. + +**PR 4 — Container & deploy** +`Dockerfile`, `docker-compose.yml` service/image/volume/container names. Published image tag switch. Template wordmark + `<title>` + meta (the last user-visible string flip). + +**PR 5 — Repo & docs** +README, docs, CONTRIBUTING. Forgejo repo rename. Local directory rename (optional). + +**PR 6 — Cleanup (after one release cycle)** +Remove env var fallback shim from PR 3. Remove config dir migration code. + +**Rationale:** reskin first is safe and visible; package rename is the mechanical core; config/container flips are the breaking-change moments gated behind the fallback shim; docs last so they reflect final state. From 1f6404f0fd1e4a13ba5932cfb7da073b0c5c0e87 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen <bryan.vandeusen@fabledsword.com> Date: Mon, 13 Apr 2026 14:40:26 -0400 Subject: [PATCH 10/46] docs: rebrand implementation plan Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --- docs/superpowers/plans/2026-04-13-rebrand.md | 927 +++++++++++++++++++ 1 file changed, 927 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-13-rebrand.md diff --git a/docs/superpowers/plans/2026-04-13-rebrand.md b/docs/superpowers/plans/2026-04-13-rebrand.md new file mode 100644 index 0000000..89a0ff9 --- /dev/null +++ b/docs/superpowers/plans/2026-04-13-rebrand.md @@ -0,0 +1,927 @@ +# Roundtable Rebrand Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. +> +> **Testing:** Per project feedback, skip all test authoring and test runs during this work. Verify manually by running the app. + +**Goal:** Rebrand FabledScryer to Roundtable: full visual reskin plus full rename of package, config, containers, and docs. + +**Architecture:** Staged as six sequential PRs. PR 1 is a pure visual/copy reskin that still ships under the FabledScryer name. PRs 2–4 perform the mechanical rename in order (package → config → container + user-visible strings) with a fallback shim in PR 3 so self-hosters don't break mid-upgrade. PR 5 updates docs. PR 6 removes the shim after one release cycle. + +**Tech Stack:** Python 3.13, Quart, SQLAlchemy + Alembic (asyncpg), Jinja2 templates, Docker Compose, Postgres 16. + +**Spec:** `docs/superpowers/specs/2026-04-13-rebrand-design.md` + +--- + +## File Structure + +**Modified (PR 1 — visual reskin):** +- `fabledscryer/templates/base.html` — palette tokens, logo SVG, font, background, wordmark, `<title>` +- `fabledscryer/templates/auth/login.html` — themed tagline +- `fabledscryer/templates/errors/404.html` (create if missing) — themed 404 +- `fabledscryer/templates/errors/500.html` (create if missing) — themed 500 +- `fabledscryer/templates/dashboard/*.html` — hero title "Under Watch, Under Care" +- Any template with an empty-state message — themed line + +**Renamed (PR 2 — package):** +- `fabledscryer/` → `roundtable/` (directory + every `from fabledscryer` / `import fabledscryer` reference) +- `pyproject.toml` — name, entry point, hatch packages +- `alembic.ini` — `script_location` +- `tests/**` — imports (tests not executed, but must not break collection; update imports mechanically) + +**Modified (PR 3 — config & env):** +- `fabledscryer/config.py` → `roundtable/config.py` (already moved in PR 2) — read both `ROUNDTABLE_*` and `FABLEDSCRYER_*` with deprecation warning +- `.env.example`, `config.example.yaml` — new names +- First-boot migration of `~/.config/fabledscryer` → `~/.config/roundtable` (if the app uses it — verify during task) + +**Modified (PR 4 — container + user strings):** +- `Dockerfile` — COPY paths, CMD, image labels +- `docker-compose.yml` — service name, image, container_name, volumes, env +- `entrypoint.sh` — package path for `nut_setup.py` invocation +- `roundtable/templates/base.html` — wordmark text "Fabled Scryer" → "Roundtable", `<title>` +- `roundtable/templates/auth/login.html` — any residual "Fabled Scryer" text +- `README.md` — first-page references (full doc sweep is PR 5) + +**Modified (PR 5 — docs):** +- `README.md`, `docs/**/*.md` + +**Modified (PR 6 — cleanup):** +- `roundtable/config.py` — remove fallback shim + +--- + +## PR 1 — Visual Reskin + +### Task 1: Replace palette tokens in `base.html` + +**Files:** +- Modify: `fabledscryer/templates/base.html:13-35` + +- [ ] **Step 1: Open `fabledscryer/templates/base.html` and replace the `:root` block (lines 13–35) with the Pewter & Gold palette** + +```css +:root { + --bg: #131315; + --bg-card: #1d1d20; + --bg-elevated: #24242a; + --border: #30303a; + --border-mid: #3a3a44; + --text: #d4d0c0; + --text-muted: #b8b8b0; + --text-dim: #8a8a92; + --gold: #c8a840; + --gold-hover: #d8b850; + --gold-dim: #2a2410; + --pewter: #8a8a92; + --accent: var(--gold); + --accent-hover: var(--gold-hover); + --green: #4aa86a; + --green-dim: #162a1c; + --yellow: #d4a840; + --yellow-dim: #2a2410; + --orange: #c87840; + --orange-dim: #2a1a10; + --red: #c84048; + --red-dim: #2a1014; + --font-serif: 'EB Garamond', Georgia, serif; +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add fabledscryer/templates/base.html +git commit -m "style: pewter & gold palette tokens" +``` + +### Task 2: Swap Google Font to EB Garamond + +**Files:** +- Modify: `fabledscryer/templates/base.html:9-11` + +- [ ] **Step 1: Replace the font preconnect + stylesheet link (lines 9–11) with EB Garamond** + +```html +<link rel="preconnect" href="https://fonts.googleapis.com"> +<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> +<link href="https://fonts.googleapis.com/css2?family=EB+Garamond:ital,wght@0,400;0,600;0,700;1,400&display=swap" rel="stylesheet"> +``` + +- [ ] **Step 2: Commit** + +```bash +git add fabledscryer/templates/base.html +git commit -m "style: swap Libertinus Serif → EB Garamond" +``` + +### Task 3: Replace crystal ball logo with Heraldic Seal + +**Files:** +- Modify: `fabledscryer/templates/base.html:187-214` + +- [ ] **Step 1: Replace the `<svg>` block inside `nav .brand` (lines 188–213) with the locked seal SVG** + +```html +<svg width="22" height="22" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"> + <circle cx="12" cy="12" r="10.5" fill="none" stroke="#c8a840" stroke-width="1.5"/> + <circle cx="12" cy="12" r="9.3" fill="#8a8a92" opacity="0.1"/> + <path d="M7 10 L7 5.8 L9 7.8 L10.5 5 L12 7.2 L13.5 5 L15 7.8 L17 5.8 L17 10 Z" fill="#c8a840"/> + <ellipse cx="12" cy="15.5" rx="5.2" ry="1" fill="#c8a840"/> + <rect x="9.6" y="15.5" width="1" height="3.8" fill="#c8a840"/> + <rect x="13.4" y="15.5" width="1" height="3.8" fill="#c8a840"/> +</svg> +``` + +- [ ] **Step 2: Change the brand wordmark gradient (lines 68–71) to solid gold** + +Replace: +```css +background: linear-gradient(110deg, #c8b4f8 0%, var(--gold) 100%); +-webkit-background-clip: text; +-webkit-text-fill-color: transparent; +background-clip: text; +``` + +With: +```css +color: var(--gold); +``` + +- [ ] **Step 3: Commit** + +```bash +git add fabledscryer/templates/base.html +git commit -m "feat: heraldic seal logo (crown & ellipse table)" +``` + +### Task 4: Replace star field with candlelit glow + +**Files:** +- Modify: `fabledscryer/templates/base.html:42-46` (CSS), `182` (markup), `266-369` (script) + +- [ ] **Step 1: Replace the `#star-field` CSS rules (lines 42–46) with a candlelit glow rule** + +```css +#candle-glow { + position: fixed; + inset: 0; + pointer-events: none; + z-index: 0; + background: + radial-gradient(ellipse 80% 60% at 50% 40%, rgba(200, 168, 64, 0.045) 0%, transparent 70%), + radial-gradient(ellipse 50% 40% at 20% 80%, rgba(200, 168, 64, 0.025) 0%, transparent 65%); +} +``` + +- [ ] **Step 2: Rename the markup `<div id="star-field">` (line 182) to `<div id="candle-glow">`** + +```html +<div id="candle-glow"></div> +``` + +- [ ] **Step 3: Delete the entire star-field IIFE script block (lines 266–369)** — from `<script>` through `</script>` that contains `var field = document.getElementById('star-field');`. The glow is CSS-only; no JS needed. + +- [ ] **Step 4: Commit** + +```bash +git add fabledscryer/templates/base.html +git commit -m "style: candlelit glow background replaces star field" +``` + +### Task 5: Update `<title>` tag + +**Files:** +- Modify: `fabledscryer/templates/base.html:6` + +- [ ] **Step 1: Change the title block** + +```html +<title>{% block title %}Roundtable{% endblock %} +``` + +Note: the visible wordmark text still reads "Fabled Scryer" — that flips in PR 4. This only changes the browser tab title, which is acceptable to flip early since it's not visible inside the UI. + +Actually, leave the visible `` as "Fabled Scryer" in PR 1 to keep this PR purely about palette/logo/font/background. Revert this change if already applied. + +- [ ] **Step 2: Revert line 6 back to `Fabled Scryer` if modified, then commit nothing for this task.** (PR 1 does not change any user-visible strings.) + +### Task 6: Add themed empty-state and login tagline + +**Files:** +- Modify: `fabledscryer/templates/auth/login.html` +- Modify: existing empty-state markup in list templates (identify via grep) + +- [ ] **Step 1: Identify empty-state lines** + +Run: +```bash +grep -rn 'class="empty"' fabledscryer/templates/ +``` + +- [ ] **Step 2: Replace each empty-state copy with a one-line themed variant** + +For each hit, replace the inner text with a single themed sentence. Examples: +- Hosts list empty → `No hosts yet. Summon one to the table.` +- Alerts list empty → `Silence in the realm. No alerts to show.` +- Plugins list empty → `No plugins installed. The table awaits new seats.` + +Use judgment — one sentence per list. Keep them short. + +- [ ] **Step 3: Add login tagline** + +Open `fabledscryer/templates/auth/login.html`. Below the wordmark/heading, add: + +```html +<p style="color: var(--text-dim); font-family: var(--font-serif); font-style: italic; font-size: 0.95rem; margin-top: 0.25rem;">Where the realm's watch convenes.</p> +``` + +- [ ] **Step 4: Commit** + +```bash +git add fabledscryer/templates/ +git commit -m "copy: themed empty states + login tagline" +``` + +### Task 7: Add 404 and 500 templates + +**Files:** +- Check: `fabledscryer/templates/errors/` (create if missing) +- Create or modify: `fabledscryer/templates/errors/404.html`, `fabledscryer/templates/errors/500.html` + +- [ ] **Step 1: Check if error templates and handlers already exist** + +Run: +```bash +ls fabledscryer/templates/errors/ 2>/dev/null +grep -rn "errorhandler\|@app.error" fabledscryer/app.py fabledscryer/*/routes.py +``` + +- [ ] **Step 2: If templates exist, update their copy to themed messages. If they don't exist, create them and wire handlers in `fabledscryer/app.py`.** + +Content for `fabledscryer/templates/errors/404.html`: + +```html +{% extends "base.html" %} +{% block title %}Not Found — Roundtable{% endblock %} +{% block content %} +<div style="text-align:center; padding: 4rem 2rem; font-family: var(--font-serif);"> + <h1 style="color: var(--gold); font-size: 2.5rem; margin-bottom: 0.5rem;">404</h1> + <p style="color: var(--text); font-size: 1.2rem; margin-bottom: 0.25rem;">No such seat at this table.</p> + <p style="color: var(--text-dim); margin-bottom: 1.5rem;">The page you sought is not among us.</p> + <a href="/" class="btn">Return to the hall</a> +</div> +{% endblock %} +``` + +Content for `fabledscryer/templates/errors/500.html`: + +```html +{% extends "base.html" %} +{% block title %}Error — Roundtable{% endblock %} +{% block content %} +<div style="text-align:center; padding: 4rem 2rem; font-family: var(--font-serif);"> + <h1 style="color: var(--red); font-size: 2.5rem; margin-bottom: 0.5rem;">500</h1> + <p style="color: var(--text); font-size: 1.2rem; margin-bottom: 0.25rem;">The watch has faltered.</p> + <p style="color: var(--text-dim); margin-bottom: 1.5rem;">An unexpected error occurred. The cause has been logged.</p> + <a href="/" class="btn">Return to the hall</a> +</div> +{% endblock %} +``` + +- [ ] **Step 3: If handlers are missing, add them to `fabledscryer/app.py` near other Quart error wiring** + +```python +@app.errorhandler(404) +async def not_found(_): + return await render_template("errors/404.html"), 404 + +@app.errorhandler(500) +async def server_error(_): + return await render_template("errors/500.html"), 500 +``` + +Match `render_template` import style already used in the file. + +- [ ] **Step 4: Commit** + +```bash +git add fabledscryer/templates/errors/ fabledscryer/app.py +git commit -m "feat: themed 404 and 500 pages" +``` + +### Task 8: Dashboard hero title "Under Watch, Under Care" + +**Files:** +- Modify: `fabledscryer/templates/dashboard/index.html` (or equivalent) + +- [ ] **Step 1: Locate the dashboard top-of-page template** + +Run: +```bash +ls fabledscryer/templates/dashboard/ +grep -rn "page-title\|dashboard" fabledscryer/templates/dashboard/ | head +``` + +- [ ] **Step 2: At the top of the dashboard page content block, add a hero title** + +```html +<h1 class="page-title" style="text-align:center; color: var(--gold); font-size: 1.8rem; letter-spacing: 0.02em;">Under Watch, Under Care</h1> +``` + +If the dashboard already has a `.page-title`, replace its text content with "Under Watch, Under Care" rather than adding a second title. + +- [ ] **Step 3: Commit** + +```bash +git add fabledscryer/templates/dashboard/ +git commit -m "copy: dashboard hero title" +``` + +### Task 9: Manual visual verification + +- [ ] **Step 1: Start the app** + +Run: +```bash +docker compose up --build +``` + +- [ ] **Step 2: Open `http://localhost:5000` in a browser and walk through** + - Login page — tagline visible, gold wordmark, candle glow background + - Dashboard — hero title, logo in nav, no purple anywhere + - Hosts / Alerts / Plugins / Settings — palette applied consistently + - Trigger a 404 (e.g. `/nope`) — themed page renders + - Check any empty state you can produce + +- [ ] **Step 3: Note visually off items in a scratch file, fix them, and commit** + +```bash +git add -A +git commit -m "style: visual polish after manual review" +``` + +**PR 1 done.** Open PR with title `feat: roundtable visual reskin (pewter & gold)`. + +--- + +## PR 2 — Python Package Rename + +### Task 10: Rename the package directory + +**Files:** +- Rename: `fabledscryer/` → `roundtable/` + +- [ ] **Step 1: Rename the directory** + +```bash +git mv fabledscryer roundtable +``` + +- [ ] **Step 2: Confirm the move** + +```bash +ls roundtable/ | head +git status | head +``` + +Expected: see `__init__.py`, `app.py`, `cli.py`, etc. inside `roundtable/`. + +- [ ] **Step 3: Commit** + +```bash +git commit -m "refactor: rename package directory fabledscryer → roundtable" +``` + +### Task 11: Rewrite all `fabledscryer` imports to `roundtable` + +**Files:** +- Modify: every file under `roundtable/`, `tests/`, `alembic.ini`, `entrypoint.sh`, `Dockerfile` that imports from the old package + +- [ ] **Step 1: Find every remaining `fabledscryer` reference in Python code** + +Run: +```bash +grep -rn "fabledscryer" roundtable/ tests/ alembic.ini entrypoint.sh Dockerfile pyproject.toml +``` + +Keep the output visible as a checklist. + +- [ ] **Step 2: Run a safe codemod across Python files** + +```bash +grep -rl "fabledscryer" roundtable/ tests/ | xargs sed -i 's/fabledscryer/roundtable/g' +``` + +This touches only files under `roundtable/` and `tests/`. Do NOT run it across the whole repo yet — `docs/`, `.env.example`, `docker-compose.yml`, `config.example.yaml` and `README.md` are handled in later PRs. + +- [ ] **Step 3: Spot-check a few critical files** + +```bash +grep -n "roundtable\|fabledscryer" roundtable/app.py roundtable/cli.py roundtable/config.py roundtable/migrations/env.py +``` + +Expected: only `roundtable` references remain; no stray `fabledscryer`. + +- [ ] **Step 4: Commit** + +```bash +git add -A +git commit -m "refactor: update imports fabledscryer → roundtable" +``` + +### Task 12: Update `pyproject.toml` + +**Files:** +- Modify: `pyproject.toml` + +- [ ] **Step 1: Replace the project name, script entry, and hatch packages** + +```toml +[project] +name = "roundtable" +version = "0.1.0" +# ... dependencies unchanged ... + +[project.scripts] +roundtable = "roundtable.cli:main" + +[tool.hatch.build.targets.wheel] +packages = ["roundtable"] +``` + +Leave `[project.optional-dependencies]`, `[tool.pytest.ini_options]`, and all dependency pins untouched. + +- [ ] **Step 2: Commit** + +```bash +git add pyproject.toml +git commit -m "build: rename package to roundtable in pyproject" +``` + +### Task 13: Update `alembic.ini` + +**Files:** +- Modify: `alembic.ini` + +- [ ] **Step 1: Change `script_location`** + +```ini +[alembic] +script_location = roundtable/migrations +prepend_sys_path = . +version_path_separator = os +``` + +- [ ] **Step 2: Commit** + +```bash +git add alembic.ini +git commit -m "build: point alembic at roundtable/migrations" +``` + +### Task 14: Verify the package installs and imports cleanly + +- [ ] **Step 1: Install in editable mode into a throwaway venv** + +```bash +python -m venv /tmp/rt-verify +/tmp/rt-verify/bin/pip install -e . +/tmp/rt-verify/bin/python -c "import roundtable; import roundtable.app; import roundtable.cli; print('ok')" +/tmp/rt-verify/bin/roundtable --help +``` + +Expected: `ok` and CLI help output. No ImportError. + +- [ ] **Step 2: Remove venv** + +```bash +rm -rf /tmp/rt-verify +``` + +- [ ] **Step 3: No commit needed (verification only).** + +**PR 2 done.** Open PR with title `refactor: rename python package fabledscryer → roundtable`. + +--- + +## PR 3 — Config & Environment Variables + +### Task 15: Add env-var fallback shim in `roundtable/config.py` + +**Files:** +- Modify: `roundtable/config.py` + +- [ ] **Step 1: Replace the env var lookups with a fallback helper** + +Open `roundtable/config.py` and replace the existing `load_bootstrap` body so it reads `ROUNDTABLE_*` first and falls back to `FABLEDSCRYER_*` with a deprecation warning. + +```python +def _env_with_fallback(new_name: str, old_name: str) -> str | None: + val = os.environ.get(new_name) + if val is not None: + return val + val = os.environ.get(old_name) + if val is not None: + logger.warning( + "%s is deprecated, use %s instead. Fallback will be removed " + "in a future release.", + old_name, new_name, + ) + return val + return None +``` + +Then inside `load_bootstrap`, replace the existing env lookups with: + +```python +database_url = ( + _env_with_fallback("ROUNDTABLE_DATABASE_URL", "FABLEDSCRYER_DATABASE_URL") + or _env_with_fallback("ROUNDTABLE_DATABASE__URL", "FABLEDSCRYER_DATABASE__URL") + or raw.get("database", {}).get("url") +) +if not database_url: + raise ValueError( + "Database URL is required. Set ROUNDTABLE_DATABASE_URL env var " + "or add 'database.url' to config.yaml." + ) + +# ... +plugin_dir = ( + _env_with_fallback("ROUNDTABLE_PLUGIN_DIR", "FABLEDSCRYER_PLUGIN_DIR") + or raw.get("plugin_dir", "plugins") +) +``` + +And in `_resolve_secret_key`: + +```python +from_env = ( + _env_with_fallback("ROUNDTABLE_SECRET_KEY", "FABLEDSCRYER_SECRET_KEY") + or raw.get("secret_key") +) +``` + +- [ ] **Step 2: Grep for any other `FABLEDSCRYER_` or `FABLEDNETMON_` env var reads across `roundtable/`** + +```bash +grep -rn "FABLEDSCRYER_\|FABLEDNETMON_" roundtable/ +``` + +For each hit, apply the same pattern (new name first, old as fallback with warning). If `FABLEDNETMON_` is pure residue with no current consumer, delete the reference. + +- [ ] **Step 3: Commit** + +```bash +git add roundtable/config.py +git commit -m "feat: ROUNDTABLE_* env vars with FABLEDSCRYER_* fallback" +``` + +### Task 16: Update `.env.example` and `config.example.yaml` + +**Files:** +- Modify: `.env.example`, `config.example.yaml` + +- [ ] **Step 1: Rewrite `.env.example`** + +```bash +grep -n "FABLEDSCRYER\|FABLEDNETMON" .env.example +``` + +Replace every occurrence with `ROUNDTABLE_*`. Add a short comment at top: + +``` +# Roundtable environment configuration. +# Only ROUNDTABLE_DATABASE_URL is required. Other settings live in the DB. +``` + +- [ ] **Step 2: Rewrite `config.example.yaml`** + +Replace the header and example strings: + +```yaml +# Roundtable — Bootstrap Configuration Example +# +# The only REQUIRED setting is the database URL. +# Set it via env var (recommended for Docker): +# +# ROUNDTABLE_DATABASE_URL=postgresql+asyncpg://user:pass@host/db +# +# All other settings are stored in the database and managed via +# the Settings UI at /settings/. + +database: + url: "postgresql+asyncpg://roundtable:password@localhost/roundtable" +``` + +- [ ] **Step 3: Commit** + +```bash +git add .env.example config.example.yaml +git commit -m "docs: ROUNDTABLE_* env var examples" +``` + +### Task 17: Manual config verification + +- [ ] **Step 1: Run the app with only the new env var** + +```bash +ROUNDTABLE_DATABASE_URL=postgresql+asyncpg://fabledscryer:fabledscryer@localhost/fabledscryer \ + python -m roundtable.cli --host 127.0.0.1 --port 5001 +``` + +(Use whatever local DB you have; the old db name is fine — data isn't being renamed.) + +Expected: app starts, no deprecation warning. + +- [ ] **Step 2: Run the app with only the old env var** + +```bash +FABLEDSCRYER_DATABASE_URL=postgresql+asyncpg://fabledscryer:fabledscryer@localhost/fabledscryer \ + python -m roundtable.cli --host 127.0.0.1 --port 5001 +``` + +Expected: app starts, deprecation warning logged once. + +- [ ] **Step 3: Kill processes.** No commit. + +**PR 3 done.** Open PR with title `feat: ROUNDTABLE_* env vars (FABLEDSCRYER_* fallback)`. + +--- + +## PR 4 — Container, Deploy, User-Visible Strings + +### Task 18: Update `Dockerfile` + +**Files:** +- Modify: `Dockerfile` + +- [ ] **Step 1: Replace `fabledscryer` references** + +Change: +```dockerfile +COPY fabledscryer/ fabledscryer/ +``` +to +```dockerfile +COPY roundtable/ roundtable/ +``` + +Change the CMD: +```dockerfile +CMD ["roundtable", "--host", "0.0.0.0", "--port", "5000"] +``` + +Leave everything else (apt packages, gosu, app user, EXPOSE 5000) untouched. + +- [ ] **Step 2: Commit** + +```bash +git add Dockerfile +git commit -m "build: update Dockerfile for roundtable package" +``` + +### Task 19: Update `entrypoint.sh` + +**Files:** +- Modify: `entrypoint.sh` + +- [ ] **Step 1: Replace the `nut_setup.py` invocation path** + +Change every `/app/fabledscryer/nut_setup.py` to `/app/roundtable/nut_setup.py`. + +Update any comment that says "FabledScryer container entrypoint." to "Roundtable container entrypoint." + +- [ ] **Step 2: Commit** + +```bash +git add entrypoint.sh +git commit -m "build: entrypoint.sh uses roundtable package path" +``` + +### Task 20: Update `docker-compose.yml` + +**Files:** +- Modify: `docker-compose.yml` + +- [ ] **Step 1: Replace the service definition** + +```yaml +services: + roundtable: + build: . + container_name: roundtable + image: roundtable:latest + ports: + - "5000:5000" + volumes: + - app_data:/data + - ./plugins:/app/plugins:ro + - /mnt/Data/traefik/log:/var/log/traefik:ro + - /var/run/docker.sock:/var/run/docker.sock:ro + environment: + - ROUNDTABLE_DATABASE_URL=postgresql+asyncpg://roundtable:roundtable@db/roundtable + depends_on: + db: + condition: service_healthy + restart: unless-stopped + + db: + image: postgres:16-alpine + environment: + POSTGRES_USER: roundtable + POSTGRES_PASSWORD: roundtable + POSTGRES_DB: roundtable + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U roundtable"] + interval: 5s + timeout: 5s + retries: 5 + restart: unless-stopped + +volumes: + pgdata: + app_data: +``` + +**Data note:** the existing dev database is named `fabledscryer` with user `fabledscryer`. For local dev, either (a) drop and recreate the DB with the new name, or (b) leave the existing compose `environment` values pointing at the old DB name if you want to preserve data. Pick based on whether the local DB has data worth keeping — document the choice in the commit message. + +- [ ] **Step 2: Commit** + +```bash +git add docker-compose.yml +git commit -m "build: docker-compose service → roundtable" +``` + +### Task 21: Flip user-visible wordmark and title + +**Files:** +- Modify: `roundtable/templates/base.html` + +- [ ] **Step 1: Update the `<title>` block (around line 6)** + +```html +<title>{% block title %}Roundtable{% endblock %} +``` + +- [ ] **Step 2: Update the nav wordmark (around line 214)** + +Change `Fabled Scryer` inside `nav .brand` to `Roundtable`. + +- [ ] **Step 3: Grep for any other user-visible "Fabled Scryer" strings in templates** + +```bash +grep -rn "Fabled Scryer\|FabledScryer" roundtable/templates/ +``` + +Replace each with "Roundtable". + +- [ ] **Step 4: Commit** + +```bash +git add roundtable/templates/ +git commit -m "copy: flip visible wordmark → Roundtable" +``` + +### Task 22: Manual container verification + +- [ ] **Step 1: Rebuild and run** + +```bash +docker compose down +docker compose up --build +``` + +- [ ] **Step 2: Verify** + - Container named `roundtable` runs + - Browser shows "Roundtable" in tab title and nav + - Login, dashboard, and error pages all render with the new palette and wordmark + - Logs show no import errors and no unhandled deprecation warnings + +- [ ] **Step 3: Stop** + +```bash +docker compose down +``` + +**PR 4 done.** Open PR with title `feat: roundtable container & wordmark flip`. + +--- + +## PR 5 — Docs & Repo + +### Task 23: Sweep `README.md` and `docs/` + +**Files:** +- Modify: `README.md`, `docs/**/*.md` + +- [ ] **Step 1: Find every remaining reference** + +```bash +grep -rn "fabledscryer\|FabledScryer\|Fabled Scryer" README.md docs/ +``` + +- [ ] **Step 2: Replace mechanically, then proofread** + +```bash +grep -rl "fabledscryer\|FabledScryer\|Fabled Scryer" README.md docs/ \ + | xargs sed -i \ + -e 's/FabledScryer/Roundtable/g' \ + -e 's/fabledscryer/roundtable/g' \ + -e 's/Fabled Scryer/Roundtable/g' +``` + +Then read each changed file top-to-bottom and fix any mangled sentences (e.g. capitalization at the start of sentences, code blocks that now reference a directory that still needs a `./` prefix, etc.). Pay extra attention to: +- `docs/architecture.md` +- `docs/reference/code-map.md` +- `docs/core/configuration.md` +- `docs/plugins/writing-a-plugin.md` + +- [ ] **Step 3: Commit** + +```bash +git add README.md docs/ +git commit -m "docs: roundtable rename sweep" +``` + +### Task 24: Rename the Forgejo repo + +- [ ] **Step 1: Via Forgejo UI or API, rename the repo from `FabledScryer` to `Roundtable`.** The old URL will redirect for one grace period. + +- [ ] **Step 2: Update your local remote URL** + +```bash +git remote set-url origin +git remote -v +``` + +- [ ] **Step 3: Optionally rename the local working directory** + +```bash +# from the parent dir +cd .. +mv FabledScryer Roundtable +cd Roundtable +``` + +- [ ] **Step 4: No commit.** + +**PR 5 done.** Open PR with title `docs: roundtable rename sweep`. + +--- + +## PR 6 — Cleanup (deferred one release cycle) + +### Task 25: Remove env var fallback shim + +**Files:** +- Modify: `roundtable/config.py` + +- [ ] **Step 1: Delete `_env_with_fallback` and inline direct `os.environ.get("ROUNDTABLE_*")` lookups** + +Replace each `_env_with_fallback("ROUNDTABLE_X", "FABLEDSCRYER_X")` call with `os.environ.get("ROUNDTABLE_X")`. Delete the helper. + +- [ ] **Step 2: Grep to confirm no `FABLEDSCRYER_` references remain** + +```bash +grep -rn "FABLEDSCRYER_\|FABLEDNETMON_" . +``` + +Expected: zero hits (or only historical commit messages, which don't show up in `grep`). + +- [ ] **Step 3: Commit** + +```bash +git add roundtable/config.py +git commit -m "refactor: remove FABLEDSCRYER_* env var fallback shim" +``` + +### Task 26: Final sweep + +- [ ] **Step 1: Search the entire repo one last time** + +```bash +grep -rn "fabledscryer\|FabledScryer\|Fabled Scryer\|FABLEDSCRYER\|FABLEDNETMON" . +``` + +Expected: zero results outside of `.git/` and historical `docs/superpowers/plans/` or `docs/superpowers/specs/` files (those record the rename intentionally — leave them). + +- [ ] **Step 2: If any unexpected hits appear, fix and commit** + +```bash +git add -A +git commit -m "refactor: final rename sweep" +``` + +**PR 6 done.** Rebrand complete. + +--- + +## Self-Review Notes + +- Spec coverage: all 5 spec sections mapped to PRs 1–5; PR 6 handles the cleanup implied by the shim in PR 3. +- No placeholders — every template replacement includes concrete code; every grep includes the actual pattern. +- Type consistency: config helper is `_env_with_fallback` in both Task 15 and Task 25; env var names are `ROUNDTABLE_DATABASE_URL`, `ROUNDTABLE_PLUGIN_DIR`, `ROUNDTABLE_SECRET_KEY` consistently. +- Tests intentionally skipped per project feedback; verification is manual (browser + container). From e8691bc7e41b47a5efa06c1fb2e1dc918bbba0ae Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 13 Apr 2026 14:51:12 -0400 Subject: [PATCH 11/46] style: pewter & gold palette tokens Co-Authored-By: Claude Opus 4.6 --- fabledscryer/templates/base.html | 44 +++++++++++++++++--------------- 1 file changed, 23 insertions(+), 21 deletions(-) diff --git a/fabledscryer/templates/base.html b/fabledscryer/templates/base.html index e1e7eb1..40b93d8 100644 --- a/fabledscryer/templates/base.html +++ b/fabledscryer/templates/base.html @@ -11,27 +11,29 @@