diff --git a/plugins/docker/__init__.py b/plugins/docker/__init__.py index 7183961..5e09d1d 100644 --- a/plugins/docker/__init__.py +++ b/plugins/docker/__init__.py @@ -47,3 +47,8 @@ def get_scheduled_tasks() -> list: def get_blueprint(): from .routes import docker_bp return docker_bp + + +def get_nav() -> list[dict]: + # Surfaces in the sidebar's Infrastructure group (fleet-wide container view). + return [{"label": "Docker", "href": "/plugins/docker/"}] diff --git a/plugins/snmp/__init__.py b/plugins/snmp/__init__.py index 8655884..0222aee 100644 --- a/plugins/snmp/__init__.py +++ b/plugins/snmp/__init__.py @@ -21,3 +21,8 @@ def get_scheduled_tasks() -> list: def get_blueprint(): from .routes import snmp_bp return snmp_bp + + +def get_nav() -> list[dict]: + # Surfaces in the sidebar's Infrastructure group (SNMP device readings). + return [{"label": "SNMP", "href": "/plugins/snmp/"}] diff --git a/plugins/traefik/__init__.py b/plugins/traefik/__init__.py index f2c7ac7..19f2982 100644 --- a/plugins/traefik/__init__.py +++ b/plugins/traefik/__init__.py @@ -28,3 +28,8 @@ def get_scheduled_tasks() -> list: def get_blueprint(): from .routes import traefik_bp return traefik_bp + + +def get_nav() -> list[dict]: + # Surfaces in the sidebar's Infrastructure group (Traefik services view). + return [{"label": "Traefik", "href": "/plugins/traefik/"}] diff --git a/plugins/unifi/__init__.py b/plugins/unifi/__init__.py index 4bb589f..5dbedbb 100644 --- a/plugins/unifi/__init__.py +++ b/plugins/unifi/__init__.py @@ -26,3 +26,8 @@ def get_scheduled_tasks() -> list: def get_blueprint(): from .routes import unifi_bp return unifi_bp + + +def get_nav() -> list[dict]: + # Surfaces in the sidebar's Infrastructure group (UniFi network overview). + return [{"label": "UniFi", "href": "/plugins/unifi/"}] diff --git a/steward/app.py b/steward/app.py index 8e5915e..e069f69 100644 --- a/steward/app.py +++ b/steward/app.py @@ -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(), diff --git a/steward/core/plugin_manager.py b/steward/core/plugin_manager.py index 680235c..5dedde3 100644 --- a/steward/core/plugin_manager.py +++ b/steward/core/plugin_manager.py @@ -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": , "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', '?')})" diff --git a/steward/templates/base.html b/steward/templates/base.html index 2fdd8ea..dbe5a82 100644 --- a/steward/templates/base.html +++ b/steward/templates/base.html @@ -287,7 +287,9 @@ body.dash-editing .widget-drawer { transform:translateY(0); }