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:
+184
-73
@@ -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 = []
|
||||
|
||||
Reference in New Issue
Block a user