feat: managed NUT setup, plugin config improvements, migration fix

- 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 <json> 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 <noreply@anthropic.com>
This commit is contained in:
2026-03-23 08:15:48 -04:00
parent ced1eebe1d
commit 93d5ed8fb0
7 changed files with 444 additions and 21 deletions
+14 -4
View File
@@ -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"]
+13
View File
@@ -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
+45
View File
@@ -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 "$@"
+90 -14
View File
@@ -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"):
+121
View File
@@ -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)
+150 -2
View File
@@ -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/")
+11 -1
View File
@@ -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 #}
<div style="display:flex;align-items:flex-start;gap:0.5rem;padding:0.5rem 0.75rem;
background:var(--bg);border-radius:5px;border:1px solid var(--border);
font-size:0.82rem;color:var(--text-muted);">
<span style="flex-shrink:0;">📋</span>
<span><strong style="color:var(--text);">{{ cfg_key }}</strong> — list config, edit in
<code>plugin.yaml</code> ({{ cfg_default | length }} item{{ 's' if cfg_default | length != 1 }}).</span>
</div>
{% elif cfg_default is mapping %}
{# Nested dict group #}
<div style="padding:0.6rem 0.75rem;background:var(--bg);border-radius:5px;border:1px solid var(--border);">
<div style="font-size:0.75rem;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.05em;margin-bottom:0.6rem;">{{ cfg_key }}</div>