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 #} +
plugin.yaml ({{ cfg_default | length }} item{{ 's' if cfg_default | length != 1 }}).
+