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:
@@ -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()
|
||||
|
||||
+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 = []
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
{# settings/_plugin_repos.html — HTMX partial for plugin repository management #}
|
||||
<div id="plugin-repos">
|
||||
|
||||
{% if repos %}
|
||||
<div style="display:grid;gap:0.6rem;margin-bottom:0.6rem;">
|
||||
{% for repo in repos %}
|
||||
{% set idx = loop.index0 %}
|
||||
|
||||
<div class="card" style="padding:0;overflow:visible;">
|
||||
|
||||
{# ── View row ────────────────────────────────────────────────────────── #}
|
||||
<div id="plugin-repo-view-{{ idx }}"
|
||||
style="display:flex;align-items:center;gap:0.75rem;padding:0.85rem 1rem;">
|
||||
<div style="flex:1;min-width:0;">
|
||||
<div style="font-weight:600;font-size:0.88rem;">{{ repo.name or repo.url }}</div>
|
||||
<div style="font-size:0.77rem;color:var(--text-muted);margin-top:0.1rem;
|
||||
white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">{{ repo.url }}</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:0.4rem;flex-shrink:0;">
|
||||
<button class="btn btn-ghost btn-sm" style="font-size:0.78rem;"
|
||||
onclick="toggleRepoEdit({{ idx }})">Edit</button>
|
||||
<button class="btn btn-danger btn-sm" style="font-size:0.78rem;"
|
||||
hx-post="/settings/plugins/repos/{{ idx }}/remove"
|
||||
hx-target="#plugin-repos"
|
||||
hx-swap="outerHTML"
|
||||
hx-confirm="Remove repository '{{ repo.name or repo.url }}'?">×</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Inline edit form ──────────────────────────────────────────────── #}
|
||||
<div id="plugin-repo-edit-{{ idx }}"
|
||||
style="display:none;border-top:1px solid var(--border);padding:1rem;">
|
||||
<form hx-post="/settings/plugins/repos/{{ idx }}/save"
|
||||
hx-target="#plugin-repos"
|
||||
hx-swap="outerHTML">
|
||||
<div style="display:grid;grid-template-columns:1fr 2fr;gap:0.6rem;">
|
||||
<div class="form-group" style="margin:0;">
|
||||
<label style="font-size:0.8rem;">Name</label>
|
||||
<input type="text" name="name" value="{{ repo.name }}" placeholder="My Plugins">
|
||||
</div>
|
||||
<div class="form-group" style="margin:0;">
|
||||
<label style="font-size:0.8rem;">Index URL</label>
|
||||
<input type="url" name="url" value="{{ repo.url }}"
|
||||
placeholder="https://example.com/plugins/index.yaml" required>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:0.5rem;margin-top:0.75rem;">
|
||||
<button type="submit" class="btn btn-sm">Save</button>
|
||||
<button type="button" class="btn btn-ghost btn-sm"
|
||||
onclick="toggleRepoEdit({{ idx }})">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card" style="color:var(--text-muted);font-size:0.88rem;text-align:center;padding:1.5rem;margin-bottom:0.6rem;">
|
||||
No repositories configured. Add one below.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ── Add repository ────────────────────────────────────────────────────────── #}
|
||||
<details class="card" style="padding:0;overflow:hidden;">
|
||||
<summary style="display:flex;align-items:center;gap:0.75rem;padding:0.85rem 1rem;
|
||||
cursor:pointer;list-style:none;user-select:none;">
|
||||
<span style="flex:1;font-weight:600;font-size:0.875rem;color:var(--text);">Add Repository</span>
|
||||
<span class="btn btn-sm" style="pointer-events:none;">New ▾</span>
|
||||
</summary>
|
||||
<form hx-post="/settings/plugins/repos/add"
|
||||
hx-target="#plugin-repos"
|
||||
hx-swap="outerHTML"
|
||||
style="padding:1rem;padding-top:0;border-top:1px solid var(--border);">
|
||||
<div style="display:grid;grid-template-columns:1fr 2fr;gap:0.6rem;padding-top:1rem;">
|
||||
<div class="form-group" style="margin:0;">
|
||||
<label style="font-size:0.8rem;">Name</label>
|
||||
<input type="text" name="name" placeholder="My Plugins">
|
||||
</div>
|
||||
<div class="form-group" style="margin:0;">
|
||||
<label style="font-size:0.8rem;">Index URL</label>
|
||||
<input type="url" name="url"
|
||||
placeholder="https://example.com/plugins/index.yaml" required>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-sm" style="margin-top:0.75rem;width:100%;">
|
||||
Add Repository
|
||||
</button>
|
||||
</form>
|
||||
</details>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function toggleRepoEdit(idx) {
|
||||
var edit = document.getElementById('plugin-repo-edit-' + idx);
|
||||
edit.style.display = edit.style.display === 'none' ? 'block' : 'none';
|
||||
}
|
||||
</script>
|
||||
@@ -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" %}
|
||||
|
||||
<div style="display:flex;align-items:center;gap:1rem;margin-bottom:1.5rem;">
|
||||
<a href="/settings/plugins/" class="btn btn-ghost btn-sm">← Plugins</a>
|
||||
<h1 class="page-title" style="margin-bottom:0;">{{ plugin.get('name', plugin._dir) }}</h1>
|
||||
<span style="color:var(--text-muted);font-size:0.82rem;">v{{ plugin.get('version', '?') }}</span>
|
||||
{% if fail_reason %}
|
||||
<span style="font-size:0.75rem;padding:0.15em 0.55em;border-radius:3px;
|
||||
background:color-mix(in srgb,var(--red) 15%,var(--bg));color:var(--red);">Error</span>
|
||||
{% elif is_loaded %}
|
||||
<span style="font-size:0.75rem;padding:0.15em 0.55em;border-radius:3px;
|
||||
background:color-mix(in srgb,var(--green) 12%,var(--bg));color:var(--green);">Loaded</span>
|
||||
{% elif plugin._enabled %}
|
||||
<span style="font-size:0.75rem;padding:0.15em 0.55em;border-radius:3px;
|
||||
background:var(--bg-elevated);color:var(--text-muted);">Enabled (restart required)</span>
|
||||
{% else %}
|
||||
<span style="font-size:0.75rem;padding:0.15em 0.55em;border-radius:3px;
|
||||
background:var(--bg-elevated);color:var(--text-muted);">Disabled</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if fail_reason %}
|
||||
<div style="display:flex;align-items:flex-start;gap:0.5rem;padding:0.6rem 0.9rem;margin-bottom:1rem;
|
||||
background:color-mix(in srgb,var(--red) 10%,var(--bg));
|
||||
border:1px solid color-mix(in srgb,var(--red) 30%,var(--border));
|
||||
border-radius:6px;font-size:0.84rem;max-width:720px;">
|
||||
<span style="color:var(--red);flex-shrink:0;">✕</span>
|
||||
<span><strong style="color:var(--red);">Failed to load</strong>
|
||||
<span style="color:var(--text-muted);margin-left:0.3rem;">— {{ fail_reason }}</span>
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if plugin.get('description') %}
|
||||
<p style="color:var(--text-muted);font-size:0.88rem;margin-bottom:1.25rem;max-width:620px;">{{ plugin.description }}</p>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/settings/plugins/{{ plugin._dir }}/" style="max-width:720px;">
|
||||
|
||||
{# ── Enable toggle ────────────────────────────────────────────────────────── #}
|
||||
<div class="card" style="padding:1rem 1.25rem;margin-bottom:1rem;">
|
||||
<div style="display:flex;align-items:center;gap:0.75rem;">
|
||||
<input type="checkbox"
|
||||
name="plugin.{{ plugin._dir }}.enabled"
|
||||
id="plugin-{{ plugin._dir }}-enabled"
|
||||
{% if plugin._enabled %}checked{% endif %}>
|
||||
<label for="plugin-{{ plugin._dir }}-enabled" style="margin:0;font-weight:600;cursor:pointer;">
|
||||
Enable this plugin
|
||||
</label>
|
||||
</div>
|
||||
<div style="font-size:0.8rem;color:var(--text-muted);margin-top:0.4rem;margin-left:1.6rem;">
|
||||
Enable/disable changes take effect after a restart.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Config fields ─────────────────────────────────────────────────────────── #}
|
||||
{% if plugin.get('config') %}
|
||||
<div class="card" style="padding:1.25rem;margin-bottom:1rem;">
|
||||
<div class="section-title" style="margin-bottom:1rem;">Configuration</div>
|
||||
<div style="display:grid;gap:0.75rem;">
|
||||
|
||||
{% 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 %}
|
||||
<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 %}
|
||||
<div style="padding:0.75rem;background:var(--bg);border-radius:5px;border:1px solid var(--border);">
|
||||
<div style="font-size:0.74rem;color:var(--text-muted);text-transform:uppercase;
|
||||
letter-spacing:0.05em;margin-bottom:0.6rem;">{{ cfg_key }}</div>
|
||||
{% set sub_cfg = plugin._config.get(cfg_key, cfg_default) if plugin._config.get(cfg_key, cfg_default) is mapping else cfg_default %}
|
||||
<div style="display:grid;gap:0.5rem;">
|
||||
{% for sub_key, sub_default in cfg_default.items() %}
|
||||
{% if sub_default is sameas false or sub_default is sameas true %}
|
||||
<div style="display:flex;align-items:center;gap:0.5rem;">
|
||||
<input type="checkbox"
|
||||
name="plugin.{{ plugin._dir }}.{{ cfg_key }}.{{ sub_key }}"
|
||||
id="plugin-{{ plugin._dir }}-{{ cfg_key }}-{{ sub_key }}"
|
||||
{% if sub_cfg.get(sub_key, sub_default) %}checked{% endif %}>
|
||||
<label for="plugin-{{ plugin._dir }}-{{ cfg_key }}-{{ sub_key }}"
|
||||
style="margin:0;font-size:0.85rem;color:var(--text);">{{ sub_key }}</label>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="form-group" style="margin:0;">
|
||||
<label style="font-size:0.82rem;">{{ sub_key }}</label>
|
||||
<input type="text"
|
||||
name="plugin.{{ plugin._dir }}.{{ cfg_key }}.{{ sub_key }}"
|
||||
value="{{ sub_cfg.get(sub_key, sub_default) }}">
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% elif cfg_default is sameas false or cfg_default is sameas true %}
|
||||
<div style="display:flex;align-items:center;gap:0.5rem;">
|
||||
<input type="checkbox"
|
||||
name="plugin.{{ plugin._dir }}.{{ cfg_key }}"
|
||||
id="plugin-{{ plugin._dir }}-{{ cfg_key }}"
|
||||
{% if plugin._config.get(cfg_key, cfg_default) %}checked{% endif %}>
|
||||
<label for="plugin-{{ plugin._dir }}-{{ cfg_key }}"
|
||||
style="margin:0;font-size:0.85rem;color:var(--text);">{{ cfg_key }}</label>
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
<div class="form-group" style="margin:0;">
|
||||
<label style="font-size:0.82rem;">{{ cfg_key }}</label>
|
||||
<input type="{% if 'password' in cfg_key or 'secret' in cfg_key %}password{% else %}text{% endif %}"
|
||||
name="plugin.{{ plugin._dir }}.{{ cfg_key }}"
|
||||
value="{% if 'password' in cfg_key or 'secret' in cfg_key %}{% else %}{{ plugin._config.get(cfg_key, cfg_default) }}{% endif %}"
|
||||
{% if 'password' in cfg_key or 'secret' in cfg_key %}placeholder="••••••••"{% endif %}>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<button type="submit" class="btn">Save</button>
|
||||
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -5,7 +5,7 @@
|
||||
{% set active_tab = "plugins" %}
|
||||
{% include "settings/_tabs.html" %}
|
||||
|
||||
{# ── Restart banner (replaced by _restart_pending.html after action) #}
|
||||
{# ── Restart banner ────────────────────────────────────────────────────────── #}
|
||||
<div id="restart-banner"></div>
|
||||
|
||||
{# ── Installed Plugins ─────────────────────────────────────────────────────── #}
|
||||
@@ -22,141 +22,73 @@
|
||||
</div>
|
||||
|
||||
{% if not discovered_plugins %}
|
||||
<div class="card" style="color:var(--text-muted);font-size:0.9rem;">
|
||||
<div class="card" style="color:var(--text-muted);font-size:0.9rem;margin-bottom:1.5rem;">
|
||||
No plugins found in the plugin directory.
|
||||
</div>
|
||||
{% else %}
|
||||
<form method="post" action="/settings/plugins/">
|
||||
<div style="display:grid;gap:1rem;max-width:720px;">
|
||||
|
||||
{# Plugin index URL #}
|
||||
<div class="card" style="padding:1rem 1.25rem;">
|
||||
<div class="section-title" style="margin-bottom:0.6rem;">Plugin Index URL</div>
|
||||
<div class="form-group" style="margin:0;">
|
||||
<input type="url" name="plugins.index_url"
|
||||
value="{{ settings.get('plugins.index_url', '') }}"
|
||||
placeholder="https://raw.githubusercontent.com/your-org/fabledscryer-plugins/main/index.yaml"
|
||||
style="font-size:0.85rem;">
|
||||
</div>
|
||||
<div style="font-size:0.77rem;color:var(--text-muted);margin-top:0.4rem;">
|
||||
URL to the remote <code>index.yaml</code> used by the plugin catalog below.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:grid;gap:0.6rem;max-width:720px;margin-bottom:1.5rem;">
|
||||
{% for plugin in discovered_plugins %}
|
||||
<div class="card" style="padding:1.25rem;">
|
||||
{% set fail_reason = plugin_failures.get(plugin._dir) %}
|
||||
<div class="card" style="padding:0.85rem 1rem;display:flex;align-items:center;gap:0.75rem;">
|
||||
|
||||
{# Plugin header: enable toggle + name/description #}
|
||||
<div style="display:flex;align-items:flex-start;gap:0.75rem;margin-bottom:{% if plugin.get('config') %}1rem{% else %}0{% endif %};">
|
||||
<input type="checkbox"
|
||||
name="plugin.{{ plugin._dir }}.enabled"
|
||||
id="plugin-{{ plugin._dir }}-enabled"
|
||||
{% if plugin._enabled %}checked{% endif %}
|
||||
style="margin-top:0.2rem;flex-shrink:0;">
|
||||
<label for="plugin-{{ plugin._dir }}-enabled" style="margin:0;cursor:pointer;">
|
||||
<span style="font-weight:600;color:var(--text);">{{ plugin.get('name', plugin._dir) }}</span>
|
||||
<span style="color:var(--text-muted);font-size:0.78rem;margin-left:0.4rem;">v{{ plugin.get('version', '?') }}</span>
|
||||
{% if plugin.get('description') %}
|
||||
<div style="color:var(--text-muted);font-size:0.83rem;margin-top:0.15rem;font-weight:normal;">{{ plugin.description }}</div>
|
||||
{% endif %}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{# Failure notice — shown when this plugin failed to load #}
|
||||
{% set fail_reason = plugin_failures.get(plugin._dir) %}
|
||||
{# Status indicator #}
|
||||
{% if fail_reason %}
|
||||
<div style="display:flex;align-items:flex-start;gap:0.5rem;padding:0.5rem 0.75rem;
|
||||
margin-top:0.6rem;background:color-mix(in srgb,var(--red) 10%,var(--bg));
|
||||
border:1px solid color-mix(in srgb,var(--red) 30%,var(--border));
|
||||
border-radius:5px;font-size:0.82rem;">
|
||||
<span style="color:var(--red);flex-shrink:0;">✕</span>
|
||||
<span><strong style="color:var(--red);">Failed to load</strong>
|
||||
<span style="color:var(--text-muted);margin-left:0.3rem;">— {{ fail_reason }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<span style="width:8px;height:8px;border-radius:50%;background:var(--red);flex-shrink:0;" title="{{ fail_reason }}"></span>
|
||||
{% elif plugin._enabled %}
|
||||
<span style="width:8px;height:8px;border-radius:50%;background:var(--green);flex-shrink:0;"></span>
|
||||
{% else %}
|
||||
<span style="width:8px;height:8px;border-radius:50%;background:var(--border-mid);flex-shrink:0;"></span>
|
||||
{% endif %}
|
||||
|
||||
{# Config fields — only shown when there are config keys #}
|
||||
{% if plugin.get('config') %}
|
||||
<div style="border-top:1px solid var(--border);padding-top:1rem;display:grid;gap:0.6rem;">
|
||||
|
||||
{% 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 #}
|
||||
<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>
|
||||
{% set sub_cfg = plugin._config.get(cfg_key, cfg_default) if plugin._config.get(cfg_key, cfg_default) is mapping else cfg_default %}
|
||||
<div style="display:grid;gap:0.5rem;">
|
||||
{% for sub_key, sub_default in cfg_default.items() %}
|
||||
{% if sub_default is sameas false or sub_default is sameas true %}
|
||||
<div style="display:flex;align-items:center;gap:0.5rem;">
|
||||
<input type="checkbox"
|
||||
name="plugin.{{ plugin._dir }}.{{ cfg_key }}.{{ sub_key }}"
|
||||
id="plugin-{{ plugin._dir }}-{{ cfg_key }}-{{ sub_key }}"
|
||||
{% if sub_cfg.get(sub_key, sub_default) %}checked{% endif %}>
|
||||
<label for="plugin-{{ plugin._dir }}-{{ cfg_key }}-{{ sub_key }}"
|
||||
style="margin:0;font-size:0.85rem;color:var(--text);">{{ sub_key }}</label>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="form-group" style="margin:0;">
|
||||
<label style="font-size:0.82rem;">{{ sub_key }}</label>
|
||||
<input type="text"
|
||||
name="plugin.{{ plugin._dir }}.{{ cfg_key }}.{{ sub_key }}"
|
||||
value="{{ sub_cfg.get(sub_key, sub_default) }}">
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% elif cfg_default is sameas false or cfg_default is sameas true %}
|
||||
<div style="display:flex;align-items:center;gap:0.5rem;">
|
||||
<input type="checkbox"
|
||||
name="plugin.{{ plugin._dir }}.{{ cfg_key }}"
|
||||
id="plugin-{{ plugin._dir }}-{{ cfg_key }}"
|
||||
{% if plugin._config.get(cfg_key, cfg_default) %}checked{% endif %}>
|
||||
<label for="plugin-{{ plugin._dir }}-{{ cfg_key }}"
|
||||
style="margin:0;font-size:0.85rem;color:var(--text);">{{ cfg_key }}</label>
|
||||
</div>
|
||||
|
||||
{# Name + version + description #}
|
||||
<div style="flex:1;min-width:0;">
|
||||
<div style="display:flex;align-items:baseline;gap:0.5rem;flex-wrap:wrap;">
|
||||
<span style="font-weight:600;font-size:0.9rem;color:var(--text);">{{ plugin.get('name', plugin._dir) }}</span>
|
||||
<span style="font-size:0.75rem;color:var(--text-muted);">v{{ plugin.get('version', '?') }}</span>
|
||||
{% if fail_reason %}
|
||||
<span style="font-size:0.72rem;padding:0.1em 0.45em;border-radius:3px;
|
||||
background:color-mix(in srgb,var(--red) 15%,var(--bg));
|
||||
color:var(--red);">Error</span>
|
||||
{% elif plugin._enabled %}
|
||||
<span style="font-size:0.72rem;padding:0.1em 0.45em;border-radius:3px;
|
||||
background:color-mix(in srgb,var(--green) 12%,var(--bg));
|
||||
color:var(--green);">Enabled</span>
|
||||
{% else %}
|
||||
<div class="form-group" style="margin:0;">
|
||||
<label style="font-size:0.82rem;">{{ cfg_key }}</label>
|
||||
<input type="{% if 'password' in cfg_key or 'secret' in cfg_key %}password{% else %}text{% endif %}"
|
||||
name="plugin.{{ plugin._dir }}.{{ cfg_key }}"
|
||||
value="{% if 'password' in cfg_key or 'secret' in cfg_key %}{% else %}{{ plugin._config.get(cfg_key, cfg_default) }}{% endif %}"
|
||||
{% if 'password' in cfg_key or 'secret' in cfg_key %}placeholder="••••••••"{% endif %}>
|
||||
</div>
|
||||
<span style="font-size:0.72rem;padding:0.1em 0.45em;border-radius:3px;
|
||||
background:var(--bg-elevated);color:var(--text-muted);">Disabled</span>
|
||||
{% endif %}
|
||||
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if plugin.get('description') %}
|
||||
<div style="font-size:0.8rem;color:var(--text-muted);margin-top:0.1rem;
|
||||
white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">{{ plugin.description }}</div>
|
||||
{% endif %}
|
||||
{% if fail_reason %}
|
||||
<div style="font-size:0.77rem;color:var(--red);margin-top:0.1rem;
|
||||
white-space:nowrap;overflow:hidden;text-overflow:ellipsis;"
|
||||
title="{{ fail_reason }}">{{ fail_reason }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Settings button #}
|
||||
<a href="/settings/plugins/{{ plugin._dir }}/"
|
||||
class="btn btn-ghost btn-sm" style="flex-shrink:0;font-size:0.78rem;">Settings →</a>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
</div>
|
||||
<div style="margin-top:1rem;display:flex;align-items:center;gap:1rem;">
|
||||
<button type="submit" class="btn">Save</button>
|
||||
<span style="font-size:0.82rem;color:var(--text-muted);">Enable/disable changes take effect after a restart.</span>
|
||||
</div>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
{# ── Plugin Repositories ───────────────────────────────────────────────────── #}
|
||||
<div style="margin-bottom:2rem;max-width:720px;">
|
||||
<div class="section-title" style="margin-bottom:0.75rem;">Plugin Repositories</div>
|
||||
<p style="color:var(--text-muted);font-size:0.84rem;margin-bottom:0.75rem;">
|
||||
Repositories are remote <code>index.yaml</code> files that Scryer checks for available
|
||||
and updated plugins. Add repos from other developers to expand the plugin catalog.
|
||||
</p>
|
||||
{% include "settings/_plugin_repos.html" %}
|
||||
</div>
|
||||
|
||||
{# ── Plugin Catalog ────────────────────────────────────────────────────────── #}
|
||||
<div style="margin-top:2rem;">
|
||||
<div style="max-width:720px;">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:0.75rem;">
|
||||
<div class="section-title">Plugin Catalog</div>
|
||||
<button class="btn btn-ghost btn-sm" style="font-size:0.78rem;"
|
||||
@@ -167,8 +99,7 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{% set index_url = settings.get('plugins.index_url', '') %}
|
||||
{% if index_url %}
|
||||
{% if repos %}
|
||||
<div id="plugin-catalog"
|
||||
hx-get="/settings/plugins/catalog/"
|
||||
hx-trigger="load"
|
||||
@@ -177,7 +108,7 @@
|
||||
</div>
|
||||
{% else %}
|
||||
<div id="plugin-catalog" style="color:var(--text-muted);font-size:0.85rem;padding:0.5rem 0;">
|
||||
Configure a Plugin Index URL above to browse available plugins.
|
||||
Add a plugin repository above to browse available plugins.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user