Files
FabledSteward/steward/templates/settings/plugins.html
T
bvandeusenandClaude Opus 5.5 3ad8eed83d
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m32s
CI / unit (push) Successful in 1m39s
CI / publish (push) Successful in 1m14s
feat: the host agent is always on; the MCP sees and starts host maintenance (#5834)
#5834 "The host agent is always on, and the MCP sees a host's
maintenance", step 1 of milestone 520 "The MCP sees what Steward has
grown".

The host agent is a core system (operator, 2026-10-15: "it's very core
to the functionality of Steward as a whole and shouldn't be considered
something that can be toggled off"). ALWAYS_ON_PLUGINS in core settings
makes to_plugins_cfg report it enabled whatever its stored row says, so
it always loads and its MCP tools are always listed. Settings -> Plugins
shows it as "Built in: always on" with no switch, and a save keeps it
enabled. Its code and URLs stay under plugins/host_agent: every
installed agent posts to /plugins/host_agent/ingest.

Maintenance through the MCP:
- list_maintenance_plans: each plan's group, weekly slot, reboot policy,
  next slot, and its latest run (hosts, how far it got, who it skipped
  and why, how it ended).
- get_maintenance_job(host | job): a host's jobs, newest and running
  first, or one job: state, packages updated, reboot, drained by, why it
  failed, when its current state times out.
- run_maintenance(host, reboot="if_required" | "never"): admin, as the
  host page's Update now; refuses a busy host or one with no helper;
  audited "<user> via MCP".
- The overview names a host whose latest job in the last week failed or
  timed out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 12:59:42 -04:00

150 lines
8.4 KiB
HTML

{# steward/templates/settings/plugins.html #}
{% extends "base.html" %}
{% from "_macros.html" import crumbs %}
{% block title %}Settings — Plugins — Steward{% endblock %}
{% from "settings/_tabs.html" import settings_tabs %}
{% block section_tabs %}{{ settings_tabs("plugins") }}{% endblock %}
{% block breadcrumb %}{{ crumbs([("Settings", "/settings/"), ("Plugins", "")]) }}{% endblock %}
{% block content %}
{# ── Restart banner ────────────────────────────────────────────────────────── #}
<div id="restart-banner"></div>
{% macro plugin_card(plugin) %}
{% set fail_reason = plugin_failures.get(plugin._dir) %}
<div class="card" style="padding:0.85rem 1rem;display:flex;align-items:center;gap:0.75rem;">
{% if fail_reason %}
<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 %}
<div style="flex:1;min-width:0;">
<div style="display:flex;align-items:baseline;gap:0.5rem;flex-wrap:wrap;">
<span style="font-weight:500;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);">{{ "Built in" if plugin._always_on else "Enabled" }}</span>
{% else %}
<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 %}
</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>
<a href="/settings/plugins/{{ plugin._dir }}/"
class="btn btn-ghost btn-sm" style="flex-shrink:0;font-size:0.78rem;">Settings →</a>
</div>
{% endmacro %}
{# ── Monitoring capabilities (host facets) ─────────────────────────────────── #}
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:0.4rem;">
<div class="section-title">Monitoring capabilities</div>
<form method="post" action="/settings/plugins/restart/"
hx-post="/settings/plugins/restart/" hx-target="#restart-banner" hx-swap="outerHTML"
data-confirm="Restart the app now? This will briefly interrupt all monitoring."
style="margin:0;">{{ csrf_field() }}
<button type="submit" class="btn btn-ghost btn-sm" style="font-size:0.78rem;">Restart App</button>
</form>
</div>
<p style="color:var(--text-muted);font-size:0.82rem;margin:0 0 0.75rem;max-width:720px;">
Built-in ways to monitor your hosts (agent metrics, SNMP, Docker). These are
facets of a host — you'll see their data on each host in <a href="/hosts/">Hosts</a>,
not as separate areas. On by default.
</p>
{% if capabilities %}
<div style="display:grid;gap:0.6rem;max-width:720px;margin-bottom:1.5rem;">
{% for plugin in capabilities %}{{ plugin_card(plugin) }}{% endfor %}
</div>
{% else %}
<div class="card" style="color:var(--text-muted);font-size:0.9rem;margin-bottom:1.5rem;">None installed.</div>
{% endif %}
{# ── Integrations (external systems) ───────────────────────────────────────── #}
<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. 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;">
{% for plugin in integrations %}{{ plugin_card(plugin) }}{% endfor %}
</div>
{% else %}
<div class="card" style="color:var(--text-muted);font-size:0.9rem;margin-bottom:1.5rem;">
No integrations installed.
</div>
{% endif %}
{# ── Orphaned settings ─────────────────────────────────────────────────────── #}
{# Hidden entirely when there is nothing to clean up — an empty "Configured but
not installed" panel would read as a problem rather than a clean state. #}
{% if orphaned_plugins %}
<div style="margin-bottom:1.5rem;max-width:720px;">
<div class="section-title" style="margin-bottom:0.4rem;">Configured but not installed</div>
<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, 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;">
{% for orphan in orphaned_plugins %}
<div class="card" style="padding:0.85rem 1rem;display:flex;align-items:center;gap:0.75rem;">
<span style="width:8px;height:8px;border-radius:50%;flex-shrink:0;
background:{{ 'var(--red)' if orphan.reason else 'var(--yellow)' }};"
title="{{ orphan.reason or 'Configured but not installed' }}"></span>
<div style="flex:1;min-width:0;">
<div style="display:flex;align-items:baseline;gap:0.5rem;flex-wrap:wrap;">
<span style="font-weight:500;font-size:0.9rem;color:var(--text);">{{ orphan.name }}</span>
{% if orphan.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);">Failed to load</span>
{% else %}
<span style="font-size:0.72rem;padding:0.1em 0.45em;border-radius:3px;
background:var(--yellow-dim);color:var(--yellow);">Not installed</span>
{% endif %}
</div>
{% if orphan.reason %}
<div style="font-size:0.77rem;color:var(--red);margin-top:0.1rem;
white-space:nowrap;overflow:hidden;text-overflow:ellipsis;"
title="{{ orphan.reason }}">{{ orphan.reason }}</div>
{% endif %}
<div style="font-size:0.8rem;color:var(--text-muted);margin-top:0.1rem;">
{% if orphan.removable %}
Settings key <code>plugin.{{ orphan.name }}</code> has no matching plugin.
{% else %}
Enabled by a built-in default with no stored settings to remove — this is a
packaging bug, not leftover configuration. Please report it.
{% endif %}
</div>
</div>
{% if orphan.removable %}
<form method="post" action="/settings/plugins/orphans/{{ orphan.name }}/remove/"
data-confirm="Remove stored settings for &quot;{{ orphan.name }}&quot;? Its saved configuration, including any credentials, will be discarded. This cannot be undone."
style="margin:0;flex-shrink:0;">{{ csrf_field() }}
<button type="submit" class="btn btn-danger btn-sm" style="font-size:0.78rem;">Remove</button>
</form>
{% endif %}
</div>
{% endfor %}
</div>
</div>
{% endif %}
{% endblock %}