const fs = require('fs') const path = require('path') const v = require('../../package.json').version const datastore = require('../store/data.js') const statestore = require('../store/state.js') const { appendTopic, addConnectionCredentials } = require('../utils/index.js') // from: https://stackoverflow.com/a/28592528/3016654 function join (...paths) { return paths.map(function (element) { return element.replace(/^\/|\/$/g, '') }).join('/') } /** * Check if an object has a property * TODO: move to test-able utility lib * @param {Object} obj - Object to check for property * @param {String} prop - Property to check for * @returns {boolean} */ function hasProperty (obj, prop) { return Object.prototype.hasOwnProperty.call(obj, prop) } module.exports = function (RED) { const express = require('express') const { Server } = require('socket.io') datastore.setConfig(RED) statestore.setConfig(RED) /** * @typedef {import('socket.io/dist').Socket} Socket * @typedef {import('socket.io/dist').Server} Server */ // store state that can maintain cross re-deployments const uiShared = { app: null, httpServer: null, /** @type { Server } */ ioServer: null, /** @type {Object.} */ connections: {}, settings: {}, contribs: {} } /** * Initialise the Express Server and SocketIO Server in Singleton Pattern * @param {Object} node - Node-RED Node * @param {Object} config - Node-RED Node Config */ function init (node, config) { node.uiShared = uiShared // ensure we have a uiShared object on the node (for testing mainly) if (!config.acceptsClientConfig) { // for those upgrading, we need this for backwards compatibility config.acceptsClientConfig = ['ui-control', 'ui-notification'] } if (!('includeClientData' in config)) { // for those upgrading, we need this for backwards compatibility config.includeClientData = true } // expose these properties at runtime node.acceptsClientConfig = config.acceptsClientConfig // which node types can be scoped to a specific client node.includeClientData = config.includeClientData // whether to include client data in msg payloads // eventually check if we have routes used, so we can support multiple base UIs if (!uiShared.app) { uiShared.app = RED.httpNode || RED.httpAdmin uiShared.httpServer = RED.server // Use the 'dashboard' settings if present, otherwise fallback // to node-red-dashboard 'ui' settings object. uiShared.settings = RED.settings.dashboard || RED.settings.ui || {} // Default no-op middleware uiShared.httpMiddleware = function (req, res, next) { next() } if (uiShared.settings.middleware) { if (typeof uiShared.settings.middleware === 'function' || Array.isArray(uiShared.settings.middleware)) { uiShared.httpMiddleware = uiShared.settings.middleware } } /** * Load in third party widgets */ let packagePath, packageJson if (RED.settings?.userDir) { packagePath = path.join(RED.settings.userDir, 'package.json') packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8')) } else { node.log('Cannot import third party widgets. No access to Node-RED package.json') } if (packageJson && packageJson.dependencies) { Object.entries(packageJson.dependencies)?.filter(([packageName, _packageVersion]) => { return packageName.includes('node-red-dashboard-2-') }).map(([packageName, _packageVersion]) => { const modulePath = path.join(RED.settings.userDir, 'node_modules', packageName) const packagePath = path.join(modulePath, 'package.json') // get third party package.json const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8')) if (packageJson?.['node-red-dashboard-2']) { // loop over object of widgets Object.entries(packageJson['node-red-dashboard-2'].widgets).forEach(([widgetName, widgetConfig]) => { uiShared.contribs[widgetName] = { package: packageName, name: widgetName, src: widgetConfig.output, component: widgetConfig.component } }) } return packageJson }) } /** * Configure Web Server to handle UI traffic */ uiShared.app.use(config.path, uiShared.httpMiddleware, express.static(path.join(__dirname, '../../dist'))) uiShared.app.get(config.path + '/_setup', uiShared.httpMiddleware, (req, res) => { let socketPath = join(RED.settings.httpNodeRoot, config.path, 'socket.io') // if no leading /, add one (happens sometimes depending on httpNodeRoot in settings.js) if (socketPath[0] !== '/') { socketPath = '/' + socketPath } let resp = { RED: { httpAdminRoot: RED.settings.httpAdminRoot, httpNodeRoot: RED.settings.httpNodeRoot }, socketio: { path: socketPath } } // Hook API - onSetup(RED, config, req, res) RED.plugins.getByType('node-red-dashboard-2').forEach(plugin => { if (plugin.hooks?.onSetup) { const _resp = plugin.hooks.onSetup(config, req, res) resp = { ...resp, ..._resp } } }) return res.json(resp) }) // debugging endpoints uiShared.app.get(config.path + '/_debug/datastore/:itemid', uiShared.httpMiddleware, (req, res) => { return res.json(datastore.get(req.params.itemid)) }) uiShared.app.get(config.path + '/_debug/statestore/:itemid', uiShared.httpMiddleware, (req, res) => { return res.json(statestore.getAll(req.params.itemid)) }) // serve dashboard uiShared.app.get(config.path, uiShared.httpMiddleware, (req, res) => { res.sendFile(path.join(__dirname, '../../dist/index.html')) }) uiShared.app.get(config.path + '/*', uiShared.httpMiddleware, (req, res) => { res.sendFile(path.join(__dirname, '../../dist/index.html')) }) node.log(`Node-RED Dashboard 2.0 (v${v}) started at ${config.path}`) /** * Create IO Server for comms between Node-RED and UI */ if (RED.settings.httpNodeRoot !== false) { const root = RED.settings.httpNodeRoot || '/' const fullPath = join(root, config.path) const socketIoPath = join('/', fullPath, 'socket.io') /** @type {import('socket.io/dist').ServerOptions} */ const serverOptions = { path: socketIoPath, maxHttpBufferSize: uiShared.settings.maxHttpBufferSize || 1e6 // SocketIO default size } // console.log('Creating socket.io server at path', socketIoPath) // disable - noisy in tests // store reference to the SocketIO Server uiShared.ioServer = new Server(uiShared.httpServer, serverOptions) uiShared.ioServer.setMaxListeners(0) // prevent memory leak warning // TODO: be more smart about this! if (typeof uiShared.settings.ioMiddleware === 'function') { uiShared.ioServer.use(uiShared.settings.ioMiddleware) } else if (Array.isArray(uiShared.settings.ioMiddleware)) { uiShared.settings.ioMiddleware.forEach(function (ioMiddleware) { uiShared.ioServer.use(ioMiddleware) }) } else { uiShared.ioServer.use(function (socket, next) { if (socket.client.conn.request.url.indexOf('transport=websocket') !== -1) { // Reject direct websocket requests socket.client.conn.close() return } if (socket.handshake.xdomain === false) { return next() } else { socket.disconnect(true) } }) } const bindOn = RED.server ? 'bound to Node-RED port' : 'on port ' + node.port node.log('Created socket.io server ' + bindOn + ' at path ' + socketIoPath) } else { node.warn('Cannot create UI Base node when httpNodeRoot set to false') } } } /** * Close the SocketIO Server */ function close (node, done) { if (!uiShared.ioServer) { done() return } // determine if any ui-pages are left, if so, don't close the server const baseNodes = [] const pageNodes = [] const themes = [] RED.nodes.eachNode(n => { if (n.type === 'ui-page' || n.type === 'ui-link') { pageNodes.push(n) } else if (n.type === 'ui-base' && n.id !== node.id) { baseNodes.push(n) } else if (n.type === 'ui-theme') { themes.push(n) } }) if (pageNodes.length > 0) { // there are still ui-pages, so don't close the server done() return } node.ui.pages.clear()// ensure we clear out any pages that may have been left over // since there are no pages, we can assume widgets and groups are also gone node.ui.widgets.clear() node.ui.groups.clear() if (baseNodes.length > 0) { // there are still other ui-base nodes, don't close the server done() return } // as there are no more instances of ui-page and this is the last ui-base, close the server uiShared.ioServer.removeAllListeners() uiShared.ioServer.disconnectSockets(true) // tidy up if (themes.length === 0) { node.ui.themes.clear() } node.ui.dashboards.clear() // ensure we clear out any dashboards that may have been left over node.uiShared = null // remove reference to ui object done && done() } /** * UI Base Node Constructor. Called each time Node-RED deploy creates / recreates a u-base node. * * _whether this constructor is called depends on if there are any changes to THIS node_ * * _A full Deploy will always call this function as every node is destroyed and re-created_ * @param {Object} n - Node-RED node configuration as entered in the nodes editor */ function UIBaseNode (n) { RED.nodes.createNode(this, n) const node = this node._created = Date.now() n.root = RED.settings.httpNodeRoot || '/' /** @type {Object.} */ // node.connections = {} // store socket.io connections for this node // // re-map existing connections for this base node for (const id in uiShared.connections) { const socket = uiShared.connections[id] if (uiShared.connections[id]._baseId === node.id) { // re establish event handlers socket.on('widget-send', onSend.bind(null, socket)) socket.on('widget-action', onAction.bind(null, socket)) socket.on('widget-change', onChange.bind(null, socket)) socket.on('widget-load', onLoad.bind(null, socket)) } } /** @type {NodeJS.Timeout} */ node.emitConfigRequested = null // used to debounce requests to emitConfig // Configure & Run Express Server init(node, n) /** * Emit an event to all connected UIs * @param {String} event * @param {Object} msg * @param {Object} wNode - the Node-RED node that is emitting the event */ function emit (event, msg, wNode) { Object.values(uiShared.connections).forEach(conn => { if (canSendTo(conn, wNode, msg)) { conn.emit(event, msg) } }) } // surface this so that other nodes can emit messages directly node.emit = emit /** * Checks, given a received msg, and the associated SocketIO connection * whether the msg has been configured to only be sent to particular connections * @param {*} conn - SocketIO Connection Object * @param {*} wNode - The Node-RED node we are sending this to * @param {*} msg - The msg to be sent * @returns {Boolean} - Whether the msg can be sent to this connection */ function canSendTo (conn, wNode, msg) { const nodeAllowsConstraints = wNode ? n.acceptsClientConfig?.includes(wNode.type) : true return (nodeAllowsConstraints && isValidConnection(conn, msg)) || !nodeAllowsConstraints } /** * Checks, given a received msg, and the associated SocketIO connection * whether the msg has been configured to only be sent to particular connections * @param {*} conn - SocketIO Connection Object * @param {*} msg - */ function isValidConnection (conn, msg) { const checks = [] // loop over plugins and check if any have defined a custom isValidConnection function // if so, use that to determine if the connection is valid for (const plugin of RED.plugins.getByType('node-red-dashboard-2')) { if (plugin.hooks?.onIsValidConnection) { checks.push(plugin.hooks.onIsValidConnection(conn, msg)) } } // conduct the core check too if (msg._client?.socketId) { // if a particular socketid has been defined, // we only send comms on the connection that matches that id checks.push(msg._client?.socketId === conn.id) } // ensure all checks validate sending this return !checks.length || !checks.includes(false) } /** * Emit UI Config to all connected UIs * @param {Socket} socket - socket.io socket connecting to the server */ function emitConfig (socket) { // loop over widgets - check statestore if we've had any dynamic properties set for (const [id, widget] of node.ui.widgets) { const state = statestore.getAll(id) if (state) { // merge the statestore with our props to account for dynamically set properties: widget.props = { ...widget.props, ...state } widget.state = { ...widget.state, ...state } } } // loop over pages - check statestore if we've had any dynamic properties set for (const [id, page] of node.ui.pages) { const state = statestore.getAll(id) if (state) { // merge the statestore with our props to account for dynamically set properties: node.ui.pages.set(id, { ...page, ...state }) } } // loop over groups - check statestore if we've had any dynamic properties set for (const [id, group] of node.ui.groups) { const state = statestore.getAll(id) if (state) { // merge the statestore with our props to account for dynamically set properties: node.ui.groups.set(id, { ...group, ...state }) } } // pass the connected UI the UI config socket.emit('ui-config', node.id, { dashboards: Object.fromEntries(node.ui.dashboards), heads: Object.fromEntries(node.ui.heads), pages: Object.fromEntries(node.ui.pages), themes: Object.fromEntries(node.ui.themes), groups: Object.fromEntries(node.ui.groups), widgets: Object.fromEntries(node.ui.widgets) }) } // remove event handler socket listeners for a given socket connection function cleanupEventHandlers (socket) { try { socket.removeAllListeners('widget-action') } catch (_error) { /* do nothing */ } try { socket.removeAllListeners('widget-change') } catch (_error) { /* do nothing */ } try { socket.removeAllListeners('widget-load') } catch (_error) { /* do nothing */ } try { socket.removeAllListeners('widget-send') } catch (_error) { /* do nothing */ } try { socket.removeAllListeners('disconnect') } catch (_error) { /* do nothing */ } // check if any widgets have defined custom socket events // remove their listeners to make sure we clean up properly node.ui?.widgets?.forEach((widget) => { if (widget.hooks?.onSocket) { for (const [eventName] of Object.entries(widget.hooks.onSocket)) { try { socket.removeAllListeners(eventName) } catch (_error) { /* do nothing */ } } } }) } function setupEventHandlers (socket, onConnection) { socket.on('widget-send', onSend.bind(null, socket)) socket.on('widget-action', onAction.bind(null, socket)) socket.on('widget-change', onChange.bind(null, socket)) socket.on('widget-load', onLoad.bind(null, socket)) // check if any widgets have defined custom socket events // most common with third-party widgets that are not part of core Dashboard 2.0 const registered = [] // track which widget types we've already subscribed for node.ui?.widgets?.forEach((widget) => { if (widget.hooks?.onSocket) { for (const [eventName, handler] of Object.entries(widget.hooks.onSocket)) { // we only need add the listener for a given event type the once if (eventName === 'connection') { if (onConnection) { // these handlers are setup as part of an onConnection event, so trigegr these now handler(socket) } } else { widget._onSocketHandlers = widget._onSocketHandlers || {} widget._onSocketHandlers[eventName] = handler.bind(null, socket) socket.on(eventName, widget._onSocketHandlers[eventName]) } } registered.push(widget.type) } }) // handle disconnection socket.on('disconnect', reason => { cleanupEventHandlers(socket) delete uiShared.connections[socket.id] node.log(`Disconnected ${socket.id} due to ${reason}`) }) } /** * on connection handler for SocketIO * @param {Socket} socket socket.io socket connecting to the server */ function onConnection (socket) { // record mapping from connection to he ui-base node socket._baseId = node.id // node.connections[socket.id] = socket // store the connection for later use uiShared.connections[socket.id] = socket // store the connection for later use emitConfig(socket) // clean up then re-register listeners // cleanupEventHandlers(socket) // setup connections, and fire any 'on('connection')' events setupEventHandlers(socket, true) } /** * Handles a widget-action event from the UI * @param {Socket} conn - socket.io socket connecting to the server * @param {String} id - widget id sending the action * @param {*} msg - The node-red msg object to forward * @returns void */ async function onAction (conn, id, msg) { // Hooks API - onAction(conn, id, msg) RED.plugins.getByType('node-red-dashboard-2').forEach(plugin => { if (plugin.hooks?.onAction && msg) { msg = plugin.hooks.onAction(conn, id, msg) } }) if (!msg) { // a plugin has made msg blank - meaning that we don't want to send it on return } msg = addConnectionCredentials(RED, msg, conn, n) // ensure msg is an object. Assume the incoming data is the payload if not if (!msg || typeof msg !== 'object') { msg = { payload: msg } } // get widget node and configuration const { wNode, widgetConfig, widgetEvents } = getWidgetAndConfig(id) // ensure we can get the requested widget from the runtime & that this widget has an onAction handler if (!wNode || !widgetEvents.onAction) { return // widget does not exist (e.g. deleted from NR and deployed BUT the ui page was not refreshed) } // Wrap execution in a try/catch to ensure we don't crash Node-RED try { msg = await appendTopic(RED, widgetConfig, wNode, msg) // pre-process the msg before send on the msg (if beforeSend is defined) if (widgetEvents?.beforeSend && typeof widgetEvents.beforeSend === 'function') { msg = await widgetEvents.beforeSend(msg) } // send the msg onwards wNode.send(msg) } catch (error) { let errorHandler = typeof (widgetEvents.onError) === 'function' ? widgetEvents.onError : null errorHandler = errorHandler || (typeof wNode.error === 'function' ? wNode.error : node.error) errorHandler && errorHandler(error) } } /** * Handles a widget-change event from the UI * @param {Socket} conn - socket.io socket connecting to the server * @param {String} id - widget id sending the action * @param {*} value - The value to send to node-red. Typically this is the payload * @returns void */ async function onChange (conn, id, value) { // console.log('conn:' + conn.id, 'on:widget-change:' + id, value) // get widget node and configuration const { wNode, widgetConfig, widgetEvents } = getWidgetAndConfig(id) if (!wNode) { return // widget does not exist any more (e.g. deleted from NR and deployed BUT the ui page was not refreshed) } let msg = datastore.get(id) || {} RED.plugins.getByType('node-red-dashboard-2').forEach(plugin => { if (plugin.hooks?.onChange) { msg = plugin.hooks.onChange(conn, id, msg) } }) if (!msg) { // a plugin has made msg blank - meaning that we don't want to send it on return } msg = addConnectionCredentials(RED, msg, conn, n) async function defaultHandler (msg, value) { if (typeof (value) === 'object' && value !== null && hasProperty(value, 'payload')) { msg.payload = value.payload } else { msg.payload = value } msg = await appendTopic(RED, widgetConfig, wNode, msg) if (widgetEvents?.beforeSend) { msg = await widgetEvents.beforeSend(msg) } datastore.save(n, wNode, msg) wNode.send(msg) // send the msg onwards } // wrap execution in a try/catch to ensure we don't crash Node-RED try { // Most of the time, we can just use this default handler, // but sometimes a node needs to do something specific (e.g. ui-switch) const handler = typeof (widgetEvents.onChange) === 'function' ? widgetEvents.onChange : defaultHandler await handler(msg, value) } catch (error) { console.log(error) let errorHandler = typeof (widgetEvents.onError) === 'function' ? widgetEvents.onError : null errorHandler = errorHandler || (typeof wNode.error === 'function' ? wNode.error : node.error) errorHandler && errorHandler(error) } } /** * Handles a widget-send event from the UI * This takes a msg input, and emits it from the relevant node (normally a template node) * also stores in the data store, and does not consider any previously stored messages (unlike widget-change) * @param {Socket} conn - socket.io socket connecting to the server * @param {String} id - widget id sending the action * @param {*} msg - The value to send to node-red. Typically this is the payload * @returns void */ async function onSend (conn, id, msg) { // console.log('conn:' + conn.id, 'on:widget-send:' + id, msg) // get widget node and configuration const { wNode, widgetEvents } = getWidgetAndConfig(id) if (!wNode) { return // widget does not exist any more (e.g. deleted from NR and deployed BUT the ui page was not refreshed) } RED.plugins.getByType('node-red-dashboard-2').forEach(plugin => { if (plugin.hooks?.onSend) { msg = plugin.hooks.onSend(conn, id, msg) } }) if (!msg) { // a plugin has made msg blank - meaning that we don't want to send it on return } msg = addConnectionCredentials(RED, msg, conn, n) async function defaultHandler (value) { if (widgetEvents?.beforeSend) { msg = await widgetEvents.beforeSend(msg) } datastore.save(n, wNode, msg) wNode.send(msg) // send the msg onwards } // wrap execution in a try/catch to ensure we don't crash Node-RED try { // Most of the time, we can just use this default handler, // but sometimes a node needs to do something specific (e.g. ui-switch) const handler = typeof (widgetEvents.onSend) === 'function' ? widgetEvents.onSend : defaultHandler await handler(msg) } catch (error) { console.log(error) let errorHandler = typeof (widgetEvents.onError) === 'function' ? widgetEvents.onError : null errorHandler = errorHandler || (typeof wNode.error === 'function' ? wNode.error : node.error) errorHandler && errorHandler(error) } } async function onLoad (conn, id, msg) { // console.log('conn:' + conn.id, 'on:widget-load:' + id, msg) if (!id) { console.error('No widget id provided for widget-load event') return } const { wNode, widgetEvents } = getWidgetAndConfig(id) // any widgets we hard-code into our front end (e.g ui-notification for connection alerts) will start with ui- // Node-RED built nodes will be a random UUID if (!wNode && !id.startsWith('ui-')) { console.log('widget does not exist any more') return // widget does not exist any more (e.g. deleted from NR and deployed BUT the ui page was not refreshed) } async function handler () { let msg = datastore.get(id) const state = statestore.getAll(id) RED.plugins.getByType('node-red-dashboard-2').forEach(plugin => { if (plugin.hooks?.onLoad) { msg = plugin.hooks.onLoad(conn, id, msg, state) } }) if (!msg && !state) { // a plugin has made msg blank - meaning that we do anything else return } conn.emit('widget-load:' + id, msg, state) } // wrap execution in a try/catch to ensure we don't crash Node-RED try { handler() } catch (error) { let errorHandler = typeof (widgetEvents.onError) === 'function' ? widgetEvents.onError : null errorHandler = errorHandler || (typeof wNode.error === 'function' ? wNode.error : node.error) errorHandler && errorHandler(error) } } /** * Get the widget node and associated configuration/event hooks * @param {String} id - ID of the widget * @returns {Object} - { wNode, widgetConfig, widgetEvents, widget } */ function getWidgetAndConfig (id) { // node.ui?.widgets is empty? // themes, groups, etc. are not empty? const wNode = RED.nodes.getNode(id) const widget = node.ui?.widgets?.get(id) const widgetConfig = widget?.props || {} const widgetEvents = widget?.hooks || {} return { wNode, widgetConfig, widgetEvents, widget } } // When a UI connects - send the UI Config from Node-RED to the UI uiShared.ioServer.on('connection', onConnection) // Make sure we clean up after ourselves node.on('close', (removed, done) => { uiShared.ioServer?.off('connection', onConnection) for (const conn of Object.values(uiShared.connections)) { cleanupEventHandlers(conn) } close(node, function (err) { if (err) { node.error(`Error closing socket.io server for ${node.id}`, err) } done() }) }) /** * External Functions for managing UI Components */ // store ui config to be sent to UI node.ui = { heads: new Map(), dashboards: new Map(), pages: new Map(), themes: new Map(), groups: new Map(), widgets: new Map() } node.stores = { data: datastore, state: statestore } /** * Queue up a config emit to the UI. This is a debounced function * NOTES: * * only sockets connected to this node will receive the config * * each ui-node will have it's own connections and will emit it's own config * @returns {void} */ node.requestEmitConfig = function () { if (node.emitConfigRequested) { return } node.emitConfigRequested = setTimeout(() => { try { // emit config to all connected UI for this ui-base Object.values(uiShared.connections).forEach(socket => { emitConfig(socket) }) } finally { node.emitConfigRequested = null } }, 300) } /** * Allow for any child node to emit to all connected UIs */ node.emit = emit node.getBaseURL = function () { // get the endpoint for the ui-base const path = n.path || '' // get our HTTP root, defined by NR Settings const base = RED.settings.httpNodeRoot || '/' const basePart = base.endsWith('/') ? base : `${base}/` const dashPart = path.startsWith('/') ? path.slice(1) : path const fullPath = `${basePart}${dashPart}` return fullPath } node.registerTheme = function (theme) { const { _wireCount, _inputCallback, _inputCallbacks, _closeCallbacks, wires, type, ...t } = theme node.ui.themes.set(t.id, t) } /** * Register allows for pages, widgets, groups, etc. to register themselves with the Base UI Node * @param {*} page * @param {*} widget */ node.register = function (page, group, widgetNode, widgetConfig, widgetEvents) { // console.log('dashboard 2.0, UIBaseNode: node.register(...)', page, group, widgetNode, widgetConfig, widgetEvents) /** * Build UI Config */ // strip widgetConfig of stuff we don't really care about (e.g. Node-RED x/y coordinates) // and leave us just with the properties set inside the Node-RED Editor, store as "props" // store our UI state properties under the .state key too let widget = null if (widgetNode && widgetConfig) { // default states if (statestore.getProperty(widgetConfig.id, 'enabled') === undefined) { statestore.set(n, widgetConfig, null, 'enabled', true) } if (statestore.getProperty(widgetConfig.id, 'visible') === undefined) { statestore.set(n, widgetConfig, null, 'visible', true) } if (statestore.getProperty(widgetConfig.id, 'class') === undefined) { statestore.set(n, widgetConfig, null, 'class', '') } // build widget object widget = { id: widgetConfig.id, type: widgetConfig.type, props: widgetConfig, layout: { width: widgetConfig.width || 3, height: widgetConfig.height || 1, order: widgetConfig.order || 0 }, state: statestore.getAll(widgetConfig.id), hooks: widgetEvents, src: uiShared.contribs[widgetConfig.type] } const parent = RED.nodes.getNode(widgetConfig.z) if (parent && parent.TYPE === 'subflow') { const orderEnv = parent.subflowInstance.env?.find(e => e.key === 'DB2_SF_ORDER') let order = parseInt(orderEnv?.value) if (isNaN(order)) { order = 0 } widget.props.subflow = { id: widgetConfig.z, name: parent.subflowInstance?.name || parent.subflowDef.name, order } } delete widget.props.id delete widget.props.type delete widget.props.x delete widget.props.y delete widget.props.wires if (widget.props.width === '0') { widget.props.width = null } if (widget.props.height === '0') { widget.props.height = null } // merge the statestore with our props toa ccount for dynamically set properties: // loop over props and check if we have any function definitions (e.g. onMounted, onInput) // and stringify them for transport over SocketIO for (const [key, value] of Object.entries(widget.props)) { // supported functions const supported = ['onMounted', 'onInput'] if (supported.includes(key) && typeof value === 'function') { widget.props[key] = value.toString() } else if (key === 'methods') { for (const [method, fcn] of Object.entries(widget.props.methods)) { if (typeof fcn === 'function') { widget.props.methods[method] = fcn.toString() } } } } } // map dashboards by their ID if (!node.ui.dashboards.has(n.id)) { node.ui.dashboards.set(n.id, n) } // map themes by their ID if (page && page.type === 'ui-page' && !node.ui.themes.has(page.theme)) { const theme = RED.nodes.getNode(page.theme) if (theme) { node.registerTheme(theme) } else { node.warn(`Theme '${page.theme}' specified in page '${page.id}' does not exist`) } } // map pages by their ID if (page) { // ensure we have the latest instance of the page's node const { _users, ...p } = page node.ui.pages.set(page.id, p) } // map groups on a page-by-page basis if (group) { const { _user, type, ...g } = group node.ui.groups.set(group.id, g) } // map widgets on a group-by-group basis if (widgetNode && widgetConfig && !node.ui.widgets.has(widget.id)) { node.ui.widgets.set(widget.id, widget) } /** * Helper Function for testing */ if (widgetNode) { widgetNode.getState = function () { return datastore.get(widgetNode.id) } /** * Event Handlers */ // add Node-RED listener to the widget for when it's corresponding node receives a msg in Node-RED widgetNode?.on('input', async function (msg, send, done) { // clean msg - #668 delete msg.res delete msg.req // ensure we have latest instance of the widget's node const wNode = RED.nodes.getNode(widgetNode.id) if (!wNode) { return // widget does not exist any more (e.g. deleted from NR and deployed BUT the ui page was not refreshed) } // Hooks API - onInput(msg) RED.plugins.getByType('node-red-dashboard-2').forEach(plugin => { if (plugin.hooks?.onInput) { msg = plugin.hooks.onInput(msg) } }) if (!msg) { // a plugin has made msg blank - meaning that we do anything else return } try { // pre-process the msg before running our onInput function if (widgetEvents?.beforeSend) { msg = await widgetEvents.beforeSend(msg) } // standard dynamic property handlers if (hasProperty(msg, 'enabled')) { statestore.set(n, widgetNode, msg, 'enabled', msg.enabled) } if (hasProperty(msg, 'visible')) { statestore.set(n, widgetNode, msg, 'visible', msg.visible) } if (hasProperty(msg, 'class') || (hasProperty(msg, 'ui_update') && hasProperty(msg.ui_update, 'class'))) { const cls = msg.class || msg.ui_update?.class statestore.set(n, widgetNode, msg, 'class', cls) } // run any node-specific handler defined in the Widget's component if (widgetEvents?.onInput) { await widgetEvents?.onInput(msg, send) } else { // msg could be null if the beforeSend errors and returns null if (msg) { // store the latest msg passed to node datastore.save(n, widgetNode, msg) if (widgetConfig.topic || widgetConfig.topicType) { msg = await appendTopic(RED, widgetConfig, wNode, msg) } if (hasProperty(widgetConfig, 'passthru')) { if (widgetConfig.passthru) { send(msg) } } else { send(msg) } } } // emit to all connected UIs emit('msg-input:' + widget.id, msg, wNode) done() } catch (err) { if (err.type === 'warn') { wNode.warn(err.message) done() } else { done(err) } } }) // when a widget is "closed" remove it from this Base Node's knowledge widgetNode?.on('close', function (removed, done) { if (removed) { // widget has been removed from the Editor // clear any data from datastore datastore.clear(widgetNode.id) } node.deregister(null, null, widgetNode) done() }) } node.requestEmitConfig() // queue up a config emit to the UI } node.deregister = function (page, group, widgetNode) { let changes = false // remove widget from our UI config if (widgetNode) { const widget = node.ui.widgets.get(widgetNode.id) if (widget.hooks?.onSocket) { // We have some custom socketIO hooks to remove // loop over SocketIO connections for (const socket of Object.values(uiShared.connections)) { // loop over events for (const [eventName] of Object.entries(widget.hooks.onSocket)) { // remove the listener for this event if (widget._onSocketHandlers) { socket.off(eventName, widget._onSocketHandlers[eventName]) } } } } node.ui.widgets.delete(widgetNode.id) changes = true } // if there are no more widgets on this group, remove the group from our UI config if (group && [...node.ui.widgets].filter(w => w.props?.group === group.id).length === 0) { node.ui.groups.delete(group.id) changes = true } // if there are no more groups on this page, remove the page from our UI config if (page && [...node.ui.groups].filter(g => g.page === page.id).length === 0) { node.ui.pages.delete(page.id) changes = true } if (changes) { node.requestEmitConfig() } } // Finally, queue up a config emit to the UI. // NOTE: this is a cautionary measure only - typically the registration of nodes will queue up a config emit // but in cases where the dashboard has no widgets registered, we still need to emit a config node.requestEmitConfig() } RED.nodes.registerType('ui-base', UIBaseNode) }