1065 lines
44 KiB
JavaScript
1065 lines
44 KiB
JavaScript
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.<string, Socket>} */
|
|
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.<string, Socket>} */
|
|
// 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)
|
|
}
|