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:
+17
-8
@@ -24,22 +24,31 @@ set -e
|
|||||||
NUT_MANAGED_JSON="/data/nut_managed.json"
|
NUT_MANAGED_JSON="/data/nut_managed.json"
|
||||||
|
|
||||||
if [ "${NUT_MANAGED:-0}" = "1" ] || [ -f "$NUT_MANAGED_JSON" ]; then
|
if [ "${NUT_MANAGED:-0}" = "1" ] || [ -f "$NUT_MANAGED_JSON" ]; then
|
||||||
|
NUT_OK=1
|
||||||
if [ -f "$NUT_MANAGED_JSON" ]; then
|
if [ -f "$NUT_MANAGED_JSON" ]; then
|
||||||
echo "[entrypoint] Found $NUT_MANAGED_JSON — configuring NUT from settings..."
|
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
|
else
|
||||||
echo "[entrypoint] NUT_MANAGED=1 — configuring NUT from environment..."
|
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
|
fi
|
||||||
|
|
||||||
echo "[entrypoint] Starting NUT driver..."
|
if [ "$NUT_OK" = "1" ]; then
|
||||||
/sbin/upsdrvctl start || echo "[entrypoint] Warning: upsdrvctl returned non-zero"
|
echo "[entrypoint] Starting NUT driver..."
|
||||||
|
/sbin/upsdrvctl start || echo "[entrypoint] Warning: upsdrvctl returned non-zero"
|
||||||
|
|
||||||
echo "[entrypoint] Starting NUT daemon..."
|
echo "[entrypoint] Starting NUT daemon..."
|
||||||
/sbin/upsd || echo "[entrypoint] Warning: upsd returned non-zero"
|
/sbin/upsd || echo "[entrypoint] Warning: upsd returned non-zero"
|
||||||
|
|
||||||
sleep 1
|
sleep 1
|
||||||
echo "[entrypoint] NUT ready on 127.0.0.1:3493."
|
echo "[entrypoint] NUT ready on 127.0.0.1:3493."
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
exec gosu app "$@"
|
exec gosu app "$@"
|
||||||
|
|||||||
+7
-1
@@ -122,7 +122,13 @@ def create_app(
|
|||||||
from .core.plugin_manager import load_plugins
|
from .core.plugin_manager import load_plugins
|
||||||
load_plugins(app)
|
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
|
@app.before_request
|
||||||
async def _validate_share_token():
|
async def _validate_share_token():
|
||||||
"""If ?s=<token> is present, validate it and set g.share_viewer."""
|
"""If ?s=<token> is present, validate it and set g.share_viewer."""
|
||||||
|
|||||||
@@ -22,6 +22,14 @@ logger = logging.getLogger(__name__)
|
|||||||
# whether to register a blueprint (new plugin) or skip it (already registered).
|
# whether to register a blueprint (new plugin) or skip it (already registered).
|
||||||
_LOADED_PLUGINS: set[str] = set()
|
_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):
|
def _import_plugin(name: str, plugin_path: Path):
|
||||||
"""Load a plugin module by file path, avoiding sys.modules stdlib collisions.
|
"""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
|
plugin_path = plugin_dir / name
|
||||||
if not plugin_path.exists():
|
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)
|
logger.error("Plugin %r: directory %s not found, skipping", name, plugin_path)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Load and validate plugin.yaml
|
# Load and validate plugin.yaml
|
||||||
yaml_path = plugin_path / "plugin.yaml"
|
yaml_path = plugin_path / "plugin.yaml"
|
||||||
if not yaml_path.exists():
|
if not yaml_path.exists():
|
||||||
|
_FAILED_PLUGINS[name] = "plugin.yaml not found"
|
||||||
logger.error("Plugin %r: plugin.yaml not found, skipping", name)
|
logger.error("Plugin %r: plugin.yaml not found, skipping", name)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with yaml_path.open() as f:
|
with yaml_path.open() as f:
|
||||||
meta = yaml.safe_load(f) or {}
|
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)
|
logger.exception("Plugin %r: failed to read plugin.yaml, skipping", name)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if meta.get("name") != name:
|
if meta.get("name") != name:
|
||||||
|
_FAILED_PLUGINS[name] = (
|
||||||
|
f"plugin.yaml name {meta.get('name')!r} does not match directory name"
|
||||||
|
)
|
||||||
logger.error(
|
logger.error(
|
||||||
"Plugin %r: plugin.yaml name=%r does not match directory name, skipping",
|
"Plugin %r: plugin.yaml name=%r does not match directory name, skipping",
|
||||||
name, meta.get("name"),
|
name, meta.get("name"),
|
||||||
@@ -113,12 +127,16 @@ def load_plugins(app: "Quart") -> None:
|
|||||||
if min_ver:
|
if min_ver:
|
||||||
try:
|
try:
|
||||||
if Version(fabledscryer.__version__) < Version(min_ver):
|
if Version(fabledscryer.__version__) < Version(min_ver):
|
||||||
|
_FAILED_PLUGINS[name] = (
|
||||||
|
f"Requires fabledscryer>={min_ver}, running {fabledscryer.__version__}"
|
||||||
|
)
|
||||||
logger.error(
|
logger.error(
|
||||||
"Plugin %r: requires fabledscryer>=%s but running %s, skipping",
|
"Plugin %r: requires fabledscryer>=%s but running %s, skipping",
|
||||||
name, min_ver, fabledscryer.__version__,
|
name, min_ver, fabledscryer.__version__,
|
||||||
)
|
)
|
||||||
continue
|
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)
|
logger.exception("Plugin %r: invalid min_app_version %r, skipping", name, min_ver)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -142,15 +160,18 @@ def load_plugins(app: "Quart") -> None:
|
|||||||
# Import the plugin package (file-path load avoids stdlib name collisions)
|
# Import the plugin package (file-path load avoids stdlib name collisions)
|
||||||
try:
|
try:
|
||||||
module = _import_plugin(name, plugin_path)
|
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)
|
logger.exception("Plugin %r: import failed, skipping", name)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Validate required exports
|
# Validate required exports
|
||||||
if not hasattr(module, "setup"):
|
if not hasattr(module, "setup"):
|
||||||
|
_FAILED_PLUGINS[name] = "Missing required export: setup()"
|
||||||
logger.error("Plugin %r: missing required export 'setup', skipping", name)
|
logger.error("Plugin %r: missing required export 'setup', skipping", name)
|
||||||
continue
|
continue
|
||||||
if not hasattr(module, "get_scheduled_tasks"):
|
if not hasattr(module, "get_scheduled_tasks"):
|
||||||
|
_FAILED_PLUGINS[name] = "Missing required export: get_scheduled_tasks()"
|
||||||
logger.error(
|
logger.error(
|
||||||
"Plugin %r: missing required export 'get_scheduled_tasks', skipping", name
|
"Plugin %r: missing required export 'get_scheduled_tasks', skipping", name
|
||||||
)
|
)
|
||||||
@@ -159,7 +180,8 @@ def load_plugins(app: "Quart") -> None:
|
|||||||
# Call setup
|
# Call setup
|
||||||
try:
|
try:
|
||||||
module.setup(app)
|
module.setup(app)
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
|
_FAILED_PLUGINS[name] = f"setup() failed: {exc}"
|
||||||
logger.exception("Plugin %r: setup() raised, skipping", name)
|
logger.exception("Plugin %r: setup() raised, skipping", name)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -169,17 +191,22 @@ def load_plugins(app: "Quart") -> None:
|
|||||||
bp = module.get_blueprint()
|
bp = module.get_blueprint()
|
||||||
app.register_blueprint(bp, url_prefix=f"/plugins/{name}")
|
app.register_blueprint(bp, url_prefix=f"/plugins/{name}")
|
||||||
logger.info("Plugin %r: blueprint registered at /plugins/%s/", name, 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)
|
logger.exception("Plugin %r: get_blueprint() raised", name)
|
||||||
|
continue
|
||||||
|
|
||||||
# Register scheduled tasks
|
# Register scheduled tasks
|
||||||
try:
|
try:
|
||||||
tasks = module.get_scheduled_tasks()
|
tasks = module.get_scheduled_tasks()
|
||||||
app._task_registry.extend(tasks)
|
app._task_registry.extend(tasks)
|
||||||
logger.info("Plugin %r: registered %d scheduled task(s)", name, len(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)
|
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)
|
_LOADED_PLUGINS.add(name)
|
||||||
logger.info("Plugin %r loaded (v%s)", name, meta.get("version", "?"))
|
logger.info("Plugin %r loaded (v%s)", name, meta.get("version", "?"))
|
||||||
|
|
||||||
|
|||||||
@@ -408,6 +408,12 @@ async def save_plugins():
|
|||||||
plugin_cfg[cfg_key] = default_val
|
plugin_cfg[cfg_key] = default_val
|
||||||
else:
|
else:
|
||||||
plugin_cfg[cfg_key] = raw_val
|
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 set_setting(db, f"plugin.{name}", plugin_cfg)
|
||||||
await _reload_app_config()
|
await _reload_app_config()
|
||||||
from fabledscryer.core.plugin_index import clear_catalog_cache
|
from fabledscryer.core.plugin_index import clear_catalog_cache
|
||||||
|
|||||||
@@ -243,6 +243,20 @@ function setTimeRange(val) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<main>
|
<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 %}
|
{% if error %}
|
||||||
<div class="alert alert-error">{{ error }}</div>
|
<div class="alert alert-error">{{ error }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -62,6 +62,20 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</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 #}
|
{# Config fields — only shown when there are config keys #}
|
||||||
{% if plugin.get('config') %}
|
{% if plugin.get('config') %}
|
||||||
<div style="border-top:1px solid var(--border);padding-top:1rem;display:grid;gap:0.6rem;">
|
<div style="border-top:1px solid var(--border);padding-top:1rem;display:grid;gap:0.6rem;">
|
||||||
|
|||||||
Reference in New Issue
Block a user