From 2dc306633c24731dbc786f09b909be11597583c2 Mon Sep 17 00:00:00 2001 From: The-Big-Danny Date: Thu, 28 May 2026 19:46:28 +0100 Subject: [PATCH] feat: design and implement modular plugin extension system with frozen sandbox API #88 --- .../dashboard/PluginRegistryView.jsx | 159 ++++++++++ src/components/dashboard/Settings.jsx | 5 +- src/plugins/PluginManager.js | 289 ++++++++++++++++++ src/plugins/index.js | 88 ++++++ src/plugins/runtimeStatusPlugin.jsx | 76 +++++ 5 files changed, 616 insertions(+), 1 deletion(-) create mode 100644 src/components/dashboard/PluginRegistryView.jsx create mode 100644 src/plugins/PluginManager.js create mode 100644 src/plugins/index.js create mode 100644 src/plugins/runtimeStatusPlugin.jsx diff --git a/src/components/dashboard/PluginRegistryView.jsx b/src/components/dashboard/PluginRegistryView.jsx new file mode 100644 index 0000000..84ca3a0 --- /dev/null +++ b/src/components/dashboard/PluginRegistryView.jsx @@ -0,0 +1,159 @@ +import React, { useEffect, useMemo, useState } from "react"; +import { pluginManager, registerActivePlugins } from "../../plugins"; +import { PLUGIN_STATUSES } from "../../plugins/PluginManager"; // Import PLUGIN_STATUSES +function PluginWidgetFrame({ widget }) { + const Component = widget.component; + + return ( +
+
+
+
+ {widget.pluginName} +
+
+ {widget.title} +
+
+
+ {widget.pluginId} +
+
+ +
+ ); +} + +function PluginStatusPill({ status }) { + const colorByStatus = { + [PLUGIN_STATUSES.INITIALIZED]: "var(--green)", + [PLUGIN_STATUSES.REGISTERED]: "var(--cyan)", + [PLUGIN_STATUSES.FAILED]: "var(--red)", + }; + + return ( + + {status} + + ); +} + +export default function PluginRegistryView({ placement = "settings" }) { + const [snapshot, setSnapshot] = useState(() => ({ + plugins: pluginManager.getPluginRecords(), + widgets: pluginManager.getWidgets({ placement }), + dataSources: pluginManager.getDataSources(), + })); + + useEffect(() => { + const refresh = () => { + setSnapshot({ + plugins: pluginManager.getPluginRecords(), + widgets: pluginManager.getWidgets({ placement }), + dataSources: pluginManager.getDataSources(), + }); + }; + + refresh(); + return pluginManager.subscribe(refresh); + }, [placement]); + + useEffect(() => { + registerActivePlugins().catch((error) => { + console.error("Plugin registration failed", error); + }); + }, []); + + const pluginCount = snapshot.plugins.length; + const dataSourceCount = snapshot.dataSources.length; + const widgets = useMemo(() => snapshot.widgets, [snapshot.widgets]); + + return ( +
+
+
+
+
+ Extensions +
+
+ Plugin Registry +
+
+
+ {pluginCount} plugins + {widgets.length} widgets + {dataSourceCount} data sources +
+
+ + {snapshot.plugins.length === 0 ? ( +
+ Plugin discovery is running. +
+ ) : ( +
+ {snapshot.plugins.map((plugin) => ( +
+
+
+ {plugin.name} +
+
+ {plugin.error || plugin.id} +
+
+ +
+ ))} +
+ )} +
+ + {widgets.map((widget) => ( + + ))} +
+ ); +} diff --git a/src/components/dashboard/Settings.jsx b/src/components/dashboard/Settings.jsx index 053ac26..1b7adb0 100644 --- a/src/components/dashboard/Settings.jsx +++ b/src/components/dashboard/Settings.jsx @@ -1,9 +1,10 @@ -import React, { useMemo, useState } from "react"; +import React, { useEffect, useMemo, useState } from "react"; import { useSettings } from "../../hooks/useSettings"; import { useStore } from "../../lib/store"; import { getEnvironmentConfig } from "../../lib/config"; import { saveAlertRule, getAlertRules, deleteAlertRule } from "../../lib/alertRulesDb"; // Import IndexedDB helpers import { ALERT_RULE_TYPE, ALERT_CHANNEL } from "../../lib/alerts"; // Import alert types +import PluginRegistryView from "./PluginRegistryView"; function FieldLabel({ children }) { return ( @@ -136,6 +137,8 @@ export default function Settings() { + + {/* New section for Alert Rules */}
Alert Rules diff --git a/src/plugins/PluginManager.js b/src/plugins/PluginManager.js new file mode 100644 index 0000000..7fe229b --- /dev/null +++ b/src/plugins/PluginManager.js @@ -0,0 +1,289 @@ +import React from "react"; +import { getEnvironmentConfig, loadConfigProfiles, getActiveProfileName } from "../lib/config"; +import { useStore } from "../lib/store"; // Assuming useStore can return the store instance + +const PLUGIN_STATUSES = Object.freeze({ + REGISTERED: "registered", + INITIALIZED: "initialized", + FAILED: "failed", +}); + +const SAFE_STATE_KEYS = Object.freeze([ + "network", + "theme", + "activeTab", + "connectedAddress", + "accountData", + "transactions", + "operations", + "networkStats", + "prices", + "walletConnected", + "walletType", + "walletPublicKey", + "streamStatus", + "streamLedgers", +]); + +const SAFE_ACTION_KEYS = Object.freeze([ + "setActiveTab", + "setNetwork", + "setConnectedAddress", + "setSearchFilters", + "addNotification", + "removeNotification", +]); + +function freezePlainObject(value) { + if (!value || typeof value !== "object") return value; + if (Array.isArray(value)) return Object.freeze(value.map(freezePlainObject)); + + return Object.freeze( + Object.fromEntries( + Object.entries(value).map(([key, entry]) => [key, freezePlainObject(entry)]) + ) + ); +} + +function pickSafeState(state) { + return freezePlainObject( + SAFE_STATE_KEYS.reduce((slice, key) => { + if (state[key] !== undefined) slice[key] = state[key]; + return slice; + }, {}) + ); +} + +function normalizePlugin(rawPlugin) { + const plugin = rawPlugin?.default || rawPlugin; + if (typeof plugin === "function") return plugin(); + return plugin; +} + +function normalizeWidget(widget, pluginRecord, index) { + if (!widget || typeof widget !== "object") return null; + + const Component = widget.component || widget.Component || widget.render; + if (!Component) return null; + + return { + id: String(widget.id || `${pluginRecord.id}:widget:${index}`), + pluginId: pluginRecord.id, + pluginName: pluginRecord.name, + title: widget.title || widget.name || pluginRecord.name, + placement: widget.placement || "settings", + order: Number.isFinite(widget.order) ? widget.order : 100, + props: widget.props || {}, + component: Component, + }; +} + +function normalizeDataSource(dataSource, pluginRecord, index) { + if (!dataSource || typeof dataSource !== "object") return null; + return { + id: String(dataSource.id || `${pluginRecord.id}:data-source:${index}`), + pluginId: pluginRecord.id, + pluginName: pluginRecord.name, + name: dataSource.name || dataSource.id || `Data source ${index + 1}`, + description: dataSource.description || "", + fetch: typeof dataSource.fetch === "function" ? dataSource.fetch : null, + subscribe: typeof dataSource.subscribe === "function" ? dataSource.subscribe : null, + metadata: dataSource.metadata || {}, + }; +} + +function createFallbackPlugin(plugin, reason) { + const id = String(plugin?.id || `invalid-plugin-${Date.now()}`); + const name = String(plugin?.name || id); + + return { + id, + name, + initialize: () => undefined, + getWidgets: () => [ + { + id: `${id}:fallback`, + title: `${name} unavailable`, + placement: "settings", + order: 1000, + component: function PluginFallbackWidget() { + return React.createElement( + "div", + { + style: { + color: "var(--red)", + fontSize: "12px", + lineHeight: 1.5, + }, + }, + reason + ); + }, + }, + ], + getDataSources: () => [], + }; +} + +export class PluginManager { + constructor({ store = useStore() } = {}) { // useStore() to get the actual store instance + this.store = store; + this.plugins = new Map(); + this.listeners = new Set(); + this.initializing = null; + } + + createDashboardApi(pluginId) { + const getCurrentState = () => pickSafeState(this.store.getState()); + + const actions = SAFE_ACTION_KEYS.reduce((safeActions, key) => { + const action = this.store.getState()[key]; + if (typeof action === "function") { + safeActions[key] = (...args) => action(...args); + } + return safeActions; + }, {}); + + return Object.freeze({ + pluginId, + version: "1.0.0", + getState: getCurrentState, + getConfig: () => + freezePlainObject({ + environment: getEnvironmentConfig(), + activeProfileName: getActiveProfileName(), + profiles: loadConfigProfiles(), + }), + actions: Object.freeze(actions), + subscribe: (listener) => { + if (typeof listener !== "function") return () => {}; + return this.store.subscribe((state) => listener(pickSafeState(state))); + }, + logger: Object.freeze({ + info: (...args) => console.info(`[plugin:${pluginId}]`, ...args), + warn: (...args) => console.warn(`[plugin:${pluginId}]`, ...args), + error: (...args) => console.error(`[plugin:${pluginId}]`, ...args), + }), + }); + } + + register(rawPlugin) { + const plugin = normalizePlugin(rawPlugin); + const validationError = this.validate(plugin); + const safePlugin = validationError ? createFallbackPlugin(plugin, validationError) : plugin; + const id = String(safePlugin.id); + + if (this.plugins.has(id)) { + throw new Error(`Plugin ID conflict: "${id}" is already registered.`); + } + + const record = { + id, + name: String(safePlugin.name || id), + plugin: safePlugin, + status: PLUGIN_STATUSES.REGISTERED, + error: validationError || null, + initializedAt: null, + }; + + this.plugins.set(id, record); + this.emitChange(); + return record; + } + + validate(plugin) { + if (!plugin || typeof plugin !== "object") return "Plugin export must be an object or factory."; + if (!plugin.id || typeof plugin.id !== "string") return "Plugin is missing a string id."; + if (!plugin.name || typeof plugin.name !== "string") return "Plugin is missing a string name."; + if (plugin.initialize && typeof plugin.initialize !== "function") return "Plugin initialize hook must be a function."; + if (plugin.getWidgets && typeof plugin.getWidgets !== "function") return "Plugin getWidgets hook must be a function."; + if (plugin.getDataSources && typeof plugin.getDataSources !== "function") return "Plugin getDataSources hook must be a function."; + return null; + } + + async initializeAll() { + if (this.initializing) return this.initializing; + + this.initializing = Promise.all( + Array.from(this.plugins.values()).map(async (record) => { + if (record.status === PLUGIN_STATUSES.INITIALIZED) return record; + + try { + const api = this.createDashboardApi(record.id); + if (typeof record.plugin.initialize === "function") { + await record.plugin.initialize(api); + } + record.status = PLUGIN_STATUSES.INITIALIZED; + record.initializedAt = new Date().toISOString(); + } catch (error) { + record.status = PLUGIN_STATUSES.FAILED; + record.error = error?.message || String(error); + } + this.emitChange(); + return record; + }) + ).finally(() => { + this.initializing = null; + }); + + return this.initializing; + } + + getPluginRecords() { + return Array.from(this.plugins.values()).map((record) => ({ + id: record.id, + name: record.name, + status: record.status, + error: record.error, + initializedAt: record.initializedAt, + })); + } + + getWidgets({ placement } = {}) { + return Array.from(this.plugins.values()) + .flatMap((record) => { + try { + const widgets = + typeof record.plugin.getWidgets === "function" ? record.plugin.getWidgets() : []; + return widgets + .map((widget, index) => normalizeWidget(widget, record, index)) + .filter(Boolean); + } catch (error) { + record.error = error?.message || String(error); + record.status = PLUGIN_STATUSES.FAILED; + return []; + } + }) + .filter((widget) => !placement || widget.placement === placement) + .sort((a, b) => a.order - b.order || a.title.localeCompare(b.title)); + } + + getDataSources() { + return Array.from(this.plugins.values()).flatMap((record) => { + try { + const dataSources = + typeof record.plugin.getDataSources === "function" ? record.plugin.getDataSources() : []; + return dataSources + .map((dataSource, index) => normalizeDataSource(dataSource, record, index)) + .filter(Boolean); + } catch (error) { + record.error = error?.message || String(error); + record.status = PLUGIN_STATUSES.FAILED; + return []; + } + }); + } + + subscribe(listener) { + if (typeof listener !== "function") return () => {}; + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + emitChange() { + this.listeners.forEach((listener) => listener(this)); + } +} + +export const pluginManager = new PluginManager(); +export { PLUGIN_STATUSES }; diff --git a/src/plugins/index.js b/src/plugins/index.js new file mode 100644 index 0000000..2ea3f53 --- /dev/null +++ b/src/plugins/index.js @@ -0,0 +1,88 @@ +import { PluginManager } from "./PluginManager"; +import React from "react"; + +const pluginModules = import.meta.glob("./**/*Plugin.{js,jsx,ts,tsx}", { + eager: false, +}); + +let registrationPromise = null; + +function getPluginFactory(module) { + return module?.default || module?.plugin || module?.createPlugin || null; +} + +function pathToPluginId(prefix, path) { + return `${prefix}.${path.replace(/[^a-z0-9]+/gi, "-").replace(/^-|-$/g, "").toLowerCase()}`; +} + +function registerWithFallback(manager, plugin, path) { + try { + manager.register(plugin); + } catch (error) { + manager.register({ + id: pathToPluginId("conflict", path), + name: `${path} registration conflict`, + initialize: () => undefined, + getWidgets: () => [ + { + id: `${pathToPluginId("conflict", path)}.widget`, + title: "Plugin registration conflict", + placement: "settings", + order: 1000, + component: function PluginConflictWidget() { + return React.createElement( + "div", + { style: { color: "var(--red)", fontSize: "12px" } }, + error?.message || String(error) + ); + }, + }, + ], + getDataSources: () => [], + }); + } +} + +export async function registerActivePlugins(manager = pluginManager) { + if (registrationPromise) return registrationPromise; + + registrationPromise = Promise.all( + Object.entries(pluginModules).map(async ([path, loadModule]) => { + try { + const module = await loadModule(); + const pluginFactory = getPluginFactory(module); + + if (!pluginFactory) { + registerWithFallback(manager, { + id: pathToPluginId("invalid", path), + name: path, + getWidgets: () => [], + getDataSources: () => [], + }, path); + return; + } + + registerWithFallback(manager, pluginFactory, path); + } catch (error) { + const id = pathToPluginId("failed", path); + registerWithFallback(manager, { + id, + name: path, + initialize: () => { + throw error; + }, + getWidgets: () => [], + getDataSources: () => [], + }, path); + } + }) + ) + .then(() => manager.initializeAll()) + .then(() => manager); + + return registrationPromise; +} +export const pluginManager = new PluginManager(); +} + +export { pluginManager }; diff --git a/src/plugins/runtimeStatusPlugin.jsx b/src/plugins/runtimeStatusPlugin.jsx new file mode 100644 index 0000000..58669ff --- /dev/null +++ b/src/plugins/runtimeStatusPlugin.jsx @@ -0,0 +1,76 @@ +function RuntimeStatusWidget({ api }) { + if (!api) { + return ( +
+ Runtime status is initializing. +
+ ); + } + + const state = api.getState(); + const config = api.getConfig(); + + return ( +
+ {[ + ["Network", state.network || "unknown"], + ["Environment", config.environment.environment], + ["Active tab", state.activeTab || "overview"], + ].map(([label, value]) => ( +
+
+ {label} +
+
+ {String(value)} +
+
+ ))} +
+ ); +} + +export default function createRuntimeStatusPlugin() { + let apiRef = null; + + return { + id: "core.runtime-status", + name: "Runtime Status", + initialize(api) { + apiRef = api; + api.logger.info("Runtime status plugin initialized."); + }, + getWidgets() { + return [ + { + id: "core.runtime-status.settings-widget", + title: "Runtime Status", + placement: "settings", + order: 0, + component: RuntimeStatusWidget, + props: { api: apiRef }, + }, + ]; + }, + getDataSources() { + return [ + { + id: "core.runtime-status.dashboard-state", + name: "Dashboard State", + description: "Read-only dashboard state exposed through the plugin API.", + fetch: async () => apiRef?.getState() || {}, + metadata: { scope: "read-only" }, + }, + ]; + }, + }; +}