feat: plugin fault isolation notices + NUT managed mode fixes

Plugin failures:
- Track load-time failures in _FAILED_PLUGINS dict with human-readable reasons
- Inject plugin_failures into all templates via context processor
- Show admin-only warning banner in base.html when any plugin fails to load
- Show inline failure notice per plugin card in Settings → Plugins

NUT managed mode:
- entrypoint.sh: make nut_setup.py failure non-fatal so the container starts
  even if nut_ups_host isn't set yet (allows fixing config via UI)
- save_plugins: when nut_managed is enabled, auto-set nut_host=localhost so
  the plugin connects to the managed NUT instance without extra manual config

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-23 18:04:53 -04:00
parent 56283df5a1
commit 8558d15eeb
6 changed files with 91 additions and 15 deletions
+17 -8
View File
@@ -24,22 +24,31 @@ set -e
NUT_MANAGED_JSON="/data/nut_managed.json"
if [ "${NUT_MANAGED:-0}" = "1" ] || [ -f "$NUT_MANAGED_JSON" ]; then
NUT_OK=1
if [ -f "$NUT_MANAGED_JSON" ]; then
echo "[entrypoint] Found $NUT_MANAGED_JSON — configuring NUT from settings..."
python3 /app/fabledscryer/nut_setup.py --from-file "$NUT_MANAGED_JSON"
python3 /app/fabledscryer/nut_setup.py --from-file "$NUT_MANAGED_JSON" || {
echo "[entrypoint] Warning: NUT config failed — UPS host may not be set yet. Skipping NUT startup."
NUT_OK=0
}
else
echo "[entrypoint] NUT_MANAGED=1 — configuring NUT from environment..."
python3 /app/fabledscryer/nut_setup.py
python3 /app/fabledscryer/nut_setup.py || {
echo "[entrypoint] Warning: NUT config failed — check NUT_UPS_HOST. Skipping NUT startup."
NUT_OK=0
}
fi
echo "[entrypoint] Starting NUT driver..."
/sbin/upsdrvctl start || echo "[entrypoint] Warning: upsdrvctl returned non-zero"
if [ "$NUT_OK" = "1" ]; then
echo "[entrypoint] Starting NUT driver..."
/sbin/upsdrvctl start || echo "[entrypoint] Warning: upsdrvctl returned non-zero"
echo "[entrypoint] Starting NUT daemon..."
/sbin/upsd || echo "[entrypoint] Warning: upsd returned non-zero"
echo "[entrypoint] Starting NUT daemon..."
/sbin/upsd || echo "[entrypoint] Warning: upsd returned non-zero"
sleep 1
echo "[entrypoint] NUT ready on 127.0.0.1:3493."
sleep 1
echo "[entrypoint] NUT ready on 127.0.0.1:3493."
fi
fi
exec gosu app "$@"
+7 -1
View File
@@ -122,7 +122,13 @@ def create_app(
from .core.plugin_manager import load_plugins
load_plugins(app)
# ── 10. Share-token middleware ─────────────────────────────────────────────
# ── 10. Template context: inject plugin_failures into every response ───────
@app.context_processor
def _inject_plugin_failures():
from .core.plugin_manager import get_plugin_failures
return {"plugin_failures": get_plugin_failures()}
# ── 11. Share-token middleware ─────────────────────────────────────────────
@app.before_request
async def _validate_share_token():
"""If ?s=<token> is present, validate it and set g.share_viewer."""
+33 -6
View File
@@ -22,6 +22,14 @@ logger = logging.getLogger(__name__)
# whether to register a blueprint (new plugin) or skip it (already registered).
_LOADED_PLUGINS: set[str] = set()
# Track plugins that failed to load: name → human-readable reason.
_FAILED_PLUGINS: dict[str, str] = {}
def get_plugin_failures() -> dict[str, str]:
"""Return a copy of the failed-plugin registry (name → error message)."""
return dict(_FAILED_PLUGINS)
def _import_plugin(name: str, plugin_path: Path):
"""Load a plugin module by file path, avoiding sys.modules stdlib collisions.
@@ -86,23 +94,29 @@ def load_plugins(app: "Quart") -> None:
plugin_path = plugin_dir / name
if not plugin_path.exists():
_FAILED_PLUGINS[name] = f"Plugin directory not found: {plugin_path}"
logger.error("Plugin %r: directory %s not found, skipping", name, plugin_path)
continue
# Load and validate plugin.yaml
yaml_path = plugin_path / "plugin.yaml"
if not yaml_path.exists():
_FAILED_PLUGINS[name] = "plugin.yaml not found"
logger.error("Plugin %r: plugin.yaml not found, skipping", name)
continue
try:
with yaml_path.open() as f:
meta = yaml.safe_load(f) or {}
except Exception:
except Exception as exc:
_FAILED_PLUGINS[name] = f"Failed to read plugin.yaml: {exc}"
logger.exception("Plugin %r: failed to read plugin.yaml, skipping", name)
continue
if meta.get("name") != name:
_FAILED_PLUGINS[name] = (
f"plugin.yaml name {meta.get('name')!r} does not match directory name"
)
logger.error(
"Plugin %r: plugin.yaml name=%r does not match directory name, skipping",
name, meta.get("name"),
@@ -113,12 +127,16 @@ def load_plugins(app: "Quart") -> None:
if min_ver:
try:
if Version(fabledscryer.__version__) < Version(min_ver):
_FAILED_PLUGINS[name] = (
f"Requires fabledscryer>={min_ver}, running {fabledscryer.__version__}"
)
logger.error(
"Plugin %r: requires fabledscryer>=%s but running %s, skipping",
name, min_ver, fabledscryer.__version__,
)
continue
except Exception:
except Exception as exc:
_FAILED_PLUGINS[name] = f"Invalid min_app_version {min_ver!r}: {exc}"
logger.exception("Plugin %r: invalid min_app_version %r, skipping", name, min_ver)
continue
@@ -142,15 +160,18 @@ def load_plugins(app: "Quart") -> None:
# Import the plugin package (file-path load avoids stdlib name collisions)
try:
module = _import_plugin(name, plugin_path)
except Exception:
except Exception as exc:
_FAILED_PLUGINS[name] = f"Import error: {exc}"
logger.exception("Plugin %r: import failed, skipping", name)
continue
# Validate required exports
if not hasattr(module, "setup"):
_FAILED_PLUGINS[name] = "Missing required export: setup()"
logger.error("Plugin %r: missing required export 'setup', skipping", name)
continue
if not hasattr(module, "get_scheduled_tasks"):
_FAILED_PLUGINS[name] = "Missing required export: get_scheduled_tasks()"
logger.error(
"Plugin %r: missing required export 'get_scheduled_tasks', skipping", name
)
@@ -159,7 +180,8 @@ def load_plugins(app: "Quart") -> None:
# Call setup
try:
module.setup(app)
except Exception:
except Exception as exc:
_FAILED_PLUGINS[name] = f"setup() failed: {exc}"
logger.exception("Plugin %r: setup() raised, skipping", name)
continue
@@ -169,17 +191,22 @@ def load_plugins(app: "Quart") -> None:
bp = module.get_blueprint()
app.register_blueprint(bp, url_prefix=f"/plugins/{name}")
logger.info("Plugin %r: blueprint registered at /plugins/%s/", name, name)
except Exception:
except Exception as exc:
_FAILED_PLUGINS[name] = f"Blueprint registration failed: {exc}"
logger.exception("Plugin %r: get_blueprint() raised", name)
continue
# Register scheduled tasks
try:
tasks = module.get_scheduled_tasks()
app._task_registry.extend(tasks)
logger.info("Plugin %r: registered %d scheduled task(s)", name, len(tasks))
except Exception:
except Exception as exc:
_FAILED_PLUGINS[name] = f"get_scheduled_tasks() failed: {exc}"
logger.exception("Plugin %r: get_scheduled_tasks() raised", name)
continue
_FAILED_PLUGINS.pop(name, None) # clear any stale failure from a previous reload attempt
_LOADED_PLUGINS.add(name)
logger.info("Plugin %r loaded (v%s)", name, meta.get("version", "?"))
+6
View File
@@ -408,6 +408,12 @@ async def save_plugins():
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
+14
View File
@@ -243,6 +243,20 @@ function setTimeRange(val) {
</script>
<main>
{% if plugin_failures and session.user_role == 'admin' %}
<div style="background:color-mix(in srgb,var(--yellow) 12%,var(--bg-elevated));
border:1px solid color-mix(in srgb,var(--yellow) 35%,var(--border));
border-radius:6px;padding:0.6rem 1rem;margin-bottom:1rem;
font-size:0.84rem;display:flex;align-items:flex-start;gap:0.6rem;">
<span style="color:var(--yellow);flex-shrink:0;"></span>
<span>
<strong style="color:var(--text);">
{{ plugin_failures | length }} plugin{{ 's' if plugin_failures | length != 1 }} failed to load.
</strong>
<a href="/settings/plugins/" style="color:var(--accent);margin-left:0.4rem;">View details →</a>
</span>
</div>
{% endif %}
{% if error %}
<div class="alert alert-error">{{ error }}</div>
{% endif %}
@@ -62,6 +62,20 @@
</label>
</div>
{# Failure notice — shown when this plugin failed to load #}
{% set fail_reason = plugin_failures.get(plugin._dir) %}
{% 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>
{% 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;">