Skip to content

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.

Information Circle

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 the module value in your Python RemoteComponent.
  • 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 in assets/. 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:

PropTypeDescription
ui_element_propsobjectFull portal UI state tree, including reported device state and desired state
uiElementobjectModule 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.

Information Circle

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

HookReturnsPurpose
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()stringWebSocket 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:

HookSourcePurpose
useAgentState(agentId)customer_site/hooksTyped wrapper for ui_state channel
useAgentCmds(agentId)customer_site/hooksTyped wrapper for ui_cmds channel
useAgentTags(agentId)customer_site/hooksTyped wrapper for tag_values channel
useAgentSendUiCmd(agentId)customer_site/hooksSend UI commands with logging
useAgents()customer_site/hooksAll agents in the organisation
useAgent(agentId)customer_site/hooksAgent metadata with org context
useHasAgentPermission(bit)customer_site/hooksCheck user permission for the agent
useLocationHistory(agentId)customer_site/hooksHistorical location data with pagination

Other Portal Imports

Import PathWhat It Provides
customer_site/useRemoteParamsuseRemoteParams() — React Router params (returns { agentId, ... })
customer_site/RemoteComponentWrapperContext wrapper (Redux, React Query, theme providers)
customer_site/MultiPlotWidgetSelf-contained multi-series plotting component
customer_site/GraphPopOutContextuseGraphPopOut() — DOM ref for modal graph rendering
customer_site/utilsUtility 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()
ParameterDescription
display_nameLabel for the component
component_urlURL to the widget JS file. Use $config.app().dv_widget_url to resolve from deployment config at runtime.
scopeModule federation container name (must match name in rsbuild.config.ts)
moduleExposed module path (must match a key in exposes, typically "./" + WIDGET_NAME)
childrenOptional child UI elements passed to the component
**kwargsAdditional 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"
  }
}
FieldDescription
build_widget_commandShell command to build the widget. Run by doover app build-widget.
widgetPath 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,
  },
  // ...
});
Warning

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:

  1. Loads the widget script — fetches the JS file from componentUrl and adds it to the page
  2. Initialises module federation — calls __webpack_init_sharing__ to set up the shared scope, then initialises the widget's container (window[scope])
  3. Resolves the component — calls container.get(module) to get the React component factory
  4. Wraps in providers — the RemoteComponentWrapper provides Redux, React Query, and theme context so portal hooks work inside the widget
  5. Renders — passes ui_element_props and uiElement as 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