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:
@@ -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", "?"))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user