feat(ui): plugin get_nav() hook → sidebar Infrastructure links (slice 2)
Plugin UIs had no nav home (only dashboard widgets / typed URLs). Add an optional get_nav() plugin export and surface it in the sidebar. - plugin_manager: _PLUGIN_NAV registry + get_plugin_nav() getter + a tolerant _collect_plugin_nav() (missing hook = fine; raising/malformed = logged & skipped; idempotent per plugin so hot-reload re-runs cleanly). Collected in both the startup load path and the hot-reload path. - app.py: inject plugin_nav into the template context, filtered to enabled plugins so a hot-disabled plugin's link can't linger before restart. - base.html: render plugin links under the Infrastructure group, with the same request.path active-state treatment as core links. - docker/snmp/unifi/traefik: each exports get_nav() → its /plugins/<name>/ view. - Tests: collector behavior (collect, missing hook, reload-replace, malformed item, raising hook, sorted output). With docker enabled, "Docker" now appears under Infrastructure → its fleet view. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016Jg27rgypiW2efULXJDtMC
This commit is contained in:
+5
-1
@@ -177,7 +177,7 @@ def create_app(
|
||||
# ── 10. Template context: inject plugin_failures into every response ───────
|
||||
@app.context_processor
|
||||
def _inject_plugin_failures():
|
||||
from .core.plugin_manager import get_plugin_failures
|
||||
from .core.plugin_manager import get_plugin_failures, get_plugin_nav
|
||||
from .core.settings import get_undecryptable_secrets
|
||||
# enabled_plugins lets templates gate cross-plugin embeds (e.g. the
|
||||
# Hosts hub only fetches the docker fragment when docker is enabled),
|
||||
@@ -186,9 +186,13 @@ def create_app(
|
||||
name for name, cfg in (app.config.get("PLUGINS") or {}).items()
|
||||
if isinstance(cfg, dict) and cfg.get("enabled")
|
||||
}
|
||||
# Sidebar entries for enabled plugins that expose a UI (get_nav). Filter
|
||||
# by enabled so a hot-disabled plugin's link can't linger before restart.
|
||||
plugin_nav = [e for e in get_plugin_nav() if e["plugin"] in enabled_plugins]
|
||||
return {
|
||||
"plugin_failures": get_plugin_failures(),
|
||||
"enabled_plugins": enabled_plugins,
|
||||
"plugin_nav": plugin_nav,
|
||||
# Read from an in-memory cache (no DB hit per render); kept current by
|
||||
# the startup scan + set_setting's discard-on-write.
|
||||
"undecryptable_secrets": get_undecryptable_secrets(),
|
||||
|
||||
@@ -24,12 +24,52 @@ _LOADED_PLUGINS: set[str] = set()
|
||||
# Track plugins that failed to load: name → human-readable reason.
|
||||
_FAILED_PLUGINS: dict[str, str] = {}
|
||||
|
||||
# Nav entries contributed by plugins via the optional get_nav() export, so a
|
||||
# plugin's UI gets a home in the sidebar. Each entry:
|
||||
# {"plugin": <name>, "label": str, "href": str, "section": str}
|
||||
_PLUGIN_NAV: list[dict] = []
|
||||
|
||||
|
||||
def get_plugin_failures() -> dict[str, str]:
|
||||
"""Return a copy of the failed-plugin registry (name → error message)."""
|
||||
return dict(_FAILED_PLUGINS)
|
||||
|
||||
|
||||
def get_plugin_nav() -> list[dict]:
|
||||
"""Plugin-contributed sidebar entries, sorted by section then label."""
|
||||
return sorted(_PLUGIN_NAV, key=lambda e: (e.get("section", ""), e.get("label", "")))
|
||||
|
||||
|
||||
def _collect_plugin_nav(name: str, module) -> None:
|
||||
"""Pull a plugin's optional get_nav() entries into the nav registry.
|
||||
|
||||
Tolerant by design: a missing hook is fine, and a raising or malformed hook
|
||||
is logged and skipped — a bad nav contribution must never break loading.
|
||||
Idempotent per plugin (drops prior entries first) so hot-reload re-runs cleanly.
|
||||
"""
|
||||
global _PLUGIN_NAV
|
||||
_PLUGIN_NAV = [e for e in _PLUGIN_NAV if e.get("plugin") != name]
|
||||
if not hasattr(module, "get_nav"):
|
||||
return
|
||||
try:
|
||||
items = module.get_nav() or []
|
||||
except Exception:
|
||||
logger.exception("Plugin %r: get_nav() raised, skipping its nav entries", name)
|
||||
return
|
||||
for item in items:
|
||||
try:
|
||||
label, href = str(item["label"]), str(item["href"])
|
||||
except (TypeError, KeyError):
|
||||
logger.warning("Plugin %r: malformed nav item %r, skipping", name, item)
|
||||
continue
|
||||
_PLUGIN_NAV.append({
|
||||
"plugin": name,
|
||||
"label": label,
|
||||
"href": href,
|
||||
"section": str(item.get("section", "Infrastructure")),
|
||||
})
|
||||
|
||||
|
||||
def resolve_plugin_path(plugin_dirs: list[Path], name: str) -> Path | None:
|
||||
"""Return the first plugin root that contains `name`, else None.
|
||||
|
||||
@@ -222,6 +262,7 @@ def load_plugins(app: "Quart") -> None:
|
||||
|
||||
_FAILED_PLUGINS.pop(name, None) # clear any stale failure from a previous reload attempt
|
||||
_LOADED_PLUGINS.add(name)
|
||||
_collect_plugin_nav(name, module)
|
||||
logger.info("Plugin %r loaded (v%s)", name, meta.get("version", "?"))
|
||||
|
||||
|
||||
@@ -440,6 +481,7 @@ def hot_reload_plugin(app: "Quart", name: str) -> tuple[bool, str]:
|
||||
return False, f"get_scheduled_tasks() raised: {exc}"
|
||||
|
||||
_LOADED_PLUGINS.add(name)
|
||||
_collect_plugin_nav(name, module)
|
||||
logger.info("Plugin %r hot-reloaded (v%s)", name, meta.get("version", "?"))
|
||||
return True, f"Plugin activated (v{meta.get('version', '?')})"
|
||||
|
||||
|
||||
@@ -287,7 +287,9 @@ body.dash-editing .widget-drawer { transform:translateY(0); }
|
||||
<div class="nav-group">
|
||||
<div class="nav-group-label">Infrastructure</div>
|
||||
<a href="/hosts/" {% if _p.startswith('/hosts') %}class="active" aria-current="page"{% endif %}>Hosts</a>
|
||||
{# plugin links land here in slice 2 (get_nav hook) #}
|
||||
{% for item in plugin_nav %}
|
||||
<a href="{{ item.href }}" {% if _p.startswith(item.href.rstrip('/')) %}class="active" aria-current="page"{% endif %}>{{ item.label }}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="nav-group">
|
||||
<div class="nav-group-label">Monitoring</div>
|
||||
|
||||
Reference in New Issue
Block a user