Widgets
Widgets are custom React micro-frontends that load inside the Doover portal. When the standard declarative UI elements are not enough — for example, custom visualisations, interactive maps, or domain-specific control panels — you can build a widget and embed it in any application's UI tree.
Widgets use Module Federation to share React, React Query, and other libraries with the portal at runtime. The portal loads the widget JavaScript dynamically and renders it as a first-class UI element, with full access to portal hooks for reading device state, subscribing to channels, and sending commands.
In the codebase and Python SDK, widgets are sometimes called "remote components" — the terms are interchangeable. The Python class is RemoteComponent and the UI schema type is uiRemoteComponent.
When to Use Widgets
Use the declarative UI system by default. Build a widget when you need:
- Custom visualisations (specialised charts, 3D views, maps)
- Complex interactive layouts that don't map to standard elements
- Third-party JavaScript libraries (e.g., D3, Three.js, Leaflet)
- Multi-step workflows or wizards with client-side state
If your UI is primarily numeric displays, toggles, sliders, and charts, the declarative system is simpler and requires no frontend code.
Project Structure
A widget lives in a subdirectory of the application repo, typically remote-component/:
my-app/
├── doover_config.json # App config (widget build/upload settings)
├── remote-component/ # Widget source
│ ├── rsbuild.config.ts # Build config with module federation
│ ├── ConcatenatePlugin.ts # Bundles output into a single JS file
│ ├── package.json
│ └── src/
│ ├── MyWidget.js # Entry point component
│ ├── CommsManager.js # Data access layer (optional)
│ ├── SubComponent.js # Child components
│ └── Style.css
└── assets/
└── MyWidget.js # Build output (single concatenated file)
Setting Up a Widget
1. Install Dependencies
cd remote-component npm install
The essential dependencies:
{
"dependencies": {
"@module-federation/enhanced": "^0.21.6",
"@module-federation/rsbuild-plugin": "^0.21.6",
"@rsbuild/core": "^1.6.14",
"@rsbuild/plugin-react": "^1.4.2",
"doover-js": "^0.4.8",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router": "~7.7.0"
}
}
Add any UI libraries your widget needs (e.g., @mui/material, d3, leaflet).
2. Configure Module Federation
Create rsbuild.config.ts with module federation settings:
import { defineConfig } from "@rsbuild/core";
import { pluginReact } from "@rsbuild/plugin-react";
import { pluginModuleFederation } from "@module-federation/rsbuild-plugin";
import ConcatenatePlugin from "./ConcatenatePlugin.ts";
const WIDGET_NAME = "MyWidget";
const isDev = process.env.NODE_ENV === "development";
const mfConfig = {
name: WIDGET_NAME,
// Import shared modules from the portal
remotes: {
customer_site: "customer_site@[window.dooverAdminSite_remoteUrl]",
},
// Export the widget component
exposes: {
[`./${WIDGET_NAME}`]: `./src/${WIDGET_NAME}`,
},
// Share libraries with the portal (singletons avoid duplicate React)
shared: {
react: { singleton: true, requiredVersion: "^18.3.1", eager: true },
"react-dom": { singleton: true, requiredVersion: "^18.3.1", eager: true },
"react-router": { singleton: true, requiredVersion: "^7.7.0", eager: true },
"doover-js": { singleton: true, eager: true, requiredVersion: false },
"doover-js/react": { singleton: true, eager: true, requiredVersion: false },
"customer_site/hooks": { singleton: true, requiredVersion: false },
"customer_site/RemoteAccess": { singleton: true, requiredVersion: false },
"customer_site/queryClient": { singleton: true, requiredVersion: false },
"@refinedev/core": { singleton: true, eager: true, requiredVersion: false },
"@tanstack/react-query": {
singleton: true,
eager: true,
requiredVersion: false,
},
},
};
const rspackPlugins: any[] = [];
if (!isDev) {
rspackPlugins.push(
new ConcatenatePlugin({
source: "./dist",
destination: "../assets",
name: `${WIDGET_NAME}.js`,
ignore: ["main.js"],
})
);
}
export default defineConfig({
server: {
port: 8001,
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods":
"GET, POST, PUT, DELETE, PATCH, OPTIONS",
"Access-Control-Allow-Headers":
"X-Requested-With, content-type, Authorization",
},
},
tools: {
rspack: { plugins: rspackPlugins },
},
output: {
injectStyles: true,
},
plugins: [pluginReact(), pluginModuleFederation(mfConfig)],
performance: {
chunkSplit: { strategy: "all-in-one" },
},
});
Key settings:
remotes.customer_site-- tells module federation where to find portal-exported modules at runtime. The[window.dooverAdminSite_remoteUrl]placeholder is resolved by the portal.exposes-- maps the entry point component. Must match themodulevalue in your PythonRemoteComponent.shared-- marks libraries as singletons so the widget reuses the portal's instances of React, React Query, and the portal hooks. This avoids duplicate React errors and ensures shared state.ConcatenatePlugin-- after build, merges all output JS files into a single file inassets/. The platform expects a single JS file per widget.
3. The ConcatenatePlugin
The portal loads each widget as a single JavaScript file. The ConcatenatePlugin concatenates the Rsbuild output into one file after build:
import * as fs from "fs";
import * as path from "path";
interface ConcatenatePluginOptions {
source?: string;
destination: string;
name: string;
ignore?: string[];
}
class ConcatenatePlugin {
readonly source: string;
readonly destination: string;
readonly name: string;
readonly ignore: string[];
constructor(options: ConcatenatePluginOptions) {
this.source = options.source || "./dist";
this.destination = path.resolve(options.destination);
this.name = options.name;
this.ignore = options.ignore || [];
}
apply(compiler: any): void {
compiler.hooks.afterEmit.tapAsync(
"ConcatenatePlugin",
(compilation: any, callback: (error?: Error | null) => void) => {
try {
const sourceDir = path.resolve(this.source);
const jsFiles = this.findJsFiles(sourceDir, this.ignore);
const contents = jsFiles
.map((file) => fs.readFileSync(file, "utf8"))
.join("\n");
if (!fs.existsSync(this.destination)) {
fs.mkdirSync(this.destination, { recursive: true });
}
fs.writeFileSync(path.join(this.destination, this.name), contents);
callback();
} catch (error) {
callback(error as Error);
}
}
);
}
private findJsFiles(dir: string, ignore: string[] = []): string[] {
let results: string[] = [];
for (const file of fs.readdirSync(dir)) {
const filePath = path.join(dir, file);
if (fs.statSync(filePath).isDirectory()) {
results = results.concat(this.findJsFiles(filePath, ignore));
} else if (path.extname(file) === ".js" && !ignore.includes(file)) {
results.push(filePath);
}
}
return results.sort();
}
}
export default ConcatenatePlugin;
4. NPM Scripts
Add build and dev scripts to package.json:
{
"scripts": {
"build": "rsbuild build",
"dev": "rsbuild dev",
"watch": "rsbuild build --watch"
}
}
Writing the Widget Component
The entry point component receives two props from the portal:
| Prop | Type | Description |
|---|---|---|
ui_element_props | object | Full portal UI state tree, including reported device state and desired state |
uiElement | object | Module federation metadata (scope, module, componentUrl) |
Minimal Example
import React from "react";
import { useChannelAggregate } from "doover-js/react";
import { useRemoteParams } from "customer_site/useRemoteParams";
export default function MyWidget({ ui_element_props, uiElement }) {
const { agentId } = useRemoteParams();
const { data: state, isLoading } = useChannelAggregate(
agentId,
"ui_state"
);
if (isLoading) return <div>Loading...</div>;
return (
<div>
<h3>Device State</h3>
<pre>{JSON.stringify(state?.reported, null, 2)}</pre>
</div>
);
}
Data Access
Widgets access platform data through hooks from the doover-js npm package. Install it as a dev dependency for TypeScript types during development:
npm install --save-dev doover-js
At runtime, the portal and all widgets share a single doover-js instance via module federation — this ensures one WebSocket connection and one shared React Query cache across the entire page.
The portal also exports convenience wrappers via customer_site/hooks (e.g., useAgentState, useAgentTags). These are thin wrappers around doover-js/react hooks that pre-fill channel names. You can use either, but importing from doover-js/react directly gives you proper TypeScript types, versioned APIs, and less coupling to the portal's internals.
Subscribing to Channels
import { useChannelAggregate } from "doover-js/react";
function SensorData() {
const { agentId } = useRemoteParams();
const { data: aggregate, isLoading } = useChannelAggregate(
agentId,
"sensor_readings"
);
if (isLoading) return <div>Loading...</div>;
return <div>Latest: {JSON.stringify(aggregate?.data)}</div>;
}
Use useChannelAggregate for any channel — pass the channel name to subscribe to its aggregate state. For well-known channels, the portal's customer_site/hooks provides typed shortcuts:
// These are equivalent:
const { data } = useChannelAggregate(agentId, "ui_state"); // doover-js
const { state } = useAgentState(agentId); // customer_site/hooks
Reading Channel History
import { useChannelMessages } from "doover-js/react";
function MessageLog() {
const { agentId } = useRemoteParams();
const { messages, hasMore, fetchNextPage } = useChannelMessages(
{ agentId, channelName: "activity_logs" },
["message"],
50
);
return (
<ul>
{messages?.map((msg) => (
<li key={msg.id}>{msg.data.message}</li>
))}
{hasMore && <button onClick={fetchNextPage}>Load more</button>}
</ul>
);
}
Sending Commands and Updates
import { updateAggregate, useSendMessage } from "doover-js/react";
function Controls() {
const { agentId } = useRemoteParams();
// Update a channel's aggregate
const { mutate: updateState } = updateAggregate(
agentId,
"device_config"
);
// Publish a new message to a channel
const { mutate: sendMessage } = useSendMessage(
agentId,
"commands"
);
return (
<>
<button onClick={() => updateState({ data: { mode: "standby" } })}>
Standby
</button>
<button onClick={() => sendMessage({ data: { restart: true } })}>
Restart
</button>
</>
);
}
doover-js/react Hook Reference
| Hook | Returns | Purpose |
|---|---|---|
useChannelAggregate(agentId, channel) | { data, isLoading, ... } | Subscribe to a channel's aggregate state |
useChannelMessages(channel, fields?, limit?) | { messages, hasMore, fetchNextPage, ... } | Paginated message history |
useChannelMessage(channel, messageId) | { data, ... } | Single message by ID |
updateAggregate(agentId, channel) | { mutate, ... } | Update channel aggregate |
useSendMessage(agentId, channel) | { mutate, ... } | Publish a message |
useUpdateMessage(agentId, channel, messageId) | { mutate, ... } | Patch or replace a message |
useSendRpc(agentId, channel) | { mutate, ... } | Send RPC request/response |
useAgentConnections(agentId) | { data, ... } | Agent connection details |
useMultiAgentAggregates(agents, channel) | { data, ... } | Batch aggregates across agents |
useConnectionState() | string | WebSocket connection status |
Portal Convenience Hooks
The portal exports additional hooks via customer_site/hooks that wrap doover-js/react with domain-specific logic. Use these when you need portal-level features not available in the base SDK:
| Hook | Source | Purpose |
|---|---|---|
useAgentState(agentId) | customer_site/hooks | Typed wrapper for ui_state channel |
useAgentCmds(agentId) | customer_site/hooks | Typed wrapper for ui_cmds channel |
useAgentTags(agentId) | customer_site/hooks | Typed wrapper for tag_values channel |
useAgentSendUiCmd(agentId) | customer_site/hooks | Send UI commands with logging |
useAgents() | customer_site/hooks | All agents in the organisation |
useAgent(agentId) | customer_site/hooks | Agent metadata with org context |
useHasAgentPermission(bit) | customer_site/hooks | Check user permission for the agent |
useLocationHistory(agentId) | customer_site/hooks | Historical location data with pagination |
Other Portal Imports
| Import Path | What It Provides |
|---|---|
customer_site/useRemoteParams | useRemoteParams() — React Router params (returns { agentId, ... }) |
customer_site/RemoteComponentWrapper | Context wrapper (Redux, React Query, theme providers) |
customer_site/MultiPlotWidget | Self-contained multi-series plotting component |
customer_site/GraphPopOutContext | useGraphPopOut() — DOM ref for modal graph rendering |
customer_site/utils | Utility functions: cn(), isEqual(), parseUnits(), secondsToDhms(), truncateString() |
Using the MultiPlotWidget
The portal provides a MultiPlotWidget component for embedding time-series charts inside your widget without building chart rendering from scratch.
import { MultiPlotWidget } from "customer_site/MultiPlotWidget";
function Dashboard() {
return (
<MultiPlotWidget
series={{
temperature: { name: "Temperature (°C)", color: "#ff5722" },
humidity: { name: "Humidity (%)", color: "#2196f3" },
}}
applicationName="my_app"
displayName="Environmental Data"
defaultZoom="Adaptive"
/>
);
}
The applicationName must match the application key in the UI state tree. The series keys map to fields under the application's graphData in the UI state.
Python-Side Configuration
RemoteComponent Element
On the Python side, declare the widget as a RemoteComponent in your UI class. This tells the portal to load the widget JavaScript.
from pydoover import ui
from pydoover.ui import ConnectionInfo
WIDGET_NAME = "MyWidget"
class MyUI(ui.UI, default_open=True):
widget = ui.RemoteComponent(
WIDGET_NAME,
component_url="$config.app().dv_widget_url",
scope=WIDGET_NAME,
module="./" + WIDGET_NAME,
)
connection_info = ConnectionInfo()
| Parameter | Description |
|---|---|
display_name | Label for the component |
component_url | URL to the widget JS file. Use $config.app().dv_widget_url to resolve from deployment config at runtime. |
scope | Module federation container name (must match name in rsbuild.config.ts) |
module | Exposed module path (must match a key in exposes, typically "./" + WIDGET_NAME) |
children | Optional child UI elements passed to the component |
**kwargs | Additional keyword arguments are serialised and available to the widget as props |
doover_config.json
The application's doover_config.json needs two widget-related fields:
{
"my_app": {
"build_widget_command": "npm --prefix remote-component run build",
"widget": "assets/MyWidget.js"
}
}
| Field | Description |
|---|---|
build_widget_command | Shell command to build the widget. Run by doover app build-widget. |
widget | Path to the built widget JS file, relative to the app directory. Uploaded by doover app put-widget. |
Set build_widget_command to null for applications that have no widget.
Building and Deploying
Build the Widget
# Build the widget standalone doover app build-widget # Or build manually cd remote-component && npm run build
This runs the build_widget_command from doover_config.json, which triggers Rsbuild. The ConcatenatePlugin merges the output into a single file at the path specified by widget (e.g., assets/MyWidget.js).
Upload the Widget
# Upload standalone doover app put-widget # Specify a custom path doover app put-widget --widget-fp assets/MyWidget.js
Full Publish
The standard doover app publish command builds and uploads the widget automatically as part of the publish flow:
doover app publish
To skip the widget build or upload during publish:
# Skip widget build (use existing built file) doover app publish --no-build-widget # Skip widget upload doover app publish --no-put-widget
Local Development
For local development, use the Rsbuild dev server with ngrok (or similar) to serve the widget to the portal:
# Start the dev server cd remote-component npm run dev
The dev server runs on port 8001 by default with CORS headers enabled. To connect it to the portal, set the NGROK_URL environment variable in your Rsbuild config and configure the dv_widget_url deployment config to point to your ngrok tunnel.
Add dev-specific settings to rsbuild.config.ts:
const NGROK_URL = process.env.NGROK_URL || "https://your-tunnel.ngrok.app";
export default defineConfig({
dev: {
assetPrefix: isDev ? `${NGROK_URL}/` : undefined,
hmr: false,
liveReload: false,
},
output: {
assetPrefix: isDev ? `${NGROK_URL}/` : undefined,
},
// ...
});
HMR and live reload are disabled because module federation remotes do not support them reliably. Refresh the portal page manually after making changes.
How It Works
When the portal encounters a uiRemoteComponent element in the UI state tree, it:
- Loads the widget script — fetches the JS file from
componentUrland adds it to the page - Initialises module federation — calls
__webpack_init_sharing__to set up the shared scope, then initialises the widget's container (window[scope]) - Resolves the component — calls
container.get(module)to get the React component factory - Wraps in providers — the
RemoteComponentWrapperprovides Redux, React Query, and theme context so portal hooks work inside the widget - Renders — passes
ui_element_propsanduiElementas props to the component
Because the widget shares React and React Query instances with the portal, data subscriptions (via useAgentState, useAgentChannel, etc.) use the same cache. State changes in the portal are immediately visible in the widget, and vice versa.
Related Pages
- Containers --
RemoteComponentPython element reference - UI Overview -- introduction to the declarative UI system
- App Management CLI --
build-widget,put-widget, andpublishcommands