Node-Red configuration
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

ui_base.js 45KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064
  1. const fs = require('fs')
  2. const path = require('path')
  3. const v = require('../../package.json').version
  4. const datastore = require('../store/data.js')
  5. const statestore = require('../store/state.js')
  6. const { appendTopic, addConnectionCredentials } = require('../utils/index.js')
  7. // from: https://stackoverflow.com/a/28592528/3016654
  8. function join (...paths) {
  9. return paths.map(function (element) {
  10. return element.replace(/^\/|\/$/g, '')
  11. }).join('/')
  12. }
  13. /**
  14. * Check if an object has a property
  15. * TODO: move to test-able utility lib
  16. * @param {Object} obj - Object to check for property
  17. * @param {String} prop - Property to check for
  18. * @returns {boolean}
  19. */
  20. function hasProperty (obj, prop) {
  21. return Object.prototype.hasOwnProperty.call(obj, prop)
  22. }
  23. module.exports = function (RED) {
  24. const express = require('express')
  25. const { Server } = require('socket.io')
  26. datastore.setConfig(RED)
  27. statestore.setConfig(RED)
  28. /**
  29. * @typedef {import('socket.io/dist').Socket} Socket
  30. * @typedef {import('socket.io/dist').Server} Server
  31. */
  32. // store state that can maintain cross re-deployments
  33. const uiShared = {
  34. app: null,
  35. httpServer: null,
  36. /** @type { Server } */
  37. ioServer: null,
  38. /** @type {Object.<string, Socket>} */
  39. connections: {},
  40. settings: {},
  41. contribs: {}
  42. }
  43. /**
  44. * Initialise the Express Server and SocketIO Server in Singleton Pattern
  45. * @param {Object} node - Node-RED Node
  46. * @param {Object} config - Node-RED Node Config
  47. */
  48. function init (node, config) {
  49. node.uiShared = uiShared // ensure we have a uiShared object on the node (for testing mainly)
  50. if (!config.acceptsClientConfig) {
  51. // for those upgrading, we need this for backwards compatibility
  52. config.acceptsClientConfig = ['ui-control', 'ui-notification']
  53. }
  54. if (!('includeClientData' in config)) {
  55. // for those upgrading, we need this for backwards compatibility
  56. config.includeClientData = true
  57. }
  58. // expose these properties at runtime
  59. node.acceptsClientConfig = config.acceptsClientConfig // which node types can be scoped to a specific client
  60. node.includeClientData = config.includeClientData // whether to include client data in msg payloads
  61. // eventually check if we have routes used, so we can support multiple base UIs
  62. if (!uiShared.app) {
  63. uiShared.app = RED.httpNode || RED.httpAdmin
  64. uiShared.httpServer = RED.server
  65. // Use the 'dashboard' settings if present, otherwise fallback
  66. // to node-red-dashboard 'ui' settings object.
  67. uiShared.settings = RED.settings.dashboard || RED.settings.ui || {}
  68. // Default no-op middleware
  69. uiShared.httpMiddleware = function (req, res, next) { next() }
  70. if (uiShared.settings.middleware) {
  71. if (typeof uiShared.settings.middleware === 'function' || Array.isArray(uiShared.settings.middleware)) {
  72. uiShared.httpMiddleware = uiShared.settings.middleware
  73. }
  74. }
  75. /**
  76. * Load in third party widgets
  77. */
  78. let packagePath, packageJson
  79. if (RED.settings?.userDir) {
  80. packagePath = path.join(RED.settings.userDir, 'package.json')
  81. packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8'))
  82. } else {
  83. node.log('Cannot import third party widgets. No access to Node-RED package.json')
  84. }
  85. if (packageJson && packageJson.dependencies) {
  86. Object.entries(packageJson.dependencies)?.filter(([packageName, _packageVersion]) => {
  87. return packageName.includes('node-red-dashboard-2-')
  88. }).map(([packageName, _packageVersion]) => {
  89. const modulePath = path.join(RED.settings.userDir, 'node_modules', packageName)
  90. const packagePath = path.join(modulePath, 'package.json')
  91. // get third party package.json
  92. const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8'))
  93. if (packageJson?.['node-red-dashboard-2']) {
  94. // loop over object of widgets
  95. Object.entries(packageJson['node-red-dashboard-2'].widgets).forEach(([widgetName, widgetConfig]) => {
  96. uiShared.contribs[widgetName] = {
  97. package: packageName,
  98. name: widgetName,
  99. src: widgetConfig.output,
  100. component: widgetConfig.component
  101. }
  102. })
  103. }
  104. return packageJson
  105. })
  106. }
  107. /**
  108. * Configure Web Server to handle UI traffic
  109. */
  110. uiShared.app.use(config.path, uiShared.httpMiddleware, express.static(path.join(__dirname, '../../dist')))
  111. uiShared.app.get(config.path + '/_setup', uiShared.httpMiddleware, (req, res) => {
  112. let socketPath = join(RED.settings.httpNodeRoot, config.path, 'socket.io')
  113. // if no leading /, add one (happens sometimes depending on httpNodeRoot in settings.js)
  114. if (socketPath[0] !== '/') {
  115. socketPath = '/' + socketPath
  116. }
  117. let resp = {
  118. RED: {
  119. httpAdminRoot: RED.settings.httpAdminRoot,
  120. httpNodeRoot: RED.settings.httpNodeRoot
  121. },
  122. socketio: {
  123. path: socketPath
  124. }
  125. }
  126. // Hook API - onSetup(RED, config, req, res)
  127. RED.plugins.getByType('node-red-dashboard-2').forEach(plugin => {
  128. if (plugin.hooks?.onSetup) {
  129. const _resp = plugin.hooks.onSetup(config, req, res)
  130. resp = { ...resp, ..._resp }
  131. }
  132. })
  133. return res.json(resp)
  134. })
  135. // debugging endpoints
  136. uiShared.app.get(config.path + '/_debug/datastore/:itemid', uiShared.httpMiddleware, (req, res) => {
  137. return res.json(datastore.get(req.params.itemid))
  138. })
  139. uiShared.app.get(config.path + '/_debug/statestore/:itemid', uiShared.httpMiddleware, (req, res) => {
  140. return res.json(statestore.getAll(req.params.itemid))
  141. })
  142. // serve dashboard
  143. uiShared.app.get(config.path, uiShared.httpMiddleware, (req, res) => {
  144. res.sendFile(path.join(__dirname, '../../dist/index.html'))
  145. })
  146. uiShared.app.get(config.path + '/*', uiShared.httpMiddleware, (req, res) => {
  147. res.sendFile(path.join(__dirname, '../../dist/index.html'))
  148. })
  149. node.log(`Node-RED Dashboard 2.0 (v${v}) started at ${config.path}`)
  150. /**
  151. * Create IO Server for comms between Node-RED and UI
  152. */
  153. if (RED.settings.httpNodeRoot !== false) {
  154. const root = RED.settings.httpNodeRoot || '/'
  155. const fullPath = join(root, config.path)
  156. const socketIoPath = join('/', fullPath, 'socket.io')
  157. /** @type {import('socket.io/dist').ServerOptions} */
  158. const serverOptions = {
  159. path: socketIoPath,
  160. maxHttpBufferSize: uiShared.settings.maxHttpBufferSize || 1e6 // SocketIO default size
  161. }
  162. // console.log('Creating socket.io server at path', socketIoPath) // disable - noisy in tests
  163. // store reference to the SocketIO Server
  164. uiShared.ioServer = new Server(uiShared.httpServer, serverOptions)
  165. uiShared.ioServer.setMaxListeners(0) // prevent memory leak warning // TODO: be more smart about this!
  166. if (typeof uiShared.settings.ioMiddleware === 'function') {
  167. uiShared.ioServer.use(uiShared.settings.ioMiddleware)
  168. } else if (Array.isArray(uiShared.settings.ioMiddleware)) {
  169. uiShared.settings.ioMiddleware.forEach(function (ioMiddleware) {
  170. uiShared.ioServer.use(ioMiddleware)
  171. })
  172. } else {
  173. uiShared.ioServer.use(function (socket, next) {
  174. if (socket.client.conn.request.url.indexOf('transport=websocket') !== -1) {
  175. // Reject direct websocket requests
  176. socket.client.conn.close()
  177. return
  178. }
  179. if (socket.handshake.xdomain === false) {
  180. return next()
  181. } else {
  182. socket.disconnect(true)
  183. }
  184. })
  185. }
  186. const bindOn = RED.server ? 'bound to Node-RED port' : 'on port ' + node.port
  187. node.log('Created socket.io server ' + bindOn + ' at path ' + socketIoPath)
  188. } else {
  189. node.warn('Cannot create UI Base node when httpNodeRoot set to false')
  190. }
  191. }
  192. }
  193. /**
  194. * Close the SocketIO Server
  195. */
  196. function close (node, done) {
  197. if (!uiShared.ioServer) {
  198. done()
  199. return
  200. }
  201. // determine if any ui-pages are left, if so, don't close the server
  202. const baseNodes = []
  203. const pageNodes = []
  204. const themes = []
  205. RED.nodes.eachNode(n => {
  206. if (n.type === 'ui-page' || n.type === 'ui-link') {
  207. pageNodes.push(n)
  208. } else if (n.type === 'ui-base' && n.id !== node.id) {
  209. baseNodes.push(n)
  210. } else if (n.type === 'ui-theme') {
  211. themes.push(n)
  212. }
  213. })
  214. if (pageNodes.length > 0) {
  215. // there are still ui-pages, so don't close the server
  216. done()
  217. return
  218. }
  219. node.ui.pages.clear()// ensure we clear out any pages that may have been left over
  220. // since there are no pages, we can assume widgets and groups are also gone
  221. node.ui.widgets.clear()
  222. node.ui.groups.clear()
  223. if (baseNodes.length > 0) {
  224. // there are still other ui-base nodes, don't close the server
  225. done()
  226. return
  227. }
  228. // as there are no more instances of ui-page and this is the last ui-base, close the server
  229. uiShared.ioServer.removeAllListeners()
  230. uiShared.ioServer.disconnectSockets(true)
  231. // tidy up
  232. if (themes.length === 0) {
  233. node.ui.themes.clear()
  234. }
  235. node.ui.dashboards.clear() // ensure we clear out any dashboards that may have been left over
  236. node.uiShared = null // remove reference to ui object
  237. done && done()
  238. }
  239. /**
  240. * UI Base Node Constructor. Called each time Node-RED deploy creates / recreates a u-base node.
  241. * * _whether this constructor is called depends on if there are any changes to THIS node_
  242. * * _A full Deploy will always call this function as every node is destroyed and re-created_
  243. * @param {Object} n - Node-RED node configuration as entered in the nodes editor
  244. */
  245. function UIBaseNode (n) {
  246. RED.nodes.createNode(this, n)
  247. const node = this
  248. node._created = Date.now()
  249. n.root = RED.settings.httpNodeRoot || '/'
  250. /** @type {Object.<string, Socket>} */
  251. // node.connections = {} // store socket.io connections for this node
  252. // // re-map existing connections for this base node
  253. for (const id in uiShared.connections) {
  254. const socket = uiShared.connections[id]
  255. if (uiShared.connections[id]._baseId === node.id) {
  256. // re establish event handlers
  257. socket.on('widget-send', onSend.bind(null, socket))
  258. socket.on('widget-action', onAction.bind(null, socket))
  259. socket.on('widget-change', onChange.bind(null, socket))
  260. socket.on('widget-load', onLoad.bind(null, socket))
  261. }
  262. }
  263. /** @type {NodeJS.Timeout} */
  264. node.emitConfigRequested = null // used to debounce requests to emitConfig
  265. // Configure & Run Express Server
  266. init(node, n)
  267. /**
  268. * Emit an event to all connected UIs
  269. * @param {String} event
  270. * @param {Object} msg
  271. * @param {Object} wNode - the Node-RED node that is emitting the event
  272. */
  273. function emit (event, msg, wNode) {
  274. Object.values(uiShared.connections).forEach(conn => {
  275. if (canSendTo(conn, wNode, msg)) {
  276. conn.emit(event, msg)
  277. }
  278. })
  279. }
  280. // surface this so that other nodes can emit messages directly
  281. node.emit = emit
  282. /**
  283. * Checks, given a received msg, and the associated SocketIO connection
  284. * whether the msg has been configured to only be sent to particular connections
  285. * @param {*} conn - SocketIO Connection Object
  286. * @param {*} wNode - The Node-RED node we are sending this to
  287. * @param {*} msg - The msg to be sent
  288. * @returns {Boolean} - Whether the msg can be sent to this connection
  289. */
  290. function canSendTo (conn, wNode, msg) {
  291. const nodeAllowsConstraints = wNode ? n.acceptsClientConfig?.includes(wNode.type) : true
  292. return (nodeAllowsConstraints && isValidConnection(conn, msg)) || !nodeAllowsConstraints
  293. }
  294. /**
  295. * Checks, given a received msg, and the associated SocketIO connection
  296. * whether the msg has been configured to only be sent to particular connections
  297. * @param {*} conn - SocketIO Connection Object
  298. * @param {*} msg -
  299. */
  300. function isValidConnection (conn, msg) {
  301. const checks = []
  302. // loop over plugins and check if any have defined a custom isValidConnection function
  303. // if so, use that to determine if the connection is valid
  304. for (const plugin of RED.plugins.getByType('node-red-dashboard-2')) {
  305. if (plugin.hooks?.onIsValidConnection) {
  306. checks.push(plugin.hooks.onIsValidConnection(conn, msg))
  307. }
  308. }
  309. // conduct the core check too
  310. if (msg._client?.socketId) {
  311. // if a particular socketid has been defined,
  312. // we only send comms on the connection that matches that id
  313. checks.push(msg._client?.socketId === conn.id)
  314. }
  315. // ensure all checks validate sending this
  316. return !checks.length || !checks.includes(false)
  317. }
  318. /**
  319. * Emit UI Config to all connected UIs
  320. * @param {Socket} socket - socket.io socket connecting to the server
  321. */
  322. function emitConfig (socket) {
  323. // loop over widgets - check statestore if we've had any dynamic properties set
  324. for (const [id, widget] of node.ui.widgets) {
  325. const state = statestore.getAll(id)
  326. if (state) {
  327. // merge the statestore with our props to account for dynamically set properties:
  328. widget.props = { ...widget.props, ...state }
  329. widget.state = { ...widget.state, ...state }
  330. }
  331. }
  332. // loop over pages - check statestore if we've had any dynamic properties set
  333. for (const [id, page] of node.ui.pages) {
  334. const state = statestore.getAll(id)
  335. if (state) {
  336. // merge the statestore with our props to account for dynamically set properties:
  337. node.ui.pages.set(id, { ...page, ...state })
  338. }
  339. }
  340. // loop over groups - check statestore if we've had any dynamic properties set
  341. for (const [id, group] of node.ui.groups) {
  342. const state = statestore.getAll(id)
  343. if (state) {
  344. // merge the statestore with our props to account for dynamically set properties:
  345. node.ui.groups.set(id, { ...group, ...state })
  346. }
  347. }
  348. // pass the connected UI the UI config
  349. socket.emit('ui-config', node.id, {
  350. dashboards: Object.fromEntries(node.ui.dashboards),
  351. heads: Object.fromEntries(node.ui.heads),
  352. pages: Object.fromEntries(node.ui.pages),
  353. themes: Object.fromEntries(node.ui.themes),
  354. groups: Object.fromEntries(node.ui.groups),
  355. widgets: Object.fromEntries(node.ui.widgets)
  356. })
  357. }
  358. // remove event handler socket listeners for a given socket connection
  359. function cleanupEventHandlers (socket) {
  360. try {
  361. socket.removeAllListeners('widget-action')
  362. } catch (_error) { /* do nothing */ }
  363. try {
  364. socket.removeAllListeners('widget-change')
  365. } catch (_error) { /* do nothing */ }
  366. try {
  367. socket.removeAllListeners('widget-load')
  368. } catch (_error) { /* do nothing */ }
  369. try {
  370. socket.removeAllListeners('widget-send')
  371. } catch (_error) { /* do nothing */ }
  372. try {
  373. socket.removeAllListeners('disconnect')
  374. } catch (_error) { /* do nothing */ }
  375. // check if any widgets have defined custom socket events
  376. // remove their listeners to make sure we clean up properly
  377. node.ui?.widgets?.forEach((widget) => {
  378. if (widget.hooks?.onSocket) {
  379. for (const [eventName] of Object.entries(widget.hooks.onSocket)) {
  380. try {
  381. socket.removeAllListeners(eventName)
  382. } catch (_error) { /* do nothing */ }
  383. }
  384. }
  385. })
  386. }
  387. function setupEventHandlers (socket, onConnection) {
  388. socket.on('widget-send', onSend.bind(null, socket))
  389. socket.on('widget-action', onAction.bind(null, socket))
  390. socket.on('widget-change', onChange.bind(null, socket))
  391. socket.on('widget-load', onLoad.bind(null, socket))
  392. // check if any widgets have defined custom socket events
  393. // most common with third-party widgets that are not part of core Dashboard 2.0
  394. const registered = [] // track which widget types we've already subscribed for
  395. node.ui?.widgets?.forEach((widget) => {
  396. if (widget.hooks?.onSocket) {
  397. for (const [eventName, handler] of Object.entries(widget.hooks.onSocket)) {
  398. // we only need add the listener for a given event type the once
  399. if (eventName === 'connection') {
  400. if (onConnection) {
  401. // these handlers are setup as part of an onConnection event, so trigegr these now
  402. handler(socket)
  403. }
  404. } else {
  405. widget._onSocketHandlers = widget._onSocketHandlers || {}
  406. widget._onSocketHandlers[eventName] = handler.bind(null, socket)
  407. socket.on(eventName, widget._onSocketHandlers[eventName])
  408. }
  409. }
  410. registered.push(widget.type)
  411. }
  412. })
  413. // handle disconnection
  414. socket.on('disconnect', reason => {
  415. cleanupEventHandlers(socket)
  416. delete uiShared.connections[socket.id]
  417. node.log(`Disconnected ${socket.id} due to ${reason}`)
  418. })
  419. }
  420. /**
  421. * on connection handler for SocketIO
  422. * @param {Socket} socket socket.io socket connecting to the server
  423. */
  424. function onConnection (socket) {
  425. // record mapping from connection to he ui-base node
  426. socket._baseId = node.id
  427. // node.connections[socket.id] = socket // store the connection for later use
  428. uiShared.connections[socket.id] = socket // store the connection for later use
  429. emitConfig(socket)
  430. // clean up then re-register listeners
  431. // cleanupEventHandlers(socket)
  432. // setup connections, and fire any 'on('connection')' events
  433. setupEventHandlers(socket, true)
  434. }
  435. /**
  436. * Handles a widget-action event from the UI
  437. * @param {Socket} conn - socket.io socket connecting to the server
  438. * @param {String} id - widget id sending the action
  439. * @param {*} msg - The node-red msg object to forward
  440. * @returns void
  441. */
  442. async function onAction (conn, id, msg) {
  443. // Hooks API - onAction(conn, id, msg)
  444. RED.plugins.getByType('node-red-dashboard-2').forEach(plugin => {
  445. if (plugin.hooks?.onAction && msg) {
  446. msg = plugin.hooks.onAction(conn, id, msg)
  447. }
  448. })
  449. if (!msg) {
  450. // a plugin has made msg blank - meaning that we don't want to send it on
  451. return
  452. }
  453. msg = addConnectionCredentials(RED, msg, conn, n)
  454. // ensure msg is an object. Assume the incoming data is the payload if not
  455. if (!msg || typeof msg !== 'object') {
  456. msg = { payload: msg }
  457. }
  458. // get widget node and configuration
  459. const { wNode, widgetConfig, widgetEvents } = getWidgetAndConfig(id)
  460. // ensure we can get the requested widget from the runtime & that this widget has an onAction handler
  461. if (!wNode || !widgetEvents.onAction) {
  462. return // widget does not exist (e.g. deleted from NR and deployed BUT the ui page was not refreshed)
  463. }
  464. // Wrap execution in a try/catch to ensure we don't crash Node-RED
  465. try {
  466. msg = await appendTopic(RED, widgetConfig, wNode, msg)
  467. // pre-process the msg before send on the msg (if beforeSend is defined)
  468. if (widgetEvents?.beforeSend && typeof widgetEvents.beforeSend === 'function') {
  469. msg = await widgetEvents.beforeSend(msg)
  470. }
  471. // send the msg onwards
  472. wNode.send(msg)
  473. } catch (error) {
  474. let errorHandler = typeof (widgetEvents.onError) === 'function' ? widgetEvents.onError : null
  475. errorHandler = errorHandler || (typeof wNode.error === 'function' ? wNode.error : node.error)
  476. errorHandler && errorHandler(error)
  477. }
  478. }
  479. /**
  480. * Handles a widget-change event from the UI
  481. * @param {Socket} conn - socket.io socket connecting to the server
  482. * @param {String} id - widget id sending the action
  483. * @param {*} value - The value to send to node-red. Typically this is the payload
  484. * @returns void
  485. */
  486. async function onChange (conn, id, value) {
  487. // console.log('conn:' + conn.id, 'on:widget-change:' + id, value)
  488. // get widget node and configuration
  489. const { wNode, widgetConfig, widgetEvents } = getWidgetAndConfig(id)
  490. if (!wNode) {
  491. return // widget does not exist any more (e.g. deleted from NR and deployed BUT the ui page was not refreshed)
  492. }
  493. let msg = datastore.get(id) || {}
  494. RED.plugins.getByType('node-red-dashboard-2').forEach(plugin => {
  495. if (plugin.hooks?.onChange) {
  496. msg = plugin.hooks.onChange(conn, id, msg)
  497. }
  498. })
  499. if (!msg) {
  500. // a plugin has made msg blank - meaning that we don't want to send it on
  501. return
  502. }
  503. msg = addConnectionCredentials(RED, msg, conn, n)
  504. async function defaultHandler (msg, value) {
  505. if (typeof (value) === 'object' && value !== null && hasProperty(value, 'payload')) {
  506. msg.payload = value.payload
  507. } else {
  508. msg.payload = value
  509. }
  510. msg = await appendTopic(RED, widgetConfig, wNode, msg)
  511. if (widgetEvents?.beforeSend) {
  512. msg = await widgetEvents.beforeSend(msg)
  513. }
  514. datastore.save(n, wNode, msg)
  515. wNode.send(msg) // send the msg onwards
  516. }
  517. // wrap execution in a try/catch to ensure we don't crash Node-RED
  518. try {
  519. // Most of the time, we can just use this default handler,
  520. // but sometimes a node needs to do something specific (e.g. ui-switch)
  521. const handler = typeof (widgetEvents.onChange) === 'function' ? widgetEvents.onChange : defaultHandler
  522. await handler(msg, value)
  523. } catch (error) {
  524. console.log(error)
  525. let errorHandler = typeof (widgetEvents.onError) === 'function' ? widgetEvents.onError : null
  526. errorHandler = errorHandler || (typeof wNode.error === 'function' ? wNode.error : node.error)
  527. errorHandler && errorHandler(error)
  528. }
  529. }
  530. /**
  531. * Handles a widget-send event from the UI
  532. * This takes a msg input, and emits it from the relevant node (normally a template node)
  533. * also stores in the data store, and does not consider any previously stored messages (unlike widget-change)
  534. * @param {Socket} conn - socket.io socket connecting to the server
  535. * @param {String} id - widget id sending the action
  536. * @param {*} msg - The value to send to node-red. Typically this is the payload
  537. * @returns void
  538. */
  539. async function onSend (conn, id, msg) {
  540. // console.log('conn:' + conn.id, 'on:widget-send:' + id, msg)
  541. // get widget node and configuration
  542. const { wNode, widgetEvents } = getWidgetAndConfig(id)
  543. if (!wNode) {
  544. return // widget does not exist any more (e.g. deleted from NR and deployed BUT the ui page was not refreshed)
  545. }
  546. RED.plugins.getByType('node-red-dashboard-2').forEach(plugin => {
  547. if (plugin.hooks?.onSend) {
  548. msg = plugin.hooks.onSend(conn, id, msg)
  549. }
  550. })
  551. if (!msg) {
  552. // a plugin has made msg blank - meaning that we don't want to send it on
  553. return
  554. }
  555. msg = addConnectionCredentials(RED, msg, conn, n)
  556. async function defaultHandler (value) {
  557. if (widgetEvents?.beforeSend) {
  558. msg = await widgetEvents.beforeSend(msg)
  559. }
  560. datastore.save(n, wNode, msg)
  561. wNode.send(msg) // send the msg onwards
  562. }
  563. // wrap execution in a try/catch to ensure we don't crash Node-RED
  564. try {
  565. // Most of the time, we can just use this default handler,
  566. // but sometimes a node needs to do something specific (e.g. ui-switch)
  567. const handler = typeof (widgetEvents.onSend) === 'function' ? widgetEvents.onSend : defaultHandler
  568. await handler(msg)
  569. } catch (error) {
  570. console.log(error)
  571. let errorHandler = typeof (widgetEvents.onError) === 'function' ? widgetEvents.onError : null
  572. errorHandler = errorHandler || (typeof wNode.error === 'function' ? wNode.error : node.error)
  573. errorHandler && errorHandler(error)
  574. }
  575. }
  576. async function onLoad (conn, id, msg) {
  577. // console.log('conn:' + conn.id, 'on:widget-load:' + id, msg)
  578. if (!id) {
  579. console.error('No widget id provided for widget-load event')
  580. return
  581. }
  582. const { wNode, widgetEvents } = getWidgetAndConfig(id)
  583. // any widgets we hard-code into our front end (e.g ui-notification for connection alerts) will start with ui-
  584. // Node-RED built nodes will be a random UUID
  585. if (!wNode && !id.startsWith('ui-')) {
  586. console.log('widget does not exist any more')
  587. return // widget does not exist any more (e.g. deleted from NR and deployed BUT the ui page was not refreshed)
  588. }
  589. async function handler () {
  590. let msg = datastore.get(id)
  591. const state = statestore.getAll(id)
  592. RED.plugins.getByType('node-red-dashboard-2').forEach(plugin => {
  593. if (plugin.hooks?.onLoad) {
  594. msg = plugin.hooks.onLoad(conn, id, msg, state)
  595. }
  596. })
  597. if (!msg && !state) {
  598. // a plugin has made msg blank - meaning that we do anything else
  599. return
  600. }
  601. conn.emit('widget-load:' + id, msg, state)
  602. }
  603. // wrap execution in a try/catch to ensure we don't crash Node-RED
  604. try {
  605. handler()
  606. } catch (error) {
  607. let errorHandler = typeof (widgetEvents.onError) === 'function' ? widgetEvents.onError : null
  608. errorHandler = errorHandler || (typeof wNode.error === 'function' ? wNode.error : node.error)
  609. errorHandler && errorHandler(error)
  610. }
  611. }
  612. /**
  613. * Get the widget node and associated configuration/event hooks
  614. * @param {String} id - ID of the widget
  615. * @returns {Object} - { wNode, widgetConfig, widgetEvents, widget }
  616. */
  617. function getWidgetAndConfig (id) {
  618. // node.ui?.widgets is empty?
  619. // themes, groups, etc. are not empty?
  620. const wNode = RED.nodes.getNode(id)
  621. const widget = node.ui?.widgets?.get(id)
  622. const widgetConfig = widget?.props || {}
  623. const widgetEvents = widget?.hooks || {}
  624. return { wNode, widgetConfig, widgetEvents, widget }
  625. }
  626. // When a UI connects - send the UI Config from Node-RED to the UI
  627. uiShared.ioServer.on('connection', onConnection)
  628. // Make sure we clean up after ourselves
  629. node.on('close', (removed, done) => {
  630. uiShared.ioServer?.off('connection', onConnection)
  631. for (const conn of Object.values(uiShared.connections)) {
  632. cleanupEventHandlers(conn)
  633. }
  634. close(node, function (err) {
  635. if (err) {
  636. node.error(`Error closing socket.io server for ${node.id}`, err)
  637. }
  638. done()
  639. })
  640. })
  641. /**
  642. * External Functions for managing UI Components
  643. */
  644. // store ui config to be sent to UI
  645. node.ui = {
  646. heads: new Map(),
  647. dashboards: new Map(),
  648. pages: new Map(),
  649. themes: new Map(),
  650. groups: new Map(),
  651. widgets: new Map()
  652. }
  653. node.stores = {
  654. data: datastore,
  655. state: statestore
  656. }
  657. /**
  658. * Queue up a config emit to the UI. This is a debounced function
  659. * NOTES:
  660. * * only sockets connected to this node will receive the config
  661. * * each ui-node will have it's own connections and will emit it's own config
  662. * @returns {void}
  663. */
  664. node.requestEmitConfig = function () {
  665. if (node.emitConfigRequested) {
  666. return
  667. }
  668. node.emitConfigRequested = setTimeout(() => {
  669. try {
  670. // emit config to all connected UI for this ui-base
  671. Object.values(uiShared.connections).forEach(socket => {
  672. emitConfig(socket)
  673. })
  674. } finally {
  675. node.emitConfigRequested = null
  676. }
  677. }, 300)
  678. }
  679. /**
  680. * Allow for any child node to emit to all connected UIs
  681. */
  682. node.emit = emit
  683. node.getBaseURL = function () {
  684. // get the endpoint for the ui-base
  685. const path = n.path || ''
  686. // get our HTTP root, defined by NR Settings
  687. const base = RED.settings.httpNodeRoot || '/'
  688. const basePart = base.endsWith('/') ? base : `${base}/`
  689. const dashPart = path.startsWith('/') ? path.slice(1) : path
  690. const fullPath = `${basePart}${dashPart}`
  691. return fullPath
  692. }
  693. node.registerTheme = function (theme) {
  694. const { _wireCount, _inputCallback, _inputCallbacks, _closeCallbacks, wires, type, ...t } = theme
  695. node.ui.themes.set(t.id, t)
  696. }
  697. /**
  698. * Register allows for pages, widgets, groups, etc. to register themselves with the Base UI Node
  699. * @param {*} page
  700. * @param {*} widget
  701. */
  702. node.register = function (page, group, widgetNode, widgetConfig, widgetEvents) {
  703. // console.log('dashboard 2.0, UIBaseNode: node.register(...)', page, group, widgetNode, widgetConfig, widgetEvents)
  704. /**
  705. * Build UI Config
  706. */
  707. // strip widgetConfig of stuff we don't really care about (e.g. Node-RED x/y coordinates)
  708. // and leave us just with the properties set inside the Node-RED Editor, store as "props"
  709. // store our UI state properties under the .state key too
  710. let widget = null
  711. if (widgetNode && widgetConfig) {
  712. // default states
  713. if (statestore.getProperty(widgetConfig.id, 'enabled') === undefined) {
  714. statestore.set(n, widgetConfig, null, 'enabled', true)
  715. }
  716. if (statestore.getProperty(widgetConfig.id, 'visible') === undefined) {
  717. statestore.set(n, widgetConfig, null, 'visible', true)
  718. }
  719. if (statestore.getProperty(widgetConfig.id, 'class') === undefined) {
  720. statestore.set(n, widgetConfig, null, 'class', '')
  721. }
  722. // build widget object
  723. widget = {
  724. id: widgetConfig.id,
  725. type: widgetConfig.type,
  726. props: widgetConfig,
  727. layout: {
  728. width: widgetConfig.width || 3,
  729. height: widgetConfig.height || 1,
  730. order: widgetConfig.order || 0
  731. },
  732. state: statestore.getAll(widgetConfig.id),
  733. hooks: widgetEvents,
  734. src: uiShared.contribs[widgetConfig.type]
  735. }
  736. const parent = RED.nodes.getNode(widgetConfig.z)
  737. if (parent && parent.TYPE === 'subflow') {
  738. const orderEnv = parent.subflowInstance.env?.find(e => e.key === 'DB2_SF_ORDER')
  739. let order = parseInt(orderEnv?.value)
  740. if (isNaN(order)) {
  741. order = 0
  742. }
  743. widget.props.subflow = {
  744. id: widgetConfig.z,
  745. name: parent.subflowInstance?.name || parent.subflowDef.name,
  746. order
  747. }
  748. }
  749. delete widget.props.id
  750. delete widget.props.type
  751. delete widget.props.x
  752. delete widget.props.y
  753. delete widget.props.wires
  754. if (widget.props.width === '0') {
  755. widget.props.width = null
  756. }
  757. if (widget.props.height === '0') {
  758. widget.props.height = null
  759. }
  760. // merge the statestore with our props toa ccount for dynamically set properties:
  761. // loop over props and check if we have any function definitions (e.g. onMounted, onInput)
  762. // and stringify them for transport over SocketIO
  763. for (const [key, value] of Object.entries(widget.props)) {
  764. // supported functions
  765. const supported = ['onMounted', 'onInput']
  766. if (supported.includes(key) && typeof value === 'function') {
  767. widget.props[key] = value.toString()
  768. } else if (key === 'methods') {
  769. for (const [method, fcn] of Object.entries(widget.props.methods)) {
  770. if (typeof fcn === 'function') {
  771. widget.props.methods[method] = fcn.toString()
  772. }
  773. }
  774. }
  775. }
  776. }
  777. // map dashboards by their ID
  778. if (!node.ui.dashboards.has(n.id)) {
  779. node.ui.dashboards.set(n.id, n)
  780. }
  781. // map themes by their ID
  782. if (page && page.type === 'ui-page' && !node.ui.themes.has(page.theme)) {
  783. const theme = RED.nodes.getNode(page.theme)
  784. if (theme) {
  785. node.registerTheme(theme)
  786. } else {
  787. node.warn(`Theme '${page.theme}' specified in page '${page.id}' does not exist`)
  788. }
  789. }
  790. // map pages by their ID
  791. if (page) {
  792. // ensure we have the latest instance of the page's node
  793. const { _users, ...p } = page
  794. node.ui.pages.set(page.id, p)
  795. }
  796. // map groups on a page-by-page basis
  797. if (group) {
  798. const { _user, type, ...g } = group
  799. node.ui.groups.set(group.id, g)
  800. }
  801. // map widgets on a group-by-group basis
  802. if (widgetNode && widgetConfig && !node.ui.widgets.has(widget.id)) {
  803. node.ui.widgets.set(widget.id, widget)
  804. }
  805. /**
  806. * Helper Function for testing
  807. */
  808. if (widgetNode) {
  809. widgetNode.getState = function () {
  810. return datastore.get(widgetNode.id)
  811. }
  812. /**
  813. * Event Handlers
  814. */
  815. // add Node-RED listener to the widget for when it's corresponding node receives a msg in Node-RED
  816. widgetNode?.on('input', async function (msg, send, done) {
  817. // clean msg - #668
  818. delete msg.res
  819. delete msg.req
  820. // ensure we have latest instance of the widget's node
  821. const wNode = RED.nodes.getNode(widgetNode.id)
  822. if (!wNode) {
  823. return // widget does not exist any more (e.g. deleted from NR and deployed BUT the ui page was not refreshed)
  824. }
  825. // Hooks API - onInput(msg)
  826. RED.plugins.getByType('node-red-dashboard-2').forEach(plugin => {
  827. if (plugin.hooks?.onInput) {
  828. msg = plugin.hooks.onInput(msg)
  829. }
  830. })
  831. if (!msg) {
  832. // a plugin has made msg blank - meaning that we do anything else
  833. return
  834. }
  835. try {
  836. // pre-process the msg before running our onInput function
  837. if (widgetEvents?.beforeSend) {
  838. msg = await widgetEvents.beforeSend(msg)
  839. }
  840. // standard dynamic property handlers
  841. if (hasProperty(msg, 'enabled')) {
  842. statestore.set(n, widgetNode, msg, 'enabled', msg.enabled)
  843. }
  844. if (hasProperty(msg, 'visible')) {
  845. statestore.set(n, widgetNode, msg, 'visible', msg.visible)
  846. }
  847. if (hasProperty(msg, 'class') || (hasProperty(msg, 'ui_update') && hasProperty(msg.ui_update, 'class'))) {
  848. const cls = msg.class || msg.ui_update?.class
  849. statestore.set(n, widgetNode, msg, 'class', cls)
  850. }
  851. // run any node-specific handler defined in the Widget's component
  852. if (widgetEvents?.onInput) {
  853. await widgetEvents?.onInput(msg, send)
  854. } else {
  855. // msg could be null if the beforeSend errors and returns null
  856. if (msg) {
  857. // store the latest msg passed to node
  858. datastore.save(n, widgetNode, msg)
  859. if (widgetConfig.topic || widgetConfig.topicType) {
  860. msg = await appendTopic(RED, widgetConfig, wNode, msg)
  861. }
  862. if (hasProperty(widgetConfig, 'passthru')) {
  863. if (widgetConfig.passthru) {
  864. send(msg)
  865. }
  866. } else {
  867. send(msg)
  868. }
  869. }
  870. }
  871. // emit to all connected UIs
  872. emit('msg-input:' + widget.id, msg, wNode)
  873. done()
  874. } catch (err) {
  875. if (err.type === 'warn') {
  876. wNode.warn(err.message)
  877. done()
  878. } else {
  879. done(err)
  880. }
  881. }
  882. })
  883. // when a widget is "closed" remove it from this Base Node's knowledge
  884. widgetNode?.on('close', function (removed, done) {
  885. if (removed) {
  886. // widget has been removed from the Editor
  887. // clear any data from datastore
  888. datastore.clear(widgetNode.id)
  889. }
  890. node.deregister(null, null, widgetNode)
  891. done()
  892. })
  893. }
  894. node.requestEmitConfig() // queue up a config emit to the UI
  895. }
  896. node.deregister = function (page, group, widgetNode) {
  897. let changes = false
  898. // remove widget from our UI config
  899. if (widgetNode) {
  900. const widget = node.ui.widgets.get(widgetNode.id)
  901. if (widget.hooks?.onSocket) {
  902. // We have some custom socketIO hooks to remove
  903. // loop over SocketIO connections
  904. for (const socket of Object.values(uiShared.connections)) {
  905. // loop over events
  906. for (const [eventName] of Object.entries(widget.hooks.onSocket)) {
  907. // remove the listener for this event
  908. if (widget._onSocketHandlers) {
  909. socket.off(eventName, widget._onSocketHandlers[eventName])
  910. }
  911. }
  912. }
  913. }
  914. node.ui.widgets.delete(widgetNode.id)
  915. changes = true
  916. }
  917. // if there are no more widgets on this group, remove the group from our UI config
  918. if (group && [...node.ui.widgets].filter(w => w.props?.group === group.id).length === 0) {
  919. node.ui.groups.delete(group.id)
  920. changes = true
  921. }
  922. // if there are no more groups on this page, remove the page from our UI config
  923. if (page && [...node.ui.groups].filter(g => g.page === page.id).length === 0) {
  924. node.ui.pages.delete(page.id)
  925. changes = true
  926. }
  927. if (changes) {
  928. node.requestEmitConfig()
  929. }
  930. }
  931. // Finally, queue up a config emit to the UI.
  932. // NOTE: this is a cautionary measure only - typically the registration of nodes will queue up a config emit
  933. // but in cases where the dashboard has no widgets registered, we still need to emit a config
  934. node.requestEmitConfig()
  935. }
  936. RED.nodes.registerType('ui-base', UIBaseNode)
  937. }