[PROPOSAL] - Plugin System for Dashboard extensibility #495
Replies: 2 comments
|
In the several updates an That should be generalized into obscured-input of some type for general secrets that need to be passed (like an API KEY). |
|
@kjaymiller, really appreciate the effort you've put in to this proposal. We really like this feature and think it can have a big impact on community development. That being said, it's a massive feature with a big potential impact. We'd love to collaborate on this change, but we'd like to first gather as many use cases and possible, and ensure we have enough community interest before committing to such a big rework. Is it possible to rethink this proposal into a high level architectural discussion? The existing proposal, while going over the motivation and design considerations, dives almost immediately deep into technical design, whereas we would benefit from aligning on the technical direction first. Perhaps the architecture proposal could also be split into more manageable pieces so that we can start review. Taking a step back, we'd like to understand what you'd like to improve, i.e., your immediate pain point, and any enterprise use cases. Reading your comment, I can see that you'd like to add a simple semantic cache to the frontend. Could elaborate on your use case? Perhaps we can create an issue that has an immediate solution to your pain point, while ensuring this plugin architecture gets the discussion it deserves? |
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
[Proposal]: Plugin System for Valkey Admin — Phase 1 (Panel Capability)
Area Impacted:
area/frontend(primary).area/serveris touched only narrowly, for plugin-config persistence in the web/Kubernetes deployment — see Storage, below.The
plugin-sdkpackage itself is frontend/shared code and does not depend on or modify the backend WebSocket server.Abstract
This RFC proposes the multi-phase of an extensible plugin system for Valkey Admin. Phase 1 introduces a single generic panel capability: a plugin renders a component that attaches to a named
location, wherelocationis any one of Valkey Admin's existing sections (Dashboard, Key Browser, Send Command, Cluster Topology, Hot Keys Monitoring, Big Keys Analysis, Command Logs). Every section already loads a page-specific dataset into the frontend; the panel capability gives plugins read access to that already-loaded data and a slot to render into, without requiring a new contract each time a new section wants plugin support.Phase 1 scope is a shared
plugin-sdkpackage, the genericPanelPlugincontract with alocationfield, dynamic-import loading of pre-built Vite/React ESM bundles, and a Plugin Manager UI for installing and toggling plugins.Phase 2 addresses design-token theming which allows for reskinning of existing datapoints without raw CSS injection into shared DOM.
Phase 3 is entry-renderer overrides and sandboxed code execution covering both custom JavaScript and WASM.
This document describes primarily Phase 1 but makes some early suggestions for Phase 2 and 3.
Motivation
Valkey Admin's seven sections each render a fixed set of built-in content: Dashboard's metric panels, Key Browser's key list and value inspector, Send Command's console, Cluster Topology's node map, Hot Keys and Big Keys' ranked scan results, and Command Logs' aggregated log table. Users and operators regularly want to add something specific to their own deployment on top of any of these, a custom metric panel, a docs-search widget, an AI chatbot
Today the only path is supporting the features directly which creates long-term maintenance burden and creates a bottleneck on building extensions in the community, and requires every change to pass through core review regardless of how narrow its purpose is.
Design considerations
Why one capability with a
locationfield. The seven sections differ in the shape of the data they load (metric samples vs. key metadata vs. log entries vs. topology graphs), but not in the fundamental operation a plugin performs on that data: read it, render something. Modeling this as onePanelPlugintype with alocationdiscriminant, and a per-location payload type, keeps the SDK small and means a plugin author learns one contract regardless of which section they're extending. It also means the host only needs one registry, one loader, and one manager UI, rather than duplicating that machinery per section.Why the panel capability's data access is read-only and page-scoped. Panels receive exactly the dataset the page has already fetched and rendered for its own built-in views. This keeps Phase 1's trust boundary narrow: a panel plugin can mislead a user with a bad visualization, but it cannot read connection credentials, issue new Valkey commands, or access data outside what the section already loaded for its own purposes.
Why reskinning existing datapoints is deferred to Phase 2, and why it is not raw CSS. A plugin whose CSS is scoped to a Shadow DOM boundary around its own panel (the initial design considered here) cannot restyle existing rows in the Command Log, Key Browser, or Dashboard. It can only render new content inside its own region. Reaching an existing datapoint's actual look and feel requires one of two capabilities, both proposed for Phase 2. Raw CSS is explicitly not allowed because CSS on shared elements is a known vector for UI-redress attacks (disguising a destructive action's confirmation, or using attribute-selector plus
background-image: url()tricks to exfiltrate DOM state). Instead I suggest we allow the following:Design-token theming. The host defines a fixed, finite set of CSS custom properties that its own built-in rendering already reads from (for example
--row-bg,--badge-color,--entry-accent). A theme plugin supplies values for whichever tokens it cares about; the host applies them within its own stylesheet. There is no selector, nourl(), and no way to target anything the host didn't explicitly expose as a token — this is safe in a way open CSS injection is not, while still answering the "just want to change how it looks" case.Entry-renderer overrides. For cases needing more than color/spacing changes, a plugin can register a full replacement renderer for a single entry in an existing list, using the same "receive data, return a React element" contract Panel already uses, just at row granularity:
render(entry) => React.ReactElement, mounted into a host-constrained box (fixed dimensions,overflow: hidden, noposition: fixed) so a misbehaving renderer can't bleed outside its row. This gives full visual control over one datapoint without ever handing a plugin a CSS parser pointed at shared DOM. The cost is that the plugin's renderer replaces whatever built-in row behavior existed (click-to-expand, copy buttons, keyboard navigation), so this is a heavier capability than design tokens and is scoped alongside it in Phase 2 rather than Phase 1, but is not deferred as far as the sandboxed-execution work in Phase 3.Why custom JavaScript and WASM execution are deferred further, to Phase 3. Running arbitrary plugin code against live values (key contents, command log entries, metric samples) has the same trust profile as WASM execution, and arguably a harder one to contain: JavaScript has ambient access to
fetch, cookies, and the DOM unless explicitly locked down, where WASM starts from zero capabilities by default. Rather than shipping a lightweight "custom JS" capability ahead of a proper sandbox and a separately shipping, redundant WASM sandbox later, this RFC proposes a single shared sandboxed execution capability (Worker isolation, no network, no DOM access, explicit capability grants for whatever data the host chooses to pass in, execution timeout) that both JavaScript-transform and WASM-transform plugins run inside. Building this once, rather than twice, is why it is scoped as its own phase, after the visual capabilities in Phase 2, not folded into either of them.Early Decisions to Discuss
dynamic
import()of pre-built bundles, not Module Federation, for Phase 1.Both Vite's native dynamic ESM imports and Module Federation support installing a plugin at runtime without rebuilding or redeploying the host.
Module Federation is a bundler-level mechanism (originating in webpack, also available for Vite via
@originjs/vite-plugin-federation) that lets independently built and independently deployed JavaScript bundles expose and consume each other's modules at runtime, with the bundler generating the loading and dependency-sharing glue rather than the application author writing it by hand. Whereas Module Federation's machinery negotiates shared dependencies across independently versioned remotes and publish/consume aremoteEntry.jsmanifest per remote.Dynamic
import()of an independent Vite library build (externalizingreact/react-domso it shares the host's instance) requires the plugin to be built against the exact dependency versions the host provides, with no runtime negotiation if they diverge. That negotiation and hosting overhead isn't justified for a first phase; dynamicimport()is chosen for its simplicity, accepting the exact-version constraint as the cost.Nothing in this RFC defines how one installed plugin would discover or call another regardless of which loading mechanism sits underneath. Deferred to a later RFC if demand justifies it.
Deployment-target parity. The loading mechanism (fetch plus dynamic
import()of an ES module) works identically in the Electron renderer, the Docker web build, and the Kubernetes web build, since all three run the same Vite-built frontend in a browser context. No deployment-specific plugin-loading code is required in Phase 1.Panel plugins run inside a SES Compartment with a declared network allowlist, not an iframe, and not the host's unrestricted JS context. A Panel plugin that shares the host's JS heap has, by default, unrestricted network access alongside its
page-data:readpermission. That combination is a full data-exfiltration path with no enforcement behind it, regardless of what the manifest claims. Some enforcement boundary is required; the question is which one.An iframe was the first boundary considered here, and it does work: each iframe is its own Realm, communication happens over
postMessage, and aContent-Security-Policyon the iframe can restrictconnect-srcto declared hosts. But it also imposes real UX and engineering cost. Serialization overhead on every prop/event crossing the boundary, layout and sizing handled viaResizeObserverrelayed across frames rather than natural document flow, and cross-frame focus and accessibility behavior that consistently needs extra handling.This RFC instead specifies a Secure ECMAScript (SES) Compartment, rendering into a Shadow DOM node in the main document. SES is a Hardened JavaScript proposal implementation used in production by MetaMask Snaps and Agoric). SES locks down the JavaScript environment and runs plugin code inside a Compartment: a restricted global scope endowed with only the specific capabilities the host chooses to hand it, rather than the ambient
window,document,fetch, and cookies a normal script gets. Network access is enforced the same way it would be with an iframe's CSP. A capability-restrictedfetchwrapper is the only network primitive the compartment receives, checking every request URL against the manifest's declarednetworkhosts before issuing it. This happens without the iframe's serialization and layout costs, since the plugin's rendered output is a Shadow DOM subtree in the same document, following normal layout flow and inheriting the host's theme CSS variables directly.This is a deliberate trade of boundary strength for integration cost, and that trade should be stated plainly rather than implied. A SES Compartment's isolation is a language-level, same-process boundary: it depends on the
seslibrary correctly freezing every JavaScript intrinsic and never leaking a live reference out of the compartment, not on a browser-enforced process boundary. A compartment-escape bug is a JavaScript library vulnerability where an iframe-escape bug is a browser security incident. Given that Panel plugins in Phase 1 are expected to be manually installed by the person running Valkey Admin (not distributed through an open marketplace yet) this RFC judges the trade acceptable now, with the explicit note that if the plugin ecosystem later grows to include less-vetted, widely distributed third-party plugins, the isolation boundary should be re-evaluated against real usage data at that point, rather than assumed sufficient indefinitely on the strength of this RFC alone.Capability label is derived from typed manifest fields, never plugin-supplied prose. The install-time disclosure a user sees must be computed deterministically from
permissionsandnetworkand never sourced from a free-text description the plugin author writes. A label a plugin could word however it likes would be worthless as a safety signal, since nothing stops a plugin from describing itself as harmless regardless of what it actually declares. The label escalates through three tiers: no special access (neither field populated), a plain-language row per declared permission or host (page-data:readalone, ornetworkalone), and a distinct, visually separated combined-risk callout whenever bothpage-data:readand a non-emptynetworkare present together.Credentials are declared by name and resolved into a pre-configured capability, rather than handed to the plugin as a raw value. A network-enabled Panel plugin often needs to authenticate to the host it's calling. An embedded AI-assistant panel calling an LLM provider's API is the representative case. The provider's own guidance on this exact pattern is explicit that embedding a credential in client-side code lets anyone with access to that code steal it. Because the plugin's source is fetched as plain text and evaluated inside the compartment, a credential baked into the bundle itself would be exposed to precisely that risk. This RFC instead has the plugin declare a named
SecretRequest(a label to prompt the user with, and the host it applies to) rather than a credential value. The Plugin Manager prompts the user for that credential at install time (or via Environment Variable), stores it in the same encrypted local config store used elsewhere, and never passes the raw value into the compartment. Instead, the host constructs a pre-configured request function (credential already attached to its headers) and endows that function as the capability the plugin calls. The plugin can invoke the capability but never holds, logs, or could exfiltrate the credential itself. This is defense in depth on top of the network allowlist.Comparisons with similar features in other projects
Grafana plugins integrate through "extension points" — UI slots the host declares, which plugins fill by registering against a
targetsarray naming the slot(s) they attach to, rather than each slot requiring its own plugin type. Valkey Admin'slocation-parameterizedPanelPluginfollows the same underlying idea: one plugin contract, a declared target, resolved by the host's registry at runtime. This RFC differs from Grafana's model in scope, not structure — Grafana's extension points also cover menu links and exposed components across an entire application shell with many plugin types (data sources, panels, apps); Phase 1 here covers exactly one plugin type (panels) across Valkey Admin's seven existing sections.MetaMask Snaps is the production precedent for the isolation approach proposed here. Snaps are third-party JavaScript modules that extend a browser extension with access to sensitive data, and MetaMask locks them down using Secure ECMAScript (SES), the same mechanism this RFC proposes for Panel plugins, restricting each Snap's access to global JavaScript APIs and isolating it from the rest of the extension. Where this RFC's approach differs from MetaMask's full deployment: MetaMask layers SES inside an iframe as a second, browser-enforced boundary, since Snaps are distributed through an open ecosystem of largely unvetted third-party code. This RFC proposes the SES Compartment alone for Phase 1, without the additional iframe layer, given that Phase 1 plugins are expected to be manually installed by the person running the application rather than distributed through an open marketplace — see the trade-off discussion in Design Considerations, above, for when that assumption should be revisited.
Specification
1. Packaging and loading overview
Before the detailed contracts below, it's worth walking through the full pipeline a plugin travels through end to end, since each stage's rationale is scattered across the Design Considerations section above and is easier to follow as a sequence.
Packaging. A plugin author writes a
PanelPluginagainst@valkey-admin/plugin-sdk's types, then builds it with Vite in library mode using IIFE format, not the ES-module format that would be the default choice externalizingreact,react-dom, and the SDK to global names that must match what the host endows later (see Plugin authoring workflow, below, for why IIFE and not ESM). The build output is one self-contained script, with noimport/exportsyntax anywhere in it, that assigns itself to a fixed global (globalThis.__plugin__) when run. Alongside it sits a manifest (plugin.json) declaringid,name,version,entry,sdkVersion, and the capability fieldspermissions,network, andsecrets. Manifest plus built script are the entire distributable unit.Distribution. The manifest and script only need to be reachable at a URL, or, on the desktop build, sitting in a local file a user selects. There is no registry or marketplace in Phase 1 (see Out of scope, below) — a plugin is whatever a user points the Plugin Manager at.
Install. "Install from URL" fetches the manifest, checks the declared
sdkVersionrange against the host's actual SDK version, and rejects the manifest outright ifnetworkcontains anything other than exact hostnames. It computes the capability label deterministically frompermissions/network/secrets(see Capability label, below), shows the combined-risk callout when applicable, and prompts for any namedsecretsvalues. Only after explicit acknowledgment does the host persist the manifest, enabled state, and any entered credentials to the local config store. Nothing about the plugin's actual code executes at install time — only its declared manifest fields are read.Load. Loading happens lazily, the first time a section with a matching enabled plugin renders (see Host-side loading, below, for the full mechanism). In short: the entry script is fetched as plain text, never through native
import(), since native dynamic import always runs in the page's ambient realm and would defeat the sandbox before it started; a scopedfetchand any resolved secret capabilities are constructed; a SESCompartmentis endowed with exactly those and nothing ambient; the script text is run throughcompartment.evaluate(); and the compartment's own isolatedglobalThis.__plugin__is read back out to obtain thePanelPluginobject.Render. The loaded panel registers into a registry keyed by
location. Each section queries that registry for its own location and mounts every matching panel's output into a dedicated Shadow DOM node in its layout, wrapped in a React error boundary so a crashing plugin degrades to an inline error card instead of taking down the section.1.
plugin-sdkpackageNew package under
common/plugin-sdk, published as@valkey-admin/plugin-sdk, versioned independently of the main app using semver. Plugins depend only on this package, never onapps/*internals directly.Panels never receive connection credentials or raw command execution ability, and any network access beyond the host application's own origin must be explicitly declared per-host in the manifest and is enforced by the SES Compartment's endowed capabilities — a plugin with no
networkentries is handed nofetch-equivalent at all, and one with declared hosts is handed afetchwrapper that only permits requests to those exact hosts. This keeps Phase 1's attack surface bounded: a panel plugin can mislead a user with a bad visualization, or — if it declared network hosts and the user approved them — send data to those specific, disclosed destinations, but it cannot read credentials, issue new commands, or reach any host it didn't declare up front.2. Plugin manifest file
Distributed alongside the built plugin bundle. A read-only panel with no network access:
{ "id": "com.acme.docs-search-panel", "name": "Command Docs Search", "version": "1.0.0", "type": "panel", "entry": "dist/module.js", "sdkVersion": "^1.0.0", "permissions": ["page-data:read"] }A panel that also needs network access and a credential — an embedded AI assistant is the representative case:
{ "id": "com.acme.assistant-panel", "name": "AI Assistant", "version": "1.0.0", "type": "panel", "entry": "dist/module.js", "sdkVersion": "^1.0.0", "permissions": ["page-data:read"], "network": ["api.anthropic.com"], "secrets": [ { "name": "llm-api-key", "label": "Anthropic API key", "forHost": "api.anthropic.com" } ] }3. Plugin authoring workflow
A plugin is an independent Vite library project:
The same contract, unchanged, produces a Dashboard metric panel by setting
location: 'dashboard'and typingrenderagainstPanelProps<'dashboard'>instead — no separate plugin type or SDK import is needed to target a different section.The build output is fetched as plain source text and run through the SES Compartment's
evaluate()method by the host loader (see Host-side loading, below).reactandreact-domare still externalized in the Vite build config above, matched by name in theoutput.globalsmapping, so the compartment can be endowed with the host's own instances under those same names, rather than the plugin bundling its own copy.4. Host-side loading
Each section's existing view queries
panelRegistry.get(location)for its own location and mounts each returned panel's rendered output into a dedicated Shadow DOM node attached under that section's layout — giving the plugin real document flow and inherited theme CSS variables without exposing it to, or being exposed to, the rest of the page's DOM. Each mount point is wrapped in a per-panel React error boundary so a plugin exception degrades to an inline error card rather than breaking the section.5. Capability label
Before install, the host renders a capability label computed from the manifest's
permissionsandnetworkfields — never from plugin-supplied text:Manifest validation rejects any
networkentry that is not an exact hostname (no*, no protocol-relative entries) before the label is ever computed, so the label always reflects a concrete, enforceable set of hosts rather than an open-ended grant.6. Plugin Manager UI
New page under Settings → Plugins:
secrets, the user is prompted to enter each credential, which is stored in the encrypted local config store and never displayed again in full)7. Storage
Installed plugin manifests and enabled/disabled state persist in the same local config store Valkey Admin already uses for connection configuration, keyed separately from connection data. This splits by deployment target:
area/serverchange: a read/write path for plugin config, mirroring how connection-state persistence across refresh already required backend involvement rather than being frontend-only.Validation against open issues
Cross-referencing this proposal against currently open issues in
valkey-io/valkey-adminsurfaces concrete support for the design as scoped, and useful evidence for what a later phase should cover. This section lists strong fits (issues Phase 1 or the phases already sketched above can address largely as specified) and partial fits (issues that reveal a real gap in the current capability model, worth tracking rather than papering over).Strong fits
page-data:read, a declarednetworkhost, and asecrets-declared API key resolved into a capability the plugin calls without ever holding the raw value. Phase 1 as specified already covers this.location: 'cluster-topology'Panel reading already-loaded slot-stat data. The issue's proposed drill-down to individual keys is likely expressible within the panel's own rendered interaction, not a new capability.Partial fits (gaps this RFC does not yet address)
PanelPluginprovides. Concrete evidence for a futuretoolbar-actioncapability, tracked in Alternatives considered, above.PanelLocationvalues. No capability in this RFC lets a plugin register an entirely new page — this is thenav-itemidea from the earliest draft of this proposal, never carried into Phase 1, now with a concrete issue behind it.PanelProps<L>only ever hands a panel the one location it's registered against. Whether this is solved with a multi-location declaration on the manifest, or is out of scope for Panel entirely, is an open question this issue raises rather than answers.All reactions