feat: per-plugin settings pages and multi-repo plugin catalog

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/<name>/ 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/<name>/;
  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 <noreply@anthropic.com>
This commit is contained in:
2026-03-23 18:41:31 -04:00
parent 8558d15eeb
commit 00dc4cdc9c
5 changed files with 509 additions and 218 deletions
+184 -73
View File
@@ -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/<name>/")
@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/<name>/")
@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/<int:idx>/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/<int:idx>/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 = []