From 01214a7f73eef4223a70e9a6359cbaa818608f52 Mon Sep 17 00:00:00 2001 From: Teknium Date: Thu, 16 Apr 2026 03:10:28 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20dashboard=20plugin=20system=20=E2=80=94?= =?UTF-8?q?=20extend=20the=20web=20UI=20with=20custom=20tabs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a plugin system that lets plugins add new tabs to the dashboard. Plugins live in ~/.hermes/plugins//dashboard/ alongside any existing CLI/gateway plugin code. Plugin structure: plugins//dashboard/ manifest.json # name, label, icon, tab config, entry point dist/index.js # pre-built JS bundle (IIFE, uses SDK globals) plugin_api.py # optional FastAPI router mounted at /api/plugins// Backend (hermes_cli/web_server.py): - Plugin discovery: scans plugins/*/dashboard/manifest.json from user, bundled, and project plugin directories - GET /api/dashboard/plugins — returns discovered plugin manifests - GET /api/dashboard/plugins/rescan — force re-discovery - GET /dashboard-plugins// — serves plugin static assets with path traversal protection - Optional API route mounting: imports plugin_api.py and mounts its router under /api/plugins// - Plugin API routes bypass session token auth (localhost-only) Frontend (web/src/plugins/): - Plugin SDK exposed on window.__HERMES_PLUGIN_SDK__ — provides React, hooks, UI components (Card, Badge, Button, etc.), API client, fetchJSON, theme/i18n hooks, and utilities - Plugin registry on window.__HERMES_PLUGINS__.register(name, Component) - usePlugins() hook: fetches manifests, loads JS/CSS, resolves components - App.tsx dynamically adds nav items and routes for discovered plugins - Icon resolution via static map of 20 common Lucide icons (no tree- shaking penalty — bundle only +5KB over baseline) Example plugin (plugins/example-dashboard/): - Demonstrates SDK usage: Card components, backend API call, SDK reference - Backend route: GET /api/plugins/example/hello Tested: plugin discovery, static serving, API routes, path traversal blocking, unknown plugin 404, bundle size (400KB vs 394KB baseline). --- hermes_cli/web_server.py | 166 +++++++++++++++++- .../example-dashboard/dashboard/dist/index.js | 94 ++++++++++ .../example-dashboard/dashboard/manifest.json | 13 ++ .../example-dashboard/dashboard/plugin_api.py | 14 ++ web/src/App.tsx | 114 ++++++++++-- web/src/lib/api.ts | 23 ++- web/src/main.tsx | 5 + web/src/plugins/index.ts | 3 + web/src/plugins/registry.ts | 131 ++++++++++++++ web/src/plugins/types.ts | 22 +++ web/src/plugins/usePlugins.ts | 90 ++++++++++ 11 files changed, 660 insertions(+), 15 deletions(-) create mode 100644 plugins/example-dashboard/dashboard/dist/index.js create mode 100644 plugins/example-dashboard/dashboard/manifest.json create mode 100644 plugins/example-dashboard/dashboard/plugin_api.py create mode 100644 web/src/plugins/index.ts create mode 100644 web/src/plugins/registry.ts create mode 100644 web/src/plugins/types.ts create mode 100644 web/src/plugins/usePlugins.ts diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 4d39d379b..9175c41e2 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -11,6 +11,7 @@ Usage: import asyncio import hmac +import importlib.util import json import logging import os @@ -97,6 +98,8 @@ _PUBLIC_API_PATHS: frozenset = frozenset({ "/api/config/schema", "/api/model/info", "/api/dashboard/themes", + "/api/dashboard/plugins", + "/api/dashboard/plugins/rescan", }) @@ -115,7 +118,7 @@ def _require_token(request: Request) -> None: async def auth_middleware(request: Request, call_next): """Require the session token on all /api/ routes except the public list.""" path = request.url.path - if path.startswith("/api/") and path not in _PUBLIC_API_PATHS: + if path.startswith("/api/") and path not in _PUBLIC_API_PATHS and not path.startswith("/api/plugins/"): auth = request.headers.get("authorization", "") expected = f"Bearer {_SESSION_TOKEN}" if not hmac.compare_digest(auth.encode(), expected.encode()): @@ -2145,6 +2148,167 @@ async def set_dashboard_theme(body: ThemeSetBody): return {"ok": True, "theme": body.name} +# --------------------------------------------------------------------------- +# Dashboard plugin system +# --------------------------------------------------------------------------- + +def _discover_dashboard_plugins() -> list: + """Scan plugins/*/dashboard/manifest.json for dashboard extensions. + + Checks three plugin sources (same as hermes_cli.plugins): + 1. User plugins: ~/.hermes/plugins//dashboard/manifest.json + 2. Bundled plugins: /plugins//dashboard/manifest.json (memory/, etc.) + 3. Project plugins: ./.hermes/plugins/ (only if HERMES_ENABLE_PROJECT_PLUGINS) + """ + plugins = [] + seen_names: set = set() + + search_dirs = [ + (get_hermes_home() / "plugins", "user"), + (PROJECT_ROOT / "plugins" / "memory", "bundled"), + (PROJECT_ROOT / "plugins", "bundled"), + ] + if os.environ.get("HERMES_ENABLE_PROJECT_PLUGINS"): + search_dirs.append((Path.cwd() / ".hermes" / "plugins", "project")) + + for plugins_root, source in search_dirs: + if not plugins_root.is_dir(): + continue + for child in sorted(plugins_root.iterdir()): + if not child.is_dir(): + continue + manifest_file = child / "dashboard" / "manifest.json" + if not manifest_file.exists(): + continue + try: + data = json.loads(manifest_file.read_text(encoding="utf-8")) + name = data.get("name", child.name) + if name in seen_names: + continue + seen_names.add(name) + plugins.append({ + "name": name, + "label": data.get("label", name), + "description": data.get("description", ""), + "icon": data.get("icon", "Puzzle"), + "version": data.get("version", "0.0.0"), + "tab": data.get("tab", {"path": f"/{name}", "position": "end"}), + "entry": data.get("entry", "dist/index.js"), + "css": data.get("css"), + "has_api": bool(data.get("api")), + "source": source, + "_dir": str(child / "dashboard"), + "_api_file": data.get("api"), + }) + except Exception as exc: + _log.warning("Bad dashboard plugin manifest %s: %s", manifest_file, exc) + continue + return plugins + + +# Cache discovered plugins per-process (refresh on explicit re-scan). +_dashboard_plugins_cache: Optional[list] = None + + +def _get_dashboard_plugins(force_rescan: bool = False) -> list: + global _dashboard_plugins_cache + if _dashboard_plugins_cache is None or force_rescan: + _dashboard_plugins_cache = _discover_dashboard_plugins() + return _dashboard_plugins_cache + + +@app.get("/api/dashboard/plugins") +async def get_dashboard_plugins(): + """Return discovered dashboard plugins.""" + plugins = _get_dashboard_plugins() + # Strip internal fields before sending to frontend. + return [ + {k: v for k, v in p.items() if not k.startswith("_")} + for p in plugins + ] + + +@app.get("/api/dashboard/plugins/rescan") +async def rescan_dashboard_plugins(): + """Force re-scan of dashboard plugins.""" + plugins = _get_dashboard_plugins(force_rescan=True) + return {"ok": True, "count": len(plugins)} + + +@app.get("/dashboard-plugins/{plugin_name}/{file_path:path}") +async def serve_plugin_asset(plugin_name: str, file_path: str): + """Serve static assets from a dashboard plugin directory. + + Only serves files from the plugin's ``dashboard/`` subdirectory. + Path traversal is blocked by checking ``resolve().is_relative_to()``. + """ + plugins = _get_dashboard_plugins() + plugin = next((p for p in plugins if p["name"] == plugin_name), None) + if not plugin: + raise HTTPException(status_code=404, detail="Plugin not found") + + base = Path(plugin["_dir"]) + target = (base / file_path).resolve() + + if not target.is_relative_to(base.resolve()): + raise HTTPException(status_code=403, detail="Path traversal blocked") + if not target.exists() or not target.is_file(): + raise HTTPException(status_code=404, detail="File not found") + + # Guess content type + suffix = target.suffix.lower() + content_types = { + ".js": "application/javascript", + ".mjs": "application/javascript", + ".css": "text/css", + ".json": "application/json", + ".html": "text/html", + ".svg": "image/svg+xml", + ".png": "image/png", + ".jpg": "image/jpeg", + ".woff2": "font/woff2", + ".woff": "font/woff", + } + media_type = content_types.get(suffix, "application/octet-stream") + return FileResponse(target, media_type=media_type) + + +def _mount_plugin_api_routes(): + """Import and mount backend API routes from plugins that declare them. + + Each plugin's ``api`` field points to a Python file that must expose + a ``router`` (FastAPI APIRouter). Routes are mounted under + ``/api/plugins//``. + """ + for plugin in _get_dashboard_plugins(): + api_file_name = plugin.get("_api_file") + if not api_file_name: + continue + api_path = Path(plugin["_dir"]) / api_file_name + if not api_path.exists(): + _log.warning("Plugin %s declares api=%s but file not found", plugin["name"], api_file_name) + continue + try: + spec = importlib.util.spec_from_file_location( + f"hermes_dashboard_plugin_{plugin['name']}", api_path, + ) + if spec is None or spec.loader is None: + continue + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + router = getattr(mod, "router", None) + if router is None: + _log.warning("Plugin %s api file has no 'router' attribute", plugin["name"]) + continue + app.include_router(router, prefix=f"/api/plugins/{plugin['name']}") + _log.info("Mounted plugin API routes: /api/plugins/%s/", plugin["name"]) + except Exception as exc: + _log.warning("Failed to load plugin %s API routes: %s", plugin["name"], exc) + + +# Mount plugin API routes before the SPA catch-all. +_mount_plugin_api_routes() + mount_spa(app) diff --git a/plugins/example-dashboard/dashboard/dist/index.js b/plugins/example-dashboard/dashboard/dist/index.js new file mode 100644 index 000000000..a54916be4 --- /dev/null +++ b/plugins/example-dashboard/dashboard/dist/index.js @@ -0,0 +1,94 @@ +/** + * Example Dashboard Plugin + * + * Demonstrates how to build a dashboard plugin using the Hermes Plugin SDK. + * No build step needed — this is a plain IIFE that uses globals from the SDK. + */ +(function () { + "use strict"; + + const SDK = window.__HERMES_PLUGIN_SDK__; + const { React } = SDK; + const { Card, CardHeader, CardTitle, CardContent, Badge, Button } = SDK.components; + const { useState, useEffect } = SDK.hooks; + const { cn } = SDK.utils; + + function ExamplePage() { + const [greeting, setGreeting] = useState(null); + const [loading, setLoading] = useState(false); + + function fetchGreeting() { + setLoading(true); + SDK.fetchJSON("/api/plugins/example/hello") + .then(function (data) { setGreeting(data.message); }) + .catch(function () { setGreeting("(backend not available)"); }) + .finally(function () { setLoading(false); }); + } + + return React.createElement("div", { className: "flex flex-col gap-6" }, + // Header card + React.createElement(Card, null, + React.createElement(CardHeader, null, + React.createElement("div", { className: "flex items-center gap-3" }, + React.createElement(CardTitle, { className: "text-lg" }, "Example Plugin"), + React.createElement(Badge, { variant: "outline" }, "v1.0.0"), + ), + ), + React.createElement(CardContent, { className: "flex flex-col gap-4" }, + React.createElement("p", { className: "text-sm text-muted-foreground" }, + "This is an example dashboard plugin. It demonstrates using the Plugin SDK to build ", + "custom tabs with React components, connect to backend API routes, and integrate with ", + "the existing Hermes UI system.", + ), + React.createElement("div", { className: "flex items-center gap-3" }, + React.createElement(Button, { + onClick: fetchGreeting, + disabled: loading, + className: cn( + "inline-flex items-center gap-2 border border-border bg-background/40 px-4 py-2", + "text-sm font-courier transition-colors hover:bg-foreground/10 cursor-pointer", + ), + }, loading ? "Loading..." : "Call Backend API"), + greeting && React.createElement("span", { + className: "text-sm font-courier text-muted-foreground", + }, greeting), + ), + ), + ), + + // Info card about the SDK + React.createElement(Card, null, + React.createElement(CardHeader, null, + React.createElement(CardTitle, { className: "text-base" }, "Plugin SDK Reference"), + ), + React.createElement(CardContent, null, + React.createElement("div", { className: "grid gap-3 text-sm" }, + React.createElement("div", { className: "flex flex-col gap-1 border border-border p-3" }, + React.createElement("span", { className: "font-medium" }, "window.__HERMES_PLUGIN_SDK__.React"), + React.createElement("span", { className: "text-muted-foreground text-xs" }, "React instance — use instead of importing react"), + ), + React.createElement("div", { className: "flex flex-col gap-1 border border-border p-3" }, + React.createElement("span", { className: "font-medium" }, "window.__HERMES_PLUGIN_SDK__.hooks"), + React.createElement("span", { className: "text-muted-foreground text-xs" }, "useState, useEffect, useCallback, useMemo, useRef, useContext, createContext"), + ), + React.createElement("div", { className: "flex flex-col gap-1 border border-border p-3" }, + React.createElement("span", { className: "font-medium" }, "window.__HERMES_PLUGIN_SDK__.components"), + React.createElement("span", { className: "text-muted-foreground text-xs" }, "Card, Badge, Button, Input, Label, Select, Separator, Tabs, etc."), + ), + React.createElement("div", { className: "flex flex-col gap-1 border border-border p-3" }, + React.createElement("span", { className: "font-medium" }, "window.__HERMES_PLUGIN_SDK__.api"), + React.createElement("span", { className: "text-muted-foreground text-xs" }, "Hermes API client — getStatus(), getSessions(), etc."), + ), + React.createElement("div", { className: "flex flex-col gap-1 border border-border p-3" }, + React.createElement("span", { className: "font-medium" }, "window.__HERMES_PLUGIN_SDK__.utils"), + React.createElement("span", { className: "text-muted-foreground text-xs" }, "cn(), timeAgo(), isoTimeAgo()"), + ), + ), + ), + ), + ); + } + + // Register this plugin — the dashboard picks it up automatically. + window.__HERMES_PLUGINS__.register("example", ExamplePage); +})(); diff --git a/plugins/example-dashboard/dashboard/manifest.json b/plugins/example-dashboard/dashboard/manifest.json new file mode 100644 index 000000000..2111bff5e --- /dev/null +++ b/plugins/example-dashboard/dashboard/manifest.json @@ -0,0 +1,13 @@ +{ + "name": "example", + "label": "Example", + "description": "Example dashboard plugin — demonstrates the plugin SDK", + "icon": "Sparkles", + "version": "1.0.0", + "tab": { + "path": "/example", + "position": "after:skills" + }, + "entry": "dist/index.js", + "api": "plugin_api.py" +} diff --git a/plugins/example-dashboard/dashboard/plugin_api.py b/plugins/example-dashboard/dashboard/plugin_api.py new file mode 100644 index 000000000..20aed76e2 --- /dev/null +++ b/plugins/example-dashboard/dashboard/plugin_api.py @@ -0,0 +1,14 @@ +"""Example dashboard plugin — backend API routes. + +Mounted at /api/plugins/example/ by the dashboard plugin system. +""" + +from fastapi import APIRouter + +router = APIRouter() + + +@router.get("/hello") +async def hello(): + """Simple greeting endpoint to demonstrate plugin API routes.""" + return {"message": "Hello from the example plugin!", "plugin": "example", "version": "1.0.0"} diff --git a/web/src/App.tsx b/web/src/App.tsx index dfadf1067..b07608c31 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,5 +1,11 @@ +import { useMemo } from "react"; import { Routes, Route, NavLink, Navigate } from "react-router-dom"; -import { Activity, BarChart3, Clock, FileText, KeyRound, MessageSquare, Package, Settings } from "lucide-react"; +import { + Activity, BarChart3, Clock, FileText, KeyRound, + MessageSquare, Package, Settings, Puzzle, + Sparkles, Terminal, Globe, Database, Shield, + Wrench, Zap, Heart, Star, Code, Eye, +} from "lucide-react"; import StatusPage from "@/pages/StatusPage"; import ConfigPage from "@/pages/ConfigPage"; import EnvPage from "@/pages/EnvPage"; @@ -11,20 +17,90 @@ import SkillsPage from "@/pages/SkillsPage"; import { LanguageSwitcher } from "@/components/LanguageSwitcher"; import { ThemeSwitcher } from "@/components/ThemeSwitcher"; import { useI18n } from "@/i18n"; +import { usePlugins } from "@/plugins"; +import type { RegisteredPlugin } from "@/plugins"; -const NAV_ITEMS = [ - { path: "/", labelKey: "status" as const, icon: Activity }, - { path: "/sessions", labelKey: "sessions" as const, icon: MessageSquare }, - { path: "/analytics", labelKey: "analytics" as const, icon: BarChart3 }, - { path: "/logs", labelKey: "logs" as const, icon: FileText }, - { path: "/cron", labelKey: "cron" as const, icon: Clock }, - { path: "/skills", labelKey: "skills" as const, icon: Package }, - { path: "/config", labelKey: "config" as const, icon: Settings }, - { path: "/env", labelKey: "keys" as const, icon: KeyRound }, -] as const; +// --------------------------------------------------------------------------- +// Built-in nav items +// --------------------------------------------------------------------------- + +interface NavItem { + path: string; + label: string; + labelKey?: string; + icon: React.ComponentType<{ className?: string }>; +} + +const BUILTIN_NAV: NavItem[] = [ + { path: "/", labelKey: "status", label: "Status", icon: Activity }, + { path: "/sessions", labelKey: "sessions", label: "Sessions", icon: MessageSquare }, + { path: "/analytics", labelKey: "analytics", label: "Analytics", icon: BarChart3 }, + { path: "/logs", labelKey: "logs", label: "Logs", icon: FileText }, + { path: "/cron", labelKey: "cron", label: "Cron", icon: Clock }, + { path: "/skills", labelKey: "skills", label: "Skills", icon: Package }, + { path: "/config", labelKey: "config", label: "Config", icon: Settings }, + { path: "/env", labelKey: "keys", label: "Keys", icon: KeyRound }, +]; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Map of icon names plugins can use. Covers common choices without importing all of lucide. */ +const ICON_MAP: Record> = { + Activity, BarChart3, Clock, FileText, KeyRound, + MessageSquare, Package, Settings, Puzzle, + Sparkles, Terminal, Globe, Database, Shield, + Wrench, Zap, Heart, Star, Code, Eye, +}; + +/** Resolve a Lucide icon name to a component, fallback to Puzzle. */ +function resolveIcon(name: string): React.ComponentType<{ className?: string }> { + return ICON_MAP[name] ?? Puzzle; +} + +/** Insert plugin nav items at the position specified in their manifest. */ +function buildNavItems(builtIn: NavItem[], plugins: RegisteredPlugin[]): NavItem[] { + const items = [...builtIn]; + + for (const { manifest } of plugins) { + const pluginItem: NavItem = { + path: manifest.tab.path, + label: manifest.label, + icon: resolveIcon(manifest.icon), + }; + + const pos = manifest.tab.position ?? "end"; + if (pos === "end") { + items.push(pluginItem); + } else if (pos.startsWith("after:")) { + const target = "/" + pos.slice(6); + const idx = items.findIndex((i) => i.path === target); + items.splice(idx >= 0 ? idx + 1 : items.length, 0, pluginItem); + } else if (pos.startsWith("before:")) { + const target = "/" + pos.slice(7); + const idx = items.findIndex((i) => i.path === target); + items.splice(idx >= 0 ? idx : items.length, 0, pluginItem); + } else { + items.push(pluginItem); + } + } + + return items; +} + +// --------------------------------------------------------------------------- +// App +// --------------------------------------------------------------------------- export default function App() { const { t } = useI18n(); + const { plugins } = usePlugins(); + + const navItems = useMemo( + () => buildNavItems(BUILTIN_NAV, plugins), + [plugins], + ); return (
@@ -40,7 +116,7 @@ export default function App() {