Ignition 8.3 Menu Actions Mount Named React Bundle Exports

Daniel Price14 min read
HMI / SCADAOther ManufacturerTechnical Reference
Licensed PE Working through this on a live machine? A Maine-licensed engineer can take it from here — included with IMD hardware, by the hour for everything else. Book an engineer

Which hops carry a device data file request?

Follow the packet. In Ignition 8.3 the browser, not Java, drives a device configuration file. A React component running in the gateway web UI sends HTTP requests to the gateway's resource REST API. The API resolves the request against the resource store for module com.inductiveautomation.opcua, resource type device, and reads or writes one of the resource's data files. There is no Wicket page in the path and no server-side form post. The Java side of your module has two jobs: get the files into the resource store, and tell the web UI which React component a menu action mounts.

Three endpoints cover list, read, and replace:

Operation Method Path Request detail
List device resources GET /data/api/v1/resources/list/com.inductiveautomation.opcua/device Header Accept: application/json
Get device data file GET /data/api/v1/resources/datafile/com.inductiveautomation.opcua/device/{name}/{file} Works as a plain anchor link with a download attribute
Update device data file PUT /data/api/v1/resources/datafile/com.inductiveautomation.opcua/device/{name}/{file}?signature={signature} Body is the raw file. A Content-Type header is required.

The device name is the path key, not a separate ID. The signature comes from the list response.

Check before going further: log in to the gateway, open the list URL in the same browser, and confirm that JSON comes back. If this fails, no front-end code will fix it. Download the gateway's OpenAPI JSON from the same instance and keep it open. It is the authority for parameter names, paging, and accepted content types. Generated client code that guesses REST conventions (/devices, {deviceId}, bare arrays) will not match these paths or response shapes.

Are the file-based resources in the resource store at all?

Layer one of this stack is the data. A device extension point migrated from 8.1 IDB records through IdbMigrationStrategy brings its record fields into the resource's config JSON. The default translation only knows about record columns. Anything the driver kept as separate files never reaches the resource unless you add it. Examples are an EtherNet/IP I/O definition in XML or a Modbus address map in CSV.

The extension point migrator exposes an augmenter hook for this. The augmenter runs during migration and lets the module attach extra content, such as those external files, to the new resource as named data files. Modules that kept XML-based configuration to work around Wicket limitations in 8.1 can keep XML as the file format in 8.3. Only the storage location changes: the file moves from a side path on disk to a data file owned by the device resource.

Check: call the list endpoint and inspect each migrated device's data array. It must name every file the driver needs. An empty array on a device that had an external file means the augmenter did not attach it. Fix the migration before building any UI, because the UI can only enumerate what the list endpoint returns.

Which files does each driver type carry?

A menu action for upload and retrieval does not need a full editor. It only needs to know which file names are legal for the device type in the row, and what each file contains:

Driver role Data file(s) Required? Format
EtherNet/IP Host Configuration XML Yes XML
EtherNet/IP Target Configuration XML Yes XML
EtherNet/IP client Configuration XML Optional XML
EtherNet/IP client Device EDS Optional EDS (text)
Modbus drivers Address map Yes CSV

Optional files matter to the UI. The replace flow in the rest of this procedure only overwrites files already listed in data. If a client device has no EDS yet, the UI needs an "add file" path that PUTs a new file name. Confirm in the OpenAPI document that the update endpoint creates a file when the name does not exist. Otherwise, have the augmenter or the device's default config seed an empty placeholder.

Check: for one device of each type, compare the data array against this table. Record which names are fixed and which are optional. The component's upload validation, including accepted file extensions, comes from that list.

What does the list response carry, and why does signature matter?

The list endpoint returns an envelope, not a bare array. These TypeScript types match the response fields that worked against an 8.3 gateway:

interface DeviceProfile { type: string; }
interface DeviceConfig { profile: DeviceProfile; }

interface Device {
  type: string;
  name: string;          // used as the path key in datafile URLs
  description: string;
  config: DeviceConfig;  // config.profile.type = driver type
  signature: string;     // required on PUT
  data: string[];        // data file names
}

interface DevicesMeta { total: number; matching: number; limit: number; offset: number; }
interface DeviceResources { items: Device[]; metadata: DevicesMeta; }

Two fields drive behavior:

  • config.profile.type identifies the driver. Use it to filter rows so that your menu action only acts on your module's device types. Top-level type is the resource type, not the driver.
  • signature is a version token for the resource as read. The PUT carries it back so the gateway can refuse a write based on a stale copy. After any successful write the signature changes, so the component must re-fetch before a second write to the same device.

The metadata block reports paging. If matching exceeds items.length, the response is truncated at limit. Read the paging query parameter names from the OpenAPI document and walk offset until all rows are in.

Check: log metadata and items.length on the first fetch. Confirm matching === items.length, or that paging runs, before trusting the table.

Does the read path work end to end?

Download is the simplest hop. It is a GET that the browser performs with the existing session cookie, so an anchor tag is enough:

const fileUrl = (name: string, file: string) =>
  `/data/api/v1/resources/datafile/com.inductiveautomation.opcua/device/` +
  `${encodeURIComponent(name)}/${encodeURIComponent(file)}`;

// inside the row renderer
<a href={fileUrl(device.name, file)} download={file}>{file}</a>

Encode both path segments. Device names containing spaces or slashes, and file names such as EDS files with spaces, will otherwise break the path or resolve to the wrong resource.

Check: download each file type (host XML, target XML, client EDS, Modbus CSV) and diff each one byte-for-byte against the source file used during migration. A mismatch here points back to the augmenter, such as an encoding change or a line-ending rewrite, and not to the UI.

Why does the PUT return 403 when the GETs succeed?

HTTP semantics narrow the search. A 401 means the gateway did not identify the caller. A 403 means it identified the caller and refused the operation. The same browser session already listed devices and downloaded files, so identity reaches the gateway. The refusal applies to the unsafe method, the target, or the request context.

A known failing configuration is a React app served from a WebDev-mounted folder. It could list and download every data file, but it received 403 Forbidden on every update. That PUT sent Content-Type: text/plain, the raw File object as the body, and ?signature= from the list response.

Work the candidates in order, using the browser developer tools Network tab on the failing request:

Candidate What to look at Decides it
Missing or wrong Content-Type Request headers on the PUT Compare against the media type declared for the update endpoint in the OpenAPI JSON. Missing required headers can produce a 403.
Page context the gateway will not accept writes from Request URL origin, Referer/Origin headers, page path (WebDev mount vs gateway web UI) Run the identical request from a component mounted inside the gateway web UI. If it succeeds there, the context caused the 403.
User lacks configuration-write rights Gateway security settings for the logged-in user's roles Repeat with an account known to have full gateway config access.
Stale or mis-encoded signature Query string on the PUT and the response body Re-fetch the list, then PUT immediately with encodeURIComponent(signature). The response body separates a version conflict from a permission refusal.

Always read the response body. The status code alone does not separate these causes, and the gateway usually says which check failed.

The context candidate carries the most weight. A WebDev endpoint is a separate web application mounted on the gateway. The gateway's own web UI is where menu action components run, alongside the configuration pages that already write resources. Moving the upload into a menu action component puts the request in the same context as the stock config UI. Complete EtherNet/IP configuration UIs, including file management, have been built this way on 8.3.

Check: use WebDev only as a read-only prototyping harness. Do not treat a WebDev 403 as a bug in your module until the same PUT has been tried from inside the web UI.

How does a menu action mount a React component?

A menu action does not construct UI on the Java side. Your module ships a bundled component library, and the bundle's entry point exports named React components. When a user clicks the action on an extension point row, the web UI mounts the component with that export name. It passes the row data and a callback that unmounts the component.

A component that opens a modal looks like this:

import { useState, useEffect, useCallback } from 'react';

// removeMenuComponent: unmounts this component from the menu action
// row: the row data of the entry that invoked the action
export const DeviceFilesAction = ({ removeMenuComponent, row, ...props }: MenuActionComponentProps) => {
  const [open, setOpen] = useState(false);

  useEffect(() => {
    setOpen(true);             // open the modal on mount
  }, []);

  const hide = useCallback(() => {
    removeMenuComponent();
  }, [removeMenuComponent]);

  // row carries the device name and signature for the datafile endpoints
  return <Modal {...props} open={open} onClose={hide} />;
};

Watch one pitfall in circulated snippets. Some destructure removeMenuAction from props but call removeMenuComponent. That compiles only if a variable with that name happens to be in scope. Take the callback's exact name from the MenuActionComponentProps type in the web UI package and use it in both places. If hide calls an undefined function, the modal never closes and the component stays mounted.

An action that only performs work and renders nothing returns null:

export const RefreshAction = () => {
  useEffect(() => {
    // perform the action once on mount
  }, []);
  return null;
};

Export every action at the bundle entry point:

export { DeviceFilesAction } from './DeviceFilesAction';
export { RefreshAction } from './RefreshAction';

The component has no restrictions beyond this contract. A file upload form, a download list, and a type-aware validator can all live inside the modal.

Check: build the bundle and inspect the output entry module. Every action name must appear as a named export. A default export, or an export only reachable through a nested re-export that tree-shaking removed, will not mount.

How does the upload component talk to the datafile endpoint?

Inside the modal, the upload is one PUT. Use the row passed by the menu action instead of a separate list fetch. Then refresh the signature before each write:

const DATAFILE = '/data/api/v1/resources/datafile/com.inductiveautomation.opcua/device';
const LIST = '/data/api/v1/resources/list/com.inductiveautomation.opcua/device';

async function freshSignature(name: string): Promise<string> {
  const r = await fetch(LIST, { headers: { Accept: 'application/json' } });
  if (!r.ok) throw new Error(`list ${r.status}`);
  const body: DeviceResources = await r.json();
  const dev = body.items.find(d => d.name === name);
  if (!dev) throw new Error(`device ${name} not found`);
  return dev.signature;
}

async function replaceFile(name: string, fileName: string, file: File, contentType: string) {
  const sig = await freshSignature(name);
  const url = `${DATAFILE}/${encodeURIComponent(name)}/${encodeURIComponent(fileName)}` +
              `?signature=${encodeURIComponent(sig)}`;
  const r = await fetch(url, {
    method: 'PUT',
    headers: { 'Content-Type': contentType },   // take from the OpenAPI spec
    body: file,
  });
  if (!r.ok) throw new Error(`PUT ${r.status}: ${await r.text()}`);
}

These are the design points and their reasons:

  • Raw body, not FormData. The request that reached the endpoint sent the File directly. A multipart body changes the Content-Type to multipart/form-data with a boundary, and the endpoint will reject or store the multipart envelope unless the OpenAPI document says it accepts multipart.
  • Error text surfaced. Throwing the response body into the UI turns a bare "Upload failed" into the gateway's actual reason. That is the only way to tell a permission refusal from a version conflict in the field.
  • Keep the target file name. The component replaces fileName from the row's data array. It does not use the local file's name, so the driver keeps finding the file it expects.
  • Validate the extension before sending. Accept .xml for host, target and client XML, .eds for the client EDS, and .csv for Modbus, as in the file table above.

For the modal, form fields, and table styling, build from the 8.3.0 WebUI Storybook published with the SDK documentation, not from hand-rolled inline styles. The Storybook components match the gateway UI and have been the most useful reference for building a working config UI.

Check: upload a modified copy of one file, download it again through the anchor, and diff the two. Then confirm that the device's signature in the list response changed.

How do the Java side and the build toolchain connect to the bundle?

On the gateway side, the extension point declares its menu action and points it at the web UI component. The SDK types involved are WebUIComponent and ReactComponentInfo. The one invariant is that the component name the Java side declares must equal the named export in the bundle, character for character. Take the exact constructor and registration calls from the current ignition-sdk-examples-83 repository. Its webui-webpage example is the reference project, together with the SDK Javadocs for the extension point API.

Toolchain pitfalls:

Symptom Cause Action
Example build fails resolving @inductiveautomation/ignition-web-ui Older example revisions pointed at an internal package registry (nexus.ia.local) that is unreachable outside the vendor network Pull the updated example repository. The update removed most of these problems, including leftover 8.1 code in the gateway hook.
Wicket config pages no longer appear 8.1 constructs (ConfigCategory, IConfigTab, DefaultConfigTab, RecordEditForm, getConfigPanels()) belong to the Wicket UI Rebuild each settings page as a React component in the module bundle. Settings records move to resources through the migration strategy.
8.3 module update procedure rejects a new module build Reported for module files whose file names include version and build information Remove the old module and install the new one manually, or drop version and build tokens from the file name.

WebDev remains useful during development. Serving the built bundle from a mounted folder gives a fast edit-reload loop for layout and read-path logic against a live gateway. Final write testing belongs inside the web UI.

Check: install the module and open the device list in the gateway web UI. Confirm that the menu action appears on your device types and not on others, and that clicking it mounts the component without errors in the browser console.

How do you prove the whole chain on a live gateway?

  1. Migrate one 8.1 gateway backup containing an EtherNet/IP Host, a Target, a client with EDS, and a Modbus device. Confirm that each device's data array in the list response names every expected file.
  2. Open the gateway web UI device list. Confirm that the menu action appears only on rows whose config.profile.type belongs to your module.
  3. Invoke the action on each device type. Confirm that the modal opens on mount and closes through the unmount callback, and that a second invocation mounts a fresh instance.
  4. Download every listed file and diff each against its pre-migration source.
  5. Replace one file per type with an edited copy. Watch the PUT in the Network tab: status 2xx, the expected Content-Type, and the signature query parameter present.
  6. Re-list and confirm that the device's signature changed. Then attempt a PUT with the old signature and confirm the gateway rejects it with an explicit message.
  7. Log in as a user without configuration-write rights and repeat one PUT. Confirm the refusal is reported in the modal with the gateway's response text.
  8. Restart the device, or let the driver reload its configuration, and confirm it runs on the replaced file. For example, check the new EtherNet/IP connection definition or the new Modbus addresses in the OPC tag browser. This proves the upload reached the driver.

FAQ

What happens if the menu action's component name does not match the bundle export?

Nothing mounts when the action is clicked, and the browser console reports that the component could not be resolved. Make the name on the Java side identical to the named export at the bundle entry point, and check the built entry module for that export.

What happens if I PUT a device data file with a stale signature?

The gateway refuses the write because the signature no longer matches the resource version. Re-fetch /data/api/v1/resources/list/com.inductiveautomation.opcua/device, take the device's current signature, and send it URL-encoded in the ?signature= query parameter.

What happens if I upload from a WebDev-hosted page instead of a menu action?

List and download GETs work, but the datafile PUT has been observed to return 403 Forbidden from a WebDev-mounted app. Use WebDev for read-only prototyping and run uploads from a React component mounted by the extension point menu action inside the gateway web UI.

Back to blog