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:
@@ -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