refactor(plugins): no plugin catalog; plugins ship in the image or are mounted (#5374)
CI / lint (push) Successful in 3s
CI / integration (push) Failing after 1m12s
CI / unit (push) Successful in 1m25s
CI / publish (push) Skipped

First-party plugins have shipped inside the image since a7a281c, and the
catalog's only repository (Steward-plugins) answers 404, so Steward polled a
dead URL every minute for nothing. The operator chose to remove the catalog
entirely (#5214 "Review: readable and fast on the live instance").

Removed:
- core/plugin_index.py and the plugin_catalog_refresh startup task;
- the plugins.index_url default, and migration 0035 deletes the stored
  plugins.index_url / plugins.repositories rows;
- the Settings -> Plugins repository editor, catalog, refresh, install and
  reload routes and their templates;
- the download/checksum/extract install path in plugin_manager, and the
  PLUGIN_INSTALL_DIR config it wrote to;
- plugins/index.yaml, docs/plugins/index.yaml.example and the guide's
  "Publishing to the Catalog" section (now "Shipping a Plugin").

Kept: enabling and configuring plugins, hot-reload on enable, restart, orphan
cleanup, and the external root (/data/plugins) for operator-mounted plugins.
tests/test_no_plugin_catalog.py guards the routes, default and task.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 13:15:55 -04:00
co-authored by Claude Opus 5.5
parent 501a345aed
commit f67678998c
17 changed files with 70 additions and 1258 deletions
@@ -1,114 +0,0 @@
{# settings/_catalog_partial.html — HTMX partial for plugin catalog #}
<div id="plugin-catalog">
{% if error %}
<div style="color:var(--text-muted);font-size:0.85rem;padding:0.5rem 0.75rem;background:var(--bg);border:1px solid var(--border);border-radius:6px;margin-bottom:0.75rem;">
{{ error }}
</div>
{% endif %}
{% if catalog %}
<div style="display:grid;gap:0.75rem;">
{% for plugin in catalog %}
{% set is_installed = plugin.name in installed_names %}
{% set is_loaded = plugin.name in loaded_names %}
{% set installed_ver = installed_versions.get(plugin.name, "") %}
{# update_available: catalog version is strictly newer than what's on disk #}
{% set update_available = is_installed and installed_ver and installed_ver != plugin.version %}
<div class="card" style="margin-bottom:0;padding:1rem 1.25rem;{% if update_available %}border-color:var(--gold);{% endif %}">
<div style="display:flex;align-items:flex-start;gap:1rem;">
<div style="flex:1;min-width:0;">
<div style="display:flex;align-items:center;gap:0.6rem;flex-wrap:wrap;">
<span style="font-weight:500;color:var(--text);font-size:0.9rem;">{{ plugin.name }}</span>
<span style="font-size:0.75rem;color:var(--text-muted);">v{{ plugin.version }}</span>
{% if update_available %}
<span class="badge badge-gold" style="font-size:0.68rem;">
Update available — installed v{{ installed_ver }}
</span>
{% elif is_loaded %}
<span class="badge badge-green" style="font-size:0.68rem;">Active</span>
{% elif is_installed %}
<span class="badge" style="background:var(--bg-elevated);color:var(--text-muted);font-size:0.68rem;">Installed</span>
{% endif %}
{% for tag in plugin.tags %}
<span style="font-size:0.68rem;padding:0.1em 0.4em;background:var(--bg-elevated);color:var(--text-muted);border-radius:3px;">{{ tag }}</span>
{% endfor %}
</div>
{% if plugin.description %}
<div style="font-size:0.82rem;color:var(--text-muted);margin-top:0.25rem;">{{ plugin.description }}</div>
{% endif %}
<div style="font-size:0.75rem;color:var(--text-dim);margin-top:0.15rem;display:flex;gap:0.75rem;align-items:center;flex-wrap:wrap;">
{% if plugin.author %}<span>by {{ plugin.author }}</span>{% endif %}
{% if plugin.license %}<span style="color:var(--text-muted);">{{ plugin.license }}</span>{% endif %}
{% if plugin.min_app_version %}<span style="color:var(--text-muted);">requires v{{ plugin.min_app_version }}+</span>{% endif %}
</div>
</div>
<div style="display:flex;gap:0.5rem;flex-shrink:0;align-items:center;">
{% if plugin.homepage %}
<a href="{{ plugin.homepage }}" target="_blank" rel="noopener"
class="btn btn-ghost btn-sm" style="font-size:0.78rem;">Docs</a>
{% elif plugin.repository_url %}
<a href="{{ plugin.repository_url }}" target="_blank" rel="noopener"
class="btn btn-ghost btn-sm" style="font-size:0.78rem;">Source</a>
{% endif %}
{% if (update_available or not is_installed) and not plugin.checksum_sha256 %}
{# Steward installs only what it can verify against the catalog's checksum #}
<span style="font-size:0.75rem;color:var(--text-muted);max-width:14rem;text-align:right;"
title="Add checksum_sha256 to this plugin's entry in the repository's index.yaml">
Can't {{ "update" if update_available else "install" }}: the catalog lists no checksum
</span>
{% elif update_available %}
{# Installed but outdated — offer update (download + restart) #}
<form method="post"
action="/settings/plugins/install/{{ plugin.name }}"
hx-post="/settings/plugins/install/{{ plugin.name }}"
hx-target="#catalog-result-{{ plugin.name }}"
hx-swap="outerHTML"
style="margin:0;">{{ csrf_field() }}
<button type="submit" class="btn btn-warn btn-sm" style="font-size:0.78rem;">Update</button>
</form>
{% elif is_loaded %}
{# Active and up to date — no action needed #}
{% elif is_installed %}
{# Installed but not active — offer hot-reload #}
<form method="post"
action="/settings/plugins/reload/{{ plugin.name }}"
hx-post="/settings/plugins/reload/{{ plugin.name }}"
hx-target="#catalog-result-{{ plugin.name }}"
hx-swap="outerHTML"
style="margin:0;">{{ csrf_field() }}
<button type="submit" class="btn btn-sm" style="font-size:0.78rem;">Activate</button>
</form>
{% else %}
{# Not installed — offer install #}
<form method="post"
action="/settings/plugins/install/{{ plugin.name }}"
hx-post="/settings/plugins/install/{{ plugin.name }}"
hx-target="#catalog-result-{{ plugin.name }}"
hx-swap="outerHTML"
style="margin:0;">{{ csrf_field() }}
<button type="submit" class="btn btn-sm" style="font-size:0.78rem;">Install</button>
</form>
{% endif %}
</div>
</div>
{# Inline result placeholder — replaced by _install_result.html after action #}
<div id="catalog-result-{{ plugin.name }}"></div>
</div>
{% endfor %}
</div>
{% elif not error %}
<div style="color:var(--text-muted);font-size:0.85rem;">No plugins listed in the catalog.</div>
{% endif %}
</div>
@@ -1,20 +0,0 @@
{# settings/_install_result.html — inline result after install/reload action #}
<div id="catalog-result-{{ name }}"
style="margin-top:0.6rem;padding:0.5rem 0.75rem;border-radius:5px;font-size:0.82rem;
background:{% if success %}var(--green-dim){% else %}var(--red-dim){% endif %};
color:{% if success %}var(--green){% else %}#ff8090{% endif %};">
{{ message }}
{% if restart_required %}
&nbsp;·&nbsp;
<form method="post" action="/settings/plugins/restart/"
hx-post="/settings/plugins/restart/"
hx-target="#restart-banner"
hx-swap="outerHTML"
style="display:inline;margin:0;">{{ csrf_field() }}
<button type="submit"
style="background:none;border:none;padding:0;color:var(--yellow);cursor:pointer;font-size:0.82rem;text-decoration:underline;">
Restart now
</button>
</form>
{% endif %}
</div>
@@ -1,99 +0,0 @@
{# 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:500;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|int }})">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|int }})">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 charted. 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:500;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>
+5 -40
View File
@@ -76,7 +76,8 @@
<div class="section-title" style="margin-bottom:0.4rem;">Integrations</div>
<p style="color:var(--text-muted);font-size:0.82rem;margin:0 0 0.75rem;max-width:720px;">
External systems Steward pulls data from (e.g. Traefik, UniFi). Enable and configure each one
to surface its own dashboard. Off until you set them up.
to surface its own dashboard. Off until you set them up. A plugin of your own goes in
<code>/data/plugins</code>; restart and it appears here.
</p>
{% if integrations %}
<div style="display:grid;gap:0.6rem;max-width:720px;margin-bottom:1.5rem;">
@@ -84,7 +85,7 @@
</div>
{% else %}
<div class="card" style="color:var(--text-muted);font-size:0.9rem;margin-bottom:1.5rem;">
No integrations installed. Browse the catalog below.
No integrations installed.
</div>
{% endif %}
@@ -97,8 +98,8 @@
<p style="color:var(--text-muted);font-size:0.82rem;margin:0 0 0.75rem;">
Plugins Steward has settings for, or tried to load, but cannot find on disk — including
anything counted by the "failed to load" banner. This usually means the plugin was
removed, but it also happens when an external plugin directory isn't mounted or an
install failed part-way, so nothing is deleted automatically. Removing an entry
removed, but it also happens when an external plugin directory isn't mounted, so
nothing is deleted automatically. Removing an entry
discards that plugin's saved configuration, including any credentials.
</p>
<div style="display:grid;gap:0.6rem;">
@@ -145,40 +146,4 @@
</div>
{% 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 Steward 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="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;"
hx-post="/settings/plugins/catalog/refresh"
hx-target="#plugin-catalog"
hx-swap="outerHTML">
Refresh
</button>
</div>
{% if repos %}
<div id="plugin-catalog"
hx-get="/settings/plugins/catalog/"
hx-trigger="load"
hx-swap="outerHTML">
<div style="color:var(--text-muted);font-size:0.85rem;padding:0.5rem 0;">Loading catalog…</div>
</div>
{% else %}
<div id="plugin-catalog" style="color:var(--text-muted);font-size:0.85rem;padding:0.5rem 0;">
Add a plugin repository above to browse available plugins.
</div>
{% endif %}
</div>
{% endblock %}