feat: dashboard plugin system — extend the web UI with custom tabs

Add a plugin system that lets plugins add new tabs to the dashboard.
Plugins live in ~/.hermes/plugins/<name>/dashboard/ alongside any
existing CLI/gateway plugin code.

Plugin structure:
  plugins/<name>/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/<name>/

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/<name>/<path> — serves plugin static assets
  with path traversal protection
- Optional API route mounting: imports plugin_api.py and mounts its
  router under /api/plugins/<name>/
- 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).
This commit is contained in:
Teknium 2026-04-16 03:10:28 -07:00 committed by Teknium
parent 23a42635f0
commit 01214a7f73
11 changed files with 660 additions and 15 deletions

View file

@ -11,6 +11,7 @@ Usage:
import asyncio import asyncio
import hmac import hmac
import importlib.util
import json import json
import logging import logging
import os import os
@ -97,6 +98,8 @@ _PUBLIC_API_PATHS: frozenset = frozenset({
"/api/config/schema", "/api/config/schema",
"/api/model/info", "/api/model/info",
"/api/dashboard/themes", "/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): async def auth_middleware(request: Request, call_next):
"""Require the session token on all /api/ routes except the public list.""" """Require the session token on all /api/ routes except the public list."""
path = request.url.path 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", "") auth = request.headers.get("authorization", "")
expected = f"Bearer {_SESSION_TOKEN}" expected = f"Bearer {_SESSION_TOKEN}"
if not hmac.compare_digest(auth.encode(), expected.encode()): 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} 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/<name>/dashboard/manifest.json
2. Bundled plugins: <repo>/plugins/<name>/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/<name>/``.
"""
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) mount_spa(app)

View file

@ -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);
})();

View file

@ -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"
}

View file

@ -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"}

View file

@ -1,5 +1,11 @@
import { useMemo } from "react";
import { Routes, Route, NavLink, Navigate } from "react-router-dom"; 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 StatusPage from "@/pages/StatusPage";
import ConfigPage from "@/pages/ConfigPage"; import ConfigPage from "@/pages/ConfigPage";
import EnvPage from "@/pages/EnvPage"; import EnvPage from "@/pages/EnvPage";
@ -11,20 +17,90 @@ import SkillsPage from "@/pages/SkillsPage";
import { LanguageSwitcher } from "@/components/LanguageSwitcher"; import { LanguageSwitcher } from "@/components/LanguageSwitcher";
import { ThemeSwitcher } from "@/components/ThemeSwitcher"; import { ThemeSwitcher } from "@/components/ThemeSwitcher";
import { useI18n } from "@/i18n"; import { useI18n } from "@/i18n";
import { usePlugins } from "@/plugins";
import type { RegisteredPlugin } from "@/plugins";
const NAV_ITEMS = [ // ---------------------------------------------------------------------------
{ path: "/", labelKey: "status" as const, icon: Activity }, // Built-in nav items
{ path: "/sessions", labelKey: "sessions" as const, icon: MessageSquare }, // ---------------------------------------------------------------------------
{ path: "/analytics", labelKey: "analytics" as const, icon: BarChart3 },
{ path: "/logs", labelKey: "logs" as const, icon: FileText }, interface NavItem {
{ path: "/cron", labelKey: "cron" as const, icon: Clock }, path: string;
{ path: "/skills", labelKey: "skills" as const, icon: Package }, label: string;
{ path: "/config", labelKey: "config" as const, icon: Settings }, labelKey?: string;
{ path: "/env", labelKey: "keys" as const, icon: KeyRound }, icon: React.ComponentType<{ className?: string }>;
] as const; }
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<string, React.ComponentType<{ className?: string }>> = {
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() { export default function App() {
const { t } = useI18n(); const { t } = useI18n();
const { plugins } = usePlugins();
const navItems = useMemo(
() => buildNavItems(BUILTIN_NAV, plugins),
[plugins],
);
return ( return (
<div className="flex min-h-screen flex-col bg-background text-foreground overflow-x-hidden"> <div className="flex min-h-screen flex-col bg-background text-foreground overflow-x-hidden">
@ -40,7 +116,7 @@ export default function App() {
</div> </div>
<nav className="flex items-stretch overflow-x-auto scrollbar-none"> <nav className="flex items-stretch overflow-x-auto scrollbar-none">
{NAV_ITEMS.map(({ path, labelKey, icon: Icon }) => ( {navItems.map(({ path, label, labelKey, icon: Icon }) => (
<NavLink <NavLink
key={path} key={path}
to={path} to={path}
@ -56,7 +132,9 @@ export default function App() {
{({ isActive }) => ( {({ isActive }) => (
<> <>
<Icon className="h-4 w-4 sm:h-3.5 sm:w-3.5 shrink-0" /> <Icon className="h-4 w-4 sm:h-3.5 sm:w-3.5 shrink-0" />
<span className="hidden sm:inline">{t.app.nav[labelKey]}</span> <span className="hidden sm:inline">
{labelKey ? (t.app.nav as Record<string, string>)[labelKey] ?? label : label}
</span>
<span className="absolute inset-0 bg-foreground pointer-events-none transition-opacity duration-150 group-hover:opacity-5 opacity-0" /> <span className="absolute inset-0 bg-foreground pointer-events-none transition-opacity duration-150 group-hover:opacity-5 opacity-0" />
{isActive && ( {isActive && (
<span className="absolute bottom-0 left-0 right-0 h-px bg-foreground" /> <span className="absolute bottom-0 left-0 right-0 h-px bg-foreground" />
@ -87,6 +165,16 @@ export default function App() {
<Route path="/skills" element={<SkillsPage />} /> <Route path="/skills" element={<SkillsPage />} />
<Route path="/config" element={<ConfigPage />} /> <Route path="/config" element={<ConfigPage />} />
<Route path="/env" element={<EnvPage />} /> <Route path="/env" element={<EnvPage />} />
{/* Plugin routes */}
{plugins.map(({ manifest, component: PluginComponent }) => (
<Route
key={manifest.name}
path={manifest.tab.path}
element={<PluginComponent />}
/>
))}
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>
</main> </main>

View file

@ -9,7 +9,7 @@ declare global {
} }
let _sessionToken: string | null = null; let _sessionToken: string | null = null;
async function fetchJSON<T>(url: string, init?: RequestInit): Promise<T> { export async function fetchJSON<T>(url: string, init?: RequestInit): Promise<T> {
// Inject the session token into all /api/ requests. // Inject the session token into all /api/ requests.
const headers = new Headers(init?.headers); const headers = new Headers(init?.headers);
const token = window.__HERMES_SESSION_TOKEN__; const token = window.__HERMES_SESSION_TOKEN__;
@ -192,6 +192,12 @@ export const api = {
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }), body: JSON.stringify({ name }),
}), }),
// Dashboard plugins
getPlugins: () =>
fetchJSON<PluginManifestResponse[]>("/api/dashboard/plugins"),
rescanPlugins: () =>
fetchJSON<{ ok: boolean; count: number }>("/api/dashboard/plugins/rescan"),
}; };
export interface PlatformStatus { export interface PlatformStatus {
@ -432,3 +438,18 @@ export interface ThemeListResponse {
themes: Array<{ name: string; label: string; description: string }>; themes: Array<{ name: string; label: string; description: string }>;
active: string; active: string;
} }
// ── Dashboard plugin types ─────────────────────────────────────────────
export interface PluginManifestResponse {
name: string;
label: string;
description: string;
icon: string;
version: string;
tab: { path: string; position: string };
entry: string;
css?: string | null;
has_api: boolean;
source: string;
}

View file

@ -4,6 +4,11 @@ import "./index.css";
import App from "./App"; import App from "./App";
import { I18nProvider } from "./i18n"; import { I18nProvider } from "./i18n";
import { ThemeProvider } from "./themes"; import { ThemeProvider } from "./themes";
import { exposePluginSDK } from "./plugins";
// Expose the plugin SDK before rendering so plugins loaded via <script>
// can access React, components, etc. immediately.
exposePluginSDK();
createRoot(document.getElementById("root")!).render( createRoot(document.getElementById("root")!).render(
<BrowserRouter> <BrowserRouter>

3
web/src/plugins/index.ts Normal file
View file

@ -0,0 +1,3 @@
export { exposePluginSDK, getPluginComponent, onPluginRegistered, getRegisteredCount } from "./registry";
export { usePlugins } from "./usePlugins";
export type { PluginManifest, RegisteredPlugin } from "./types";

131
web/src/plugins/registry.ts Normal file
View file

@ -0,0 +1,131 @@
/**
* Dashboard Plugin SDK + Registry
*
* Exposes React, UI components, hooks, and utilities on the window so
* that plugin bundles can use them without bundling their own copies.
*
* Plugins call window.__HERMES_PLUGINS__.register(name, Component)
* to register their tab component.
*/
import React, {
useState,
useEffect,
useCallback,
useMemo,
useRef,
useContext,
createContext,
} from "react";
import { api, fetchJSON } from "@/lib/api";
import { cn, timeAgo, isoTimeAgo } from "@/lib/utils";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectOption } from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useI18n } from "@/i18n";
import { useTheme } from "@/themes";
// ---------------------------------------------------------------------------
// Plugin registry — plugins call register() to add their component.
// ---------------------------------------------------------------------------
type RegistryListener = () => void;
const _registered: Map<string, React.ComponentType> = new Map();
const _listeners: Set<RegistryListener> = new Set();
function _notify() {
for (const fn of _listeners) {
try { fn(); } catch { /* ignore */ }
}
}
/** Register a plugin component. Called by plugin JS bundles. */
function registerPlugin(name: string, component: React.ComponentType) {
_registered.set(name, component);
_notify();
}
/** Get a registered component by plugin name. */
export function getPluginComponent(name: string): React.ComponentType | undefined {
return _registered.get(name);
}
/** Subscribe to registry changes (returns unsubscribe fn). */
export function onPluginRegistered(fn: RegistryListener): () => void {
_listeners.add(fn);
return () => _listeners.delete(fn);
}
/** Get current count of registered plugins. */
export function getRegisteredCount(): number {
return _registered.size;
}
// ---------------------------------------------------------------------------
// Expose SDK + registry on window
// ---------------------------------------------------------------------------
declare global {
interface Window {
__HERMES_PLUGIN_SDK__: unknown;
__HERMES_PLUGINS__: {
register: typeof registerPlugin;
};
}
}
export function exposePluginSDK() {
window.__HERMES_PLUGINS__ = {
register: registerPlugin,
};
window.__HERMES_PLUGIN_SDK__ = {
// React core — plugins use these instead of importing react
React,
hooks: {
useState,
useEffect,
useCallback,
useMemo,
useRef,
useContext,
createContext,
},
// Hermes API client
api,
// Raw fetchJSON for plugin-specific endpoints
fetchJSON,
// UI components (shadcn/ui primitives)
components: {
Card,
CardHeader,
CardTitle,
CardContent,
Badge,
Button,
Input,
Label,
Select,
SelectOption,
Separator,
Tabs,
TabsList,
TabsTrigger,
},
// Utilities
utils: { cn, timeAgo, isoTimeAgo },
// Hooks
useI18n,
useTheme,
};
}

22
web/src/plugins/types.ts Normal file
View file

@ -0,0 +1,22 @@
/** Types for the dashboard plugin system. */
export interface PluginManifest {
name: string;
label: string;
description: string;
icon: string;
version: string;
tab: {
path: string;
position: string; // "end", "after:<tab>", "before:<tab>"
};
entry: string;
css?: string | null;
has_api: boolean;
source: string;
}
export interface RegisteredPlugin {
manifest: PluginManifest;
component: React.ComponentType;
}

View file

@ -0,0 +1,90 @@
/**
* usePlugins hook discovers and loads dashboard plugins.
*
* 1. Fetches plugin manifests from GET /api/dashboard/plugins
* 2. Injects CSS <link> tags for plugins that declare css
* 3. Loads plugin JS bundles via <script> tags
* 4. Waits for plugins to call register() and resolves them
*/
import { useState, useEffect, useRef } from "react";
import { api } from "@/lib/api";
import type { PluginManifest, RegisteredPlugin } from "./types";
import { getPluginComponent, onPluginRegistered } from "./registry";
export function usePlugins() {
const [manifests, setManifests] = useState<PluginManifest[]>([]);
const [plugins, setPlugins] = useState<RegisteredPlugin[]>([]);
const [loading, setLoading] = useState(true);
const loadedScripts = useRef<Set<string>>(new Set());
// Fetch manifests on mount.
useEffect(() => {
api
.getPlugins()
.then((list) => {
setManifests(list);
if (list.length === 0) setLoading(false);
})
.catch(() => setLoading(false));
}, []);
// Load plugin assets when manifests arrive.
useEffect(() => {
if (manifests.length === 0) return;
for (const manifest of manifests) {
// Inject CSS if specified.
if (manifest.css) {
const cssUrl = `/dashboard-plugins/${manifest.name}/${manifest.css}`;
if (!document.querySelector(`link[href="${cssUrl}"]`)) {
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = cssUrl;
document.head.appendChild(link);
}
}
// Load JS bundle.
const jsUrl = `/dashboard-plugins/${manifest.name}/${manifest.entry}`;
if (loadedScripts.current.has(jsUrl)) continue;
loadedScripts.current.add(jsUrl);
const script = document.createElement("script");
script.src = jsUrl;
script.async = true;
script.onerror = () => {
console.warn(`[plugins] Failed to load ${manifest.name} from ${jsUrl}`);
};
document.body.appendChild(script);
}
// Give plugins a moment to load and register, then stop loading state.
const timeout = setTimeout(() => setLoading(false), 2000);
return () => clearTimeout(timeout);
}, [manifests]);
// Listen for plugin registrations and resolve them against manifests.
useEffect(() => {
function resolvePlugins() {
const resolved: RegisteredPlugin[] = [];
for (const manifest of manifests) {
const component = getPluginComponent(manifest.name);
if (component) {
resolved.push({ manifest, component });
}
}
setPlugins(resolved);
// If all plugins registered, stop loading early.
if (resolved.length === manifests.length && manifests.length > 0) {
setLoading(false);
}
}
resolvePlugins();
const unsub = onPluginRegistered(resolvePlugins);
return unsub;
}, [manifests]);
return { plugins, manifests, loading };
}