diff --git a/entrypoint.sh b/entrypoint.sh index bf10c85..f467600 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -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 "$@" diff --git a/fabledscryer/app.py b/fabledscryer/app.py index 30c5534..8cbfe64 100644 --- a/fabledscryer/app.py +++ b/fabledscryer/app.py @@ -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= is present, validate it and set g.share_viewer.""" diff --git a/fabledscryer/core/plugin_manager.py b/fabledscryer/core/plugin_manager.py index da50403..7ea0127 100644 --- a/fabledscryer/core/plugin_manager.py +++ b/fabledscryer/core/plugin_manager.py @@ -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", "?")) diff --git a/fabledscryer/settings/routes.py b/fabledscryer/settings/routes.py index ca07098..310888b 100644 --- a/fabledscryer/settings/routes.py +++ b/fabledscryer/settings/routes.py @@ -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 diff --git a/fabledscryer/templates/base.html b/fabledscryer/templates/base.html index d03d7eb..e1e7eb1 100644 --- a/fabledscryer/templates/base.html +++ b/fabledscryer/templates/base.html @@ -243,6 +243,20 @@ function setTimeRange(val) {
+ {% if plugin_failures and session.user_role == 'admin' %} +
+ + + + {{ plugin_failures | length }} plugin{{ 's' if plugin_failures | length != 1 }} failed to load. + + View details → + +
+ {% endif %} {% if error %}
{{ error }}
{% endif %} diff --git a/fabledscryer/templates/settings/plugins.html b/fabledscryer/templates/settings/plugins.html index f592cbf..b7961f3 100644 --- a/fabledscryer/templates/settings/plugins.html +++ b/fabledscryer/templates/settings/plugins.html @@ -62,6 +62,20 @@ + {# Failure notice — shown when this plugin failed to load #} + {% set fail_reason = plugin_failures.get(plugin._dir) %} + {% if fail_reason %} +
+ + Failed to load + — {{ fail_reason }} + +
+ {% endif %} + {# Config fields — only shown when there are config keys #} {% if plugin.get('config') %}