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
+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", "?"))