@flowfuse/node-red-dashboard 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +53 -0
- package/dist/css/app.d047b42b.css +1 -0
- package/dist/css/chunk-vendors.2378ce49.css +24 -0
- package/dist/fonts/materialdesignicons-webfont.3de8526e.woff +0 -0
- package/dist/fonts/materialdesignicons-webfont.477c6ab0.woff2 +0 -0
- package/dist/fonts/materialdesignicons-webfont.48a1ce0c.eot +0 -0
- package/dist/fonts/materialdesignicons-webfont.dfd403cf.ttf +0 -0
- package/dist/index.html +1 -0
- package/dist/js/app.854a8cd5.js +2 -0
- package/dist/js/app.854a8cd5.js.map +1 -0
- package/dist/js/chunk-vendors.174e8921.js +43 -0
- package/dist/js/chunk-vendors.174e8921.js.map +1 -0
- package/nodes/config/locales/en-US/ui_base.json +19 -0
- package/nodes/config/locales/en-US/ui_group.html +4 -0
- package/nodes/config/locales/en-US/ui_group.json +16 -0
- package/nodes/config/ui_base.html +807 -0
- package/nodes/config/ui_base.js +678 -0
- package/nodes/config/ui_group.html +55 -0
- package/nodes/config/ui_group.js +34 -0
- package/nodes/config/ui_page.html +84 -0
- package/nodes/config/ui_page.js +33 -0
- package/nodes/config/ui_theme.html +101 -0
- package/nodes/config/ui_theme.js +15 -0
- package/nodes/store/index.js +34 -0
- package/nodes/utils/index.js +35 -0
- package/nodes/widgets/locales/en-US/ui_button.html +7 -0
- package/nodes/widgets/locales/en-US/ui_button.json +24 -0
- package/nodes/widgets/locales/en-US/ui_chart.html +41 -0
- package/nodes/widgets/locales/en-US/ui_chart.json +17 -0
- package/nodes/widgets/locales/en-US/ui_dropdown.html +24 -0
- package/nodes/widgets/locales/en-US/ui_form.html +16 -0
- package/nodes/widgets/locales/en-US/ui_form.json +36 -0
- package/nodes/widgets/locales/en-US/ui_markdown.html +10 -0
- package/nodes/widgets/locales/en-US/ui_slider.html +9 -0
- package/nodes/widgets/locales/en-US/ui_switch.html +32 -0
- package/nodes/widgets/locales/en-US/ui_template.html +59 -0
- package/nodes/widgets/locales/en-US/ui_template.json +18 -0
- package/nodes/widgets/locales/en-US/ui_text.html +16 -0
- package/nodes/widgets/locales/en-US/ui_text_input.html +19 -0
- package/nodes/widgets/ui_button.html +146 -0
- package/nodes/widgets/ui_button.js +65 -0
- package/nodes/widgets/ui_chart.html +314 -0
- package/nodes/widgets/ui_chart.js +195 -0
- package/nodes/widgets/ui_dropdown.html +199 -0
- package/nodes/widgets/ui_dropdown.js +19 -0
- package/nodes/widgets/ui_form.html +368 -0
- package/nodes/widgets/ui_form.js +18 -0
- package/nodes/widgets/ui_markdown.html +134 -0
- package/nodes/widgets/ui_markdown.js +14 -0
- package/nodes/widgets/ui_notification.html +139 -0
- package/nodes/widgets/ui_notification.js +14 -0
- package/nodes/widgets/ui_radio_group.html +186 -0
- package/nodes/widgets/ui_radio_group.js +20 -0
- package/nodes/widgets/ui_slider.html +162 -0
- package/nodes/widgets/ui_slider.js +31 -0
- package/nodes/widgets/ui_switch.html +194 -0
- package/nodes/widgets/ui_switch.js +98 -0
- package/nodes/widgets/ui_table.html +149 -0
- package/nodes/widgets/ui_table.js +16 -0
- package/nodes/widgets/ui_template.html +283 -0
- package/nodes/widgets/ui_template.js +19 -0
- package/nodes/widgets/ui_text.html +358 -0
- package/nodes/widgets/ui_text.js +98 -0
- package/nodes/widgets/ui_text_input.html +141 -0
- package/nodes/widgets/ui_text_input.js +37 -0
- package/package.json +114 -0
|
@@ -0,0 +1,678 @@
|
|
|
1
|
+
// const Emitter = require('events').EventEmitter
|
|
2
|
+
const path = require('path')
|
|
3
|
+
|
|
4
|
+
const v = require('../../package.json').version
|
|
5
|
+
const datastore = require('../store/index.js')
|
|
6
|
+
const { appendTopic } = require('../utils/index.js')
|
|
7
|
+
|
|
8
|
+
// from: https://stackoverflow.com/a/28592528/3016654
|
|
9
|
+
function join (...paths) {
|
|
10
|
+
return paths.map(function (element) {
|
|
11
|
+
return element.replace(/^\/|\/$/g, '')
|
|
12
|
+
}).join('/')
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
module.exports = function (RED) {
|
|
16
|
+
const express = require('express')
|
|
17
|
+
const { Server } = require('socket.io')
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @typedef {import('socket.io/dist').Socket} Socket
|
|
21
|
+
* @typedef {import('socket.io/dist').Server} Server
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
// store state that can maintain cross re-deployments
|
|
25
|
+
const uiShared = {
|
|
26
|
+
app: null,
|
|
27
|
+
httpServer: null,
|
|
28
|
+
/** @type { Server } */
|
|
29
|
+
ioServer: null,
|
|
30
|
+
/** @type {Object.<string, Socket>} */
|
|
31
|
+
connections: {},
|
|
32
|
+
settings: {}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Initialise the Express Server and SocketIO Server in Singleton Pattern
|
|
37
|
+
* @param {Object} node - Node-RED Node
|
|
38
|
+
* @param {Object} config - Node-RED Node Config
|
|
39
|
+
*/
|
|
40
|
+
function init (node, config) {
|
|
41
|
+
node.uiShared = uiShared // ensure we have a uiShared object on the node (for testing mainly)
|
|
42
|
+
// eventually check if we have routes used, so we can support multiple base UIs
|
|
43
|
+
if (!uiShared.app) {
|
|
44
|
+
uiShared.app = RED.httpNode || RED.httpAdmin
|
|
45
|
+
uiShared.httpServer = RED.server
|
|
46
|
+
|
|
47
|
+
// Use the 'dashboard' settings if present, otherwise fallback
|
|
48
|
+
// to node-red-dashboard 'ui' settings object.
|
|
49
|
+
uiShared.settings = RED.settings.dashboard || RED.settings.ui || {}
|
|
50
|
+
|
|
51
|
+
// Default no-op middleware
|
|
52
|
+
uiShared.httpMiddleware = function (req, res, next) { next() }
|
|
53
|
+
if (uiShared.settings.middleware) {
|
|
54
|
+
if (typeof uiShared.settings.middleware === 'function' || Array.isArray(uiShared.settings.middleware)) {
|
|
55
|
+
uiShared.httpMiddleware = uiShared.settings.middleware
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Configure Web Server to handle UI traffic
|
|
60
|
+
*/
|
|
61
|
+
|
|
62
|
+
uiShared.app.use(config.path, uiShared.httpMiddleware, express.static(path.join(__dirname, '../../dist')))
|
|
63
|
+
|
|
64
|
+
uiShared.app.get(config.path, uiShared.httpMiddleware, (req, res) => {
|
|
65
|
+
res.sendFile(path.join(__dirname, '../../dist/index.html'))
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
uiShared.app.get(config.path + '/*', uiShared.httpMiddleware, (req, res) => {
|
|
69
|
+
res.sendFile(path.join(__dirname, '../../dist/index.html'))
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
node.log(`Node-RED Dashboard 2.0 (v${v}) started at ${config.path}`)
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Create IO Server for comms between Node-RED and UI
|
|
76
|
+
*/
|
|
77
|
+
if (RED.settings.httpNodeRoot !== false) {
|
|
78
|
+
const root = RED.settings.httpNodeRoot || '/'
|
|
79
|
+
const fullPath = join(root, config.path)
|
|
80
|
+
const socketIoPath = join('/', fullPath, 'socket.io')
|
|
81
|
+
/** @type {import('socket.io/dist').ServerOptions} */
|
|
82
|
+
const serverOptions = {
|
|
83
|
+
path: socketIoPath
|
|
84
|
+
}
|
|
85
|
+
// console.log('Creating socket.io server at path', socketIoPath) // disable - noisy in tests
|
|
86
|
+
// store reference to the SocketIO Server
|
|
87
|
+
uiShared.ioServer = new Server(uiShared.httpServer, serverOptions)
|
|
88
|
+
uiShared.ioServer.setMaxListeners(0) // prevent memory leak warning // TODO: be more smart about this!
|
|
89
|
+
|
|
90
|
+
if (typeof uiShared.settings.ioMiddleware === 'function') {
|
|
91
|
+
uiShared.ioServer.use(uiShared.settings.ioMiddleware)
|
|
92
|
+
} else if (Array.isArray(uiShared.settings.ioMiddleware)) {
|
|
93
|
+
uiShared.settings.ioMiddleware.forEach(function (ioMiddleware) {
|
|
94
|
+
uiShared.ioServer.use(ioMiddleware)
|
|
95
|
+
})
|
|
96
|
+
} else {
|
|
97
|
+
uiShared.ioServer.use(function (socket, next) {
|
|
98
|
+
if (socket.client.conn.request.url.indexOf('transport=websocket') !== -1) {
|
|
99
|
+
// Reject direct websocket requests
|
|
100
|
+
socket.client.conn.close()
|
|
101
|
+
return
|
|
102
|
+
}
|
|
103
|
+
if (socket.handshake.xdomain === false) {
|
|
104
|
+
return next()
|
|
105
|
+
} else {
|
|
106
|
+
socket.disconnect(true)
|
|
107
|
+
}
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
const bindOn = RED.server ? 'bound to Node-RED port' : 'on port ' + node.port
|
|
111
|
+
node.log('Created socket.io server ' + bindOn + ' at path ' + socketIoPath)
|
|
112
|
+
} else {
|
|
113
|
+
node.warn('Cannot create UI Base node when httpNodeRoot set to false')
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Close the SocketIO Server
|
|
120
|
+
*/
|
|
121
|
+
function close (node, done) {
|
|
122
|
+
if (!uiShared.ioServer) {
|
|
123
|
+
done()
|
|
124
|
+
return
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// determine if any ui-pages are left, if so, don't close the server
|
|
128
|
+
const baseNodes = []
|
|
129
|
+
const pageNodes = []
|
|
130
|
+
const themes = []
|
|
131
|
+
RED.nodes.eachNode(n => {
|
|
132
|
+
if (n.type === 'ui-page') {
|
|
133
|
+
pageNodes.push(n)
|
|
134
|
+
} else if (n.type === 'ui-base' && n.id !== node.id) {
|
|
135
|
+
baseNodes.push(n)
|
|
136
|
+
} else if (n.type === 'ui-theme') {
|
|
137
|
+
themes.push(n)
|
|
138
|
+
}
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
if (pageNodes.length > 0) {
|
|
142
|
+
// there are still ui-pages, so don't close the server
|
|
143
|
+
done()
|
|
144
|
+
return
|
|
145
|
+
}
|
|
146
|
+
node.ui.pages.clear()// ensure we clear out any pages that may have been left over
|
|
147
|
+
// since there are no pages, we can assume widgets and groups are also gone
|
|
148
|
+
node.ui.widgets.clear()
|
|
149
|
+
node.ui.groups.clear()
|
|
150
|
+
|
|
151
|
+
if (baseNodes.length > 0) {
|
|
152
|
+
// there are still other ui-base nodes, don't close the server
|
|
153
|
+
done()
|
|
154
|
+
return
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// as there are no more instances of ui-page and this is the last ui-base, close the server
|
|
158
|
+
uiShared.ioServer.removeAllListeners()
|
|
159
|
+
uiShared.ioServer.disconnectSockets(true)
|
|
160
|
+
// tidy up
|
|
161
|
+
if (themes.length === 0) {
|
|
162
|
+
node.ui.themes.clear()
|
|
163
|
+
}
|
|
164
|
+
node.ui.dashboards.clear() // ensure we clear out any dashboards that may have been left over
|
|
165
|
+
node.uiShared = null // remove reference to ui object
|
|
166
|
+
done && done()
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Emit an event to all connected UIs
|
|
171
|
+
* @param {String} event
|
|
172
|
+
* @param {Object} data
|
|
173
|
+
*/
|
|
174
|
+
function emit (event, data) {
|
|
175
|
+
Object.values(uiShared.connections).forEach(conn => {
|
|
176
|
+
conn.emit(event, data)
|
|
177
|
+
})
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* UI Base Node Constructor. Called each time Node-RED deploy creates / recreates a u-base node.
|
|
182
|
+
* * _whether this constructor is called depends on if there are any changes to THIS node_
|
|
183
|
+
* * _A full Deploy will always call this function as every node is destroyed and re-created_
|
|
184
|
+
* @param {Object} n - Node-RED node configuration as entered in the nodes editor
|
|
185
|
+
*/
|
|
186
|
+
function UIBaseNode (n) {
|
|
187
|
+
RED.nodes.createNode(this, n)
|
|
188
|
+
const node = this
|
|
189
|
+
|
|
190
|
+
node._created = Date.now()
|
|
191
|
+
|
|
192
|
+
/** @type {Object.<string, Socket>} */
|
|
193
|
+
// node.connections = {} // store socket.io connections for this node
|
|
194
|
+
// // re-map existing connections for this base node
|
|
195
|
+
for (const id in uiShared.connections) {
|
|
196
|
+
const socket = uiShared.connections[id]
|
|
197
|
+
if (uiShared.connections[id]._baseId === node.id) {
|
|
198
|
+
// re establish event handlers
|
|
199
|
+
socket.on('widget-action', onAction.bind(null, socket))
|
|
200
|
+
socket.on('widget-change', onChange.bind(null, socket))
|
|
201
|
+
socket.on('widget-load', onLoad.bind(null, socket))
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
/** @type {NodeJS.Timeout} */
|
|
205
|
+
node.emitConfigRequested = null // used to debounce requests to emitConfig
|
|
206
|
+
|
|
207
|
+
// Configure & Run Express Server
|
|
208
|
+
init(node, n)
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Emit UI Config to all connected UIs
|
|
212
|
+
* @param {Socket} socket - socket.io socket connecting to the server
|
|
213
|
+
*/
|
|
214
|
+
function emitConfig (socket) {
|
|
215
|
+
// pass the connected UI the UI config
|
|
216
|
+
socket.emit('ui-config', node.id, {
|
|
217
|
+
dashboards: Object.fromEntries(node.ui.dashboards),
|
|
218
|
+
heads: Object.fromEntries(node.ui.heads),
|
|
219
|
+
pages: Object.fromEntries(node.ui.pages),
|
|
220
|
+
themes: Object.fromEntries(node.ui.themes),
|
|
221
|
+
groups: Object.fromEntries(node.ui.groups),
|
|
222
|
+
widgets: Object.fromEntries(node.ui.widgets)
|
|
223
|
+
})
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// remove event handler socket listeners for a given socket connection
|
|
227
|
+
function cleanupEventHandlers (socket) {
|
|
228
|
+
try {
|
|
229
|
+
socket.removeAllListeners('widget-action')
|
|
230
|
+
} catch (_error) { /* do nothing */ }
|
|
231
|
+
try {
|
|
232
|
+
socket.removeAllListeners('widget-change')
|
|
233
|
+
} catch (_error) { /* do nothing */ }
|
|
234
|
+
try {
|
|
235
|
+
socket.removeAllListeners('widget-load')
|
|
236
|
+
} catch (_error) { /* do nothing */ }
|
|
237
|
+
try {
|
|
238
|
+
socket.removeAllListeners('disconnect')
|
|
239
|
+
} catch (_error) { /* do nothing */ }
|
|
240
|
+
|
|
241
|
+
// check if any widgets have defined custom socket events
|
|
242
|
+
// remove their listeners to make sure we clean up properly
|
|
243
|
+
node.ui?.widgets?.forEach((widget) => {
|
|
244
|
+
if (widget.hooks?.onSocket) {
|
|
245
|
+
for (const [eventName] of Object.entries(widget.hooks.onSocket)) {
|
|
246
|
+
try {
|
|
247
|
+
socket.removeAllListeners(eventName)
|
|
248
|
+
} catch (_error) { /* do nothing */ }
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
})
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function setupEventHandlers (socket) {
|
|
255
|
+
socket.on('widget-action', onAction.bind(null, socket))
|
|
256
|
+
socket.on('widget-change', onChange.bind(null, socket))
|
|
257
|
+
socket.on('widget-load', onLoad.bind(null, socket))
|
|
258
|
+
|
|
259
|
+
// check if any widgets have defined custom socket events
|
|
260
|
+
// most common with third-party widgets that are not part of core Dashboard 2.0
|
|
261
|
+
node.ui?.widgets?.forEach((widget) => {
|
|
262
|
+
if (widget.hooks?.onSocket) {
|
|
263
|
+
for (const [eventName, handler] of Object.entries(widget.hooks.onSocket)) {
|
|
264
|
+
socket.on(eventName, handler)
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
// handle disconnection
|
|
270
|
+
socket.on('disconnect', reason => {
|
|
271
|
+
cleanupEventHandlers(socket)
|
|
272
|
+
delete uiShared.connections[socket.id]
|
|
273
|
+
node.log(`Disconnected ${socket.id} due to ${reason}`)
|
|
274
|
+
})
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* on connection handler for SocketIO
|
|
279
|
+
* @param {Socket} socket socket.io socket connecting to the server
|
|
280
|
+
*/
|
|
281
|
+
function onConnection (socket) {
|
|
282
|
+
// record mapping from connection to he ui-base node
|
|
283
|
+
socket._baseId = node.id
|
|
284
|
+
|
|
285
|
+
// node.connections[socket.id] = socket // store the connection for later use
|
|
286
|
+
uiShared.connections[socket.id] = socket // store the connection for later use
|
|
287
|
+
emitConfig(socket)
|
|
288
|
+
|
|
289
|
+
// clean up then re-register listeners
|
|
290
|
+
cleanupEventHandlers(socket)
|
|
291
|
+
setupEventHandlers(socket)
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Handles a widget-action event from the UI
|
|
295
|
+
* @param {Socket} conn - socket.io socket connecting to the server
|
|
296
|
+
* @param {String} id - widget id sending the action
|
|
297
|
+
* @param {*} msg - The node-red msg object to forward
|
|
298
|
+
* @returns void
|
|
299
|
+
*/
|
|
300
|
+
async function onAction (conn, id, msg) {
|
|
301
|
+
console.log('conn:' + conn.id, 'on:widget-action:' + id, msg)
|
|
302
|
+
|
|
303
|
+
// ensure msg is an object. Assume the incoming data is the payload if not
|
|
304
|
+
if (!msg || typeof msg !== 'object') {
|
|
305
|
+
msg = { payload: msg }
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// get widget node and configuration
|
|
309
|
+
const { wNode, widgetConfig, widgetEvents } = getWidgetAndConfig(id)
|
|
310
|
+
|
|
311
|
+
// ensure we can get the requested widget from the runtime & that this widget has an onAction handler
|
|
312
|
+
if (!wNode || !widgetEvents.onAction) {
|
|
313
|
+
return // widget does not exist (e.g. deleted from NR and deployed BUT the ui page was not refreshed)
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Wrap execution in a try/catch to ensure we don't crash Node-RED
|
|
317
|
+
try {
|
|
318
|
+
msg = await appendTopic(RED, widgetConfig, wNode, msg)
|
|
319
|
+
|
|
320
|
+
// pre-process the msg before send on the msg (if beforeSend is defined)
|
|
321
|
+
if (widgetEvents?.beforeSend && typeof widgetEvents.beforeSend === 'function') {
|
|
322
|
+
msg = await widgetEvents.beforeSend(msg)
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// send the msg onwards
|
|
326
|
+
wNode.send(msg)
|
|
327
|
+
} catch (error) {
|
|
328
|
+
let errorHandler = typeof (widgetEvents.onError) === 'function' ? widgetEvents.onError : null
|
|
329
|
+
errorHandler = errorHandler || (typeof wNode.error === 'function' ? wNode.error : node.error)
|
|
330
|
+
errorHandler && errorHandler(error)
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Handles a widget-change event from the UI
|
|
336
|
+
* @param {Socket} conn - socket.io socket connecting to the server
|
|
337
|
+
* @param {String} id - widget id sending the action
|
|
338
|
+
* @param {*} value - The value to send to node-red. Typically this is the payload
|
|
339
|
+
* @returns void
|
|
340
|
+
*/
|
|
341
|
+
async function onChange (conn, id, value) {
|
|
342
|
+
console.log('conn:' + conn.id, 'on:widget-change:' + id, value)
|
|
343
|
+
|
|
344
|
+
// get widget node and configuration
|
|
345
|
+
const { wNode, widgetConfig, widgetEvents } = getWidgetAndConfig(id)
|
|
346
|
+
|
|
347
|
+
if (!wNode) {
|
|
348
|
+
return // widget does not exist any more (e.g. deleted from NR and deployed BUT the ui page was not refreshed)
|
|
349
|
+
}
|
|
350
|
+
let msg = datastore.get(id) || {}
|
|
351
|
+
async function defaultHandler (value) {
|
|
352
|
+
if (typeof (value) === 'object' && value !== null && Object.hasOwn(value, 'payload')) {
|
|
353
|
+
msg.payload = value.payload
|
|
354
|
+
} else {
|
|
355
|
+
msg.payload = value
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
msg = await appendTopic(RED, widgetConfig, wNode, msg)
|
|
359
|
+
|
|
360
|
+
if (widgetEvents?.beforeSend) {
|
|
361
|
+
msg = await widgetEvents.beforeSend(msg)
|
|
362
|
+
}
|
|
363
|
+
datastore.save(id, msg)
|
|
364
|
+
wNode.send(msg) // send the msg onwards
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// wrap execution in a try/catch to ensure we don't crash Node-RED
|
|
368
|
+
try {
|
|
369
|
+
// Most of the time, we can just use this default handler,
|
|
370
|
+
// but sometimes a node needs to do something specific (e.g. ui-switch)
|
|
371
|
+
const handler = typeof (widgetEvents.onChange) === 'function' ? widgetEvents.onChange : defaultHandler
|
|
372
|
+
await handler(value)
|
|
373
|
+
} catch (error) {
|
|
374
|
+
console.log(error)
|
|
375
|
+
let errorHandler = typeof (widgetEvents.onError) === 'function' ? widgetEvents.onError : null
|
|
376
|
+
errorHandler = errorHandler || (typeof wNode.error === 'function' ? wNode.error : node.error)
|
|
377
|
+
errorHandler && errorHandler(error)
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
async function onLoad (conn, id, msg) {
|
|
382
|
+
console.log('conn:' + conn.id, 'on:widget-load:' + id, msg)
|
|
383
|
+
|
|
384
|
+
const { wNode, widgetEvents } = getWidgetAndConfig(id)
|
|
385
|
+
if (!wNode) {
|
|
386
|
+
console.log('widget does not exist any more')
|
|
387
|
+
return // widget does not exist any more (e.g. deleted from NR and deployed BUT the ui page was not refreshed)
|
|
388
|
+
}
|
|
389
|
+
async function handler () {
|
|
390
|
+
// replicate receiving an input, so the widget can handle accordingly
|
|
391
|
+
const msg = datastore.get(id)
|
|
392
|
+
if (msg) {
|
|
393
|
+
// only emit something if we have something to send
|
|
394
|
+
// and only to this connection, not all connected clients
|
|
395
|
+
conn.emit('widget-load:' + id, msg)
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
// wrap execution in a try/catch to ensure we don't crash Node-RED
|
|
399
|
+
try {
|
|
400
|
+
handler()
|
|
401
|
+
} catch (error) {
|
|
402
|
+
let errorHandler = typeof (widgetEvents.onError) === 'function' ? widgetEvents.onError : null
|
|
403
|
+
errorHandler = errorHandler || (typeof wNode.error === 'function' ? wNode.error : node.error)
|
|
404
|
+
errorHandler && errorHandler(error)
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Get the widget node and associated configuration/event hooks
|
|
410
|
+
* @param {String} id - ID of the widget
|
|
411
|
+
* @returns {Object} - { wNode, widgetConfig, widgetEvents, widget }
|
|
412
|
+
*/
|
|
413
|
+
function getWidgetAndConfig (id) {
|
|
414
|
+
// node.ui?.widgets is empty?
|
|
415
|
+
// themes, groups, etc. are not empty?
|
|
416
|
+
const wNode = RED.nodes.getNode(id)
|
|
417
|
+
const widget = node.ui?.widgets?.get(id)
|
|
418
|
+
const widgetConfig = widget?.props || {}
|
|
419
|
+
const widgetEvents = widget?.hooks || {}
|
|
420
|
+
return { wNode, widgetConfig, widgetEvents, widget }
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// When a UI connects - send the UI Config from Node-RED to the UI
|
|
424
|
+
uiShared.ioServer.on('connection', onConnection)
|
|
425
|
+
|
|
426
|
+
// Make sure we clean up after ourselves
|
|
427
|
+
node.on('close', (removed, done) => {
|
|
428
|
+
uiShared.ioServer?.off('connection', onConnection)
|
|
429
|
+
for (const conn of Object.values(uiShared.connections)) {
|
|
430
|
+
cleanupEventHandlers(conn)
|
|
431
|
+
}
|
|
432
|
+
close(node, function (err) {
|
|
433
|
+
if (err) {
|
|
434
|
+
node.error(`Error closing socket.io server for ${node.id}`, err)
|
|
435
|
+
}
|
|
436
|
+
done()
|
|
437
|
+
})
|
|
438
|
+
})
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* External Functions for managing UI Components
|
|
442
|
+
*/
|
|
443
|
+
// store ui config to be sent to UI
|
|
444
|
+
node.ui = {
|
|
445
|
+
heads: new Map(),
|
|
446
|
+
dashboards: new Map(),
|
|
447
|
+
pages: new Map(),
|
|
448
|
+
themes: new Map(),
|
|
449
|
+
groups: new Map(),
|
|
450
|
+
widgets: new Map()
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Queue up a config emit to the UI. This is a debounced function
|
|
455
|
+
* NOTES:
|
|
456
|
+
* * only sockets connected to this node will receive the config
|
|
457
|
+
* * each ui-node will have it's own connections and will emit it's own config
|
|
458
|
+
* @returns {void}
|
|
459
|
+
*/
|
|
460
|
+
node.requestEmitConfig = function () {
|
|
461
|
+
if (node.emitConfigRequested) {
|
|
462
|
+
return
|
|
463
|
+
}
|
|
464
|
+
node.emitConfigRequested = setTimeout(() => {
|
|
465
|
+
try {
|
|
466
|
+
// emit config to all connected UI for this ui-base
|
|
467
|
+
Object.values(uiShared.connections).forEach(socket => {
|
|
468
|
+
emitConfig(socket)
|
|
469
|
+
})
|
|
470
|
+
} finally {
|
|
471
|
+
node.emitConfigRequested = null
|
|
472
|
+
}
|
|
473
|
+
}, 300)
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Register allows for pages, widgets, groups, etc. to register themselves with the Base UI Node
|
|
478
|
+
* @param {*} page
|
|
479
|
+
* @param {*} widget
|
|
480
|
+
*/
|
|
481
|
+
node.register = function (page, group, widgetNode, widgetConfig, widgetEvents) {
|
|
482
|
+
/**
|
|
483
|
+
* Build UI Config
|
|
484
|
+
*/
|
|
485
|
+
|
|
486
|
+
// strip widgetConfig of stuff we don't really care about (e.g. Node-RED x/y coordinates)
|
|
487
|
+
// and leave us just with the properties set inside the Node-RED Editor, store as "props"
|
|
488
|
+
// store our UI state properties under the .state key too
|
|
489
|
+
const widget = {
|
|
490
|
+
id: widgetConfig.id,
|
|
491
|
+
type: widgetConfig.type,
|
|
492
|
+
props: widgetConfig,
|
|
493
|
+
layout: {
|
|
494
|
+
width: widgetConfig.width || 3,
|
|
495
|
+
height: widgetConfig.height || 1,
|
|
496
|
+
order: widgetConfig.order || 0
|
|
497
|
+
},
|
|
498
|
+
state: {
|
|
499
|
+
enabled: datastore.get(widgetConfig.id)?.enabled || true,
|
|
500
|
+
visible: datastore.get(widgetConfig.id)?.visible || true
|
|
501
|
+
},
|
|
502
|
+
hooks: widgetEvents
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
delete widget.props.id
|
|
506
|
+
delete widget.props.type
|
|
507
|
+
delete widget.props.x
|
|
508
|
+
delete widget.props.y
|
|
509
|
+
delete widget.props.z
|
|
510
|
+
delete widget.props.wires
|
|
511
|
+
|
|
512
|
+
if (widget.props.width === '0') {
|
|
513
|
+
widget.props.width = null
|
|
514
|
+
}
|
|
515
|
+
if (widget.props.height === '0') {
|
|
516
|
+
widget.props.height = null
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// loop over props and check if we have any function definitions (e.g. onMounted, onInput)
|
|
520
|
+
// and stringify them for transport over SocketIO
|
|
521
|
+
for (const [key, value] of Object.entries(widget.props)) {
|
|
522
|
+
// supported functions
|
|
523
|
+
const supported = ['onMounted', 'onInput']
|
|
524
|
+
if (supported.includes(key) && typeof value === 'function') {
|
|
525
|
+
widget.props[key] = value.toString()
|
|
526
|
+
} else if (key === 'methods') {
|
|
527
|
+
for (const [method, fcn] of Object.entries(widget.props.methods)) {
|
|
528
|
+
if (typeof fcn === 'function') {
|
|
529
|
+
widget.props.methods[method] = fcn.toString()
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// map dashboards by their ID
|
|
536
|
+
if (!node.ui.dashboards.has(n.id)) {
|
|
537
|
+
node.ui.dashboards.set(n.id, n)
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// map themes by their ID
|
|
541
|
+
if (page && !node.ui.themes.has(page.theme)) {
|
|
542
|
+
const theme = RED.nodes.getNode(page.theme)
|
|
543
|
+
if (theme) {
|
|
544
|
+
// eslint-disable-next-line no-unused-vars
|
|
545
|
+
const { _wireCount, _inputCallback, _inputCallbacks, _closeCallbacks, wires, type, ...t } = theme
|
|
546
|
+
node.ui.themes.set(page.theme, t)
|
|
547
|
+
} else {
|
|
548
|
+
node.warn(`Theme '${page.theme}' specified in page '${page.id}' does not exist`)
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// map pages by their ID
|
|
553
|
+
if (page && !node.ui.pages.has(page?.id)) {
|
|
554
|
+
// eslint-disable-next-line no-unused-vars
|
|
555
|
+
const { _user, type, ...p } = page
|
|
556
|
+
node.ui.pages.set(page.id, p)
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// map groups on a page-by-page basis
|
|
560
|
+
if (group && !node.ui.groups.has(group?.id)) {
|
|
561
|
+
// eslint-disable-next-line no-unused-vars
|
|
562
|
+
const { _user, type, ...g } = group
|
|
563
|
+
node.ui.groups.set(group.id, g)
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// map widgets on a group-by-group basis
|
|
567
|
+
if (!node.ui.widgets.has(widget.id)) {
|
|
568
|
+
node.ui.widgets.set(widget.id, widget)
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* Helper Function for testing
|
|
573
|
+
*/
|
|
574
|
+
|
|
575
|
+
widgetNode.getState = function () {
|
|
576
|
+
return datastore.get(widgetNode.id)
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* Event Handlers
|
|
581
|
+
*/
|
|
582
|
+
|
|
583
|
+
// add Node-RED listener to the widget for when it's corresponding node receives a msg in Node-RED
|
|
584
|
+
widgetNode.on('input', async function (msg, send, done) {
|
|
585
|
+
// ensure we have latest instance of the widget's node
|
|
586
|
+
const wNode = RED.nodes.getNode(widgetNode.id)
|
|
587
|
+
if (!wNode) {
|
|
588
|
+
return // widget does not exist any more (e.g. deleted from NR and deployed BUT the ui page was not refreshed)
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
try {
|
|
592
|
+
// pre-process the msg before running our onInput function
|
|
593
|
+
if (widgetEvents?.beforeSend) {
|
|
594
|
+
msg = await widgetEvents.beforeSend(msg)
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// run any node-specific handler defined in the Widget's component
|
|
598
|
+
if (widgetEvents?.onInput) {
|
|
599
|
+
await widgetEvents?.onInput(msg, send)
|
|
600
|
+
} else {
|
|
601
|
+
// msg could be null if the beforeSend errors and returns null
|
|
602
|
+
if (msg) {
|
|
603
|
+
// store the latest msg passed to node
|
|
604
|
+
datastore.save(widgetNode.id, msg)
|
|
605
|
+
|
|
606
|
+
if (widgetConfig.topic || widgetConfig.topicType) {
|
|
607
|
+
msg = await appendTopic(RED, widgetConfig, wNode, msg)
|
|
608
|
+
}
|
|
609
|
+
if (Object.hasOwn(widgetConfig, 'passthru')) {
|
|
610
|
+
if (widgetConfig.passthru) {
|
|
611
|
+
send(msg)
|
|
612
|
+
}
|
|
613
|
+
} else {
|
|
614
|
+
send(msg)
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// emit to all connected UIs
|
|
620
|
+
emit('msg-input:' + widget.id, msg)
|
|
621
|
+
|
|
622
|
+
done()
|
|
623
|
+
} catch (err) {
|
|
624
|
+
if (err.type === 'warn') {
|
|
625
|
+
wNode.warn(err.message)
|
|
626
|
+
done()
|
|
627
|
+
} else {
|
|
628
|
+
done(err)
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
})
|
|
632
|
+
|
|
633
|
+
// when a widget is "closed" remove it from this Base Node's knowledge
|
|
634
|
+
widgetNode.on('close', function (removed, done) {
|
|
635
|
+
if (removed) {
|
|
636
|
+
// widget has been removed from the Editor
|
|
637
|
+
// clear any data from datastore
|
|
638
|
+
datastore.clear(widgetNode.id)
|
|
639
|
+
}
|
|
640
|
+
node.deregister(null, null, widgetNode)
|
|
641
|
+
done()
|
|
642
|
+
})
|
|
643
|
+
|
|
644
|
+
node.requestEmitConfig() // queue up a config emit to the UI
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
node.deregister = function (page, group, widgetNode) {
|
|
648
|
+
let changes = false
|
|
649
|
+
// remove widget from our UI config
|
|
650
|
+
if (widgetNode) {
|
|
651
|
+
node.ui.widgets.delete(widgetNode.id)
|
|
652
|
+
changes = true
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// if there are no more widgets on this group, remove the group from our UI config
|
|
656
|
+
if (group && [...node.ui.widgets].filter(w => w.props?.group === group.id).length === 0) {
|
|
657
|
+
node.ui.groups.delete(group.id)
|
|
658
|
+
changes = true
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// if there are no more groups on this page, remove the page from our UI config
|
|
662
|
+
if (page && [...node.ui.groups].filter(g => g.page === page.id).length === 0) {
|
|
663
|
+
node.ui.pages.delete(page.id)
|
|
664
|
+
changes = true
|
|
665
|
+
}
|
|
666
|
+
if (changes) {
|
|
667
|
+
node.requestEmitConfig()
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// Finally, queue up a config emit to the UI.
|
|
672
|
+
// NOTE: this is a cautionary measure only - typically the registration of nodes will queue up a config emit
|
|
673
|
+
// but in cases where the dashboard has no widgets registered, we still need to emit a config
|
|
674
|
+
node.requestEmitConfig()
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
RED.nodes.registerType('ui-base', UIBaseNode)
|
|
678
|
+
}
|