From 00dc4cdc9cef7b5c907a033b621dadb83ce63bd6 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 23 Mar 2026 18:41:31 -0400 Subject: [PATCH] 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 %}