node-red-contrib-uibuilder
Version:
Easily create web UI's for Node-RED using any (or no) front-end library. VueJS and bootstrap-vue included but change as desired.
1,002 lines (825 loc) • 69.5 kB
JavaScript
/* global Vue */
/*
Copyright (c) 2017-2021 Julian Knight (Totally Information)
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
/**
* This is the Front-End JavaScript for uibuilder
* It provides a number of global objects that can be used in your own javascript.
* @see the docs folder `./docs/uibuilderfe-js.md` for details of how to use this fully.
*
* Please use the default index.js file for your own code and leave this as-is.
*/
'use strict'
// @since 2017-10-17 CL: tell webpack that we need socket.io client if running from webpack build
if (typeof require !== 'undefined' && typeof io === 'undefined') {
var io = require('socket.io-client')
}
/** Create a single global using "new" with an anonymous function
* ensures that everything is isolated and only what is returned is accessible
* Isolate everything
**/
(function () {
//#region --- Type Defs --- //
/**
* @typedef {Object} _auth The standard auth object used by uibuilder security. See docs for details.
* Note that any other data may be passed from your front-end code in the _auth.info object.
* _auth.info.error, _auth.info.validJwt, _auth.info.message, _auth.info.warning
* @property {String} id Required. A unique user identifier.
* @property {String} [password] Required for login only.
* @property {String} [jwt] Required if logged in. Needed for ongoing session validation and management.
* @property {Number} [sessionExpiry] Required if logged in. Milliseconds since 1970. Needed for ongoing session validation and management.
* @property {boolean} [userValidated] Required after user validation. Whether the input ID (and optional additional data from the _auth object) validated correctly or not.
* @property {Object=} [info] Optional metadata about the user.
*/
/**
* A string containing HTML markup
* @typedef {string} html
*/
//#endregion --- Type Defs --- //
// Keep a copy of the starting context
var root = this
// Keep a copy of anything with a clashing name in the starting context
var previous_uibuilder = root.uibuilder
// Create a function with specific "this" context - this is the main code
// @since 2017-10-14 Replaced "new (function(){})" with "(function(){}).call(root)"
var uibuilder = (function () {
// Remember that things have to be defined *before* they are referenced
// Define polyfill for endsWith for IE
if (!String.prototype.endsWith) {
String.prototype.endsWith = function(suffix) {
return this.indexOf(suffix, this.length - suffix.length) !== -1
}
}
/** @type {Object} */
var self = this
//#region ======== Start of setup ======== //
self.version = '3.3.0'
self.debug = false // do not change directly - use .debug() method
self.moduleName = 'uibuilder' // Must match moduleName in uibuilder.js on the server
// @ts-expect-error ts(2345) Tests loaded ver of lib to see if minified
self.isUnminified = /param/.test(function(param) {}) // eslint-disable-line no-unused-vars
/** Empty User info template
* @type {_auth} */
self.dummyAuth = {
id: null,
jwt: undefined,
sessionExpiry: undefined,
userValidated: false,
info: {
error: undefined,
message: undefined,
validJwt: undefined,
},
}
/** Retained User info
* @type {_auth|undefined} */
self._auth = undefined
/** Flag to know whether `uibuilder.start()` has been run */
self.started = false
/** Debugging function
* @param {string} type One of log|error|warn|info|dir, etc
* @param {...*} msg Msg(s) to send to console
* WARNING: ...args is ES6, it doesn't work on IE11
* @since 2019-02-01 Apply any number of args
*/
//self.uiDebug = function (type, ...args) {
self.uiDebug = function () {
if (!self.debug) return
var type = arguments[0]
/** @since v2.0.0-dev3 2019-05-27 changed from ...apply(undefined,...) to ...apply(console,...) Fixes Issue #49 */
//console[type](...args)
console[type].apply(console, [].slice.call(arguments, 1))
} // --- End of debug function --- //
/** Returns the self object if debugging otherwise just the current version
* @return {object|string} Returns self or version
*/
self.me = function() {
return self.debug === true ? self : 'uibuilderfe.js Version: ' + self.version
}
self.uiDebug('log', '\nuibuilderfe: Debug? ', self.debug, '\n\t\tVersion? ', self.version, '\n\t\tRunning Packed version? ', !self.isUnminified, '\n ')
/** Try to get the Socket.IO namespace from the current URL - won't work if page is in a sub-folder
* @since 2017-10-21 Improve method to cope with more complex paths - thanks to Steve Rickus @shrickus
* @since 2017-11-10 v1.0.1 Check cookie first then url. cookie works even if the path is more complex (e.g. sub-folder)
* @since 2020-01-25 Removed httpRoot from namespace to prevent proxy induced errors
* @return {string} Socket.IO namespace
*/
self.setIOnamespace = function () {
var ioNamespace = ''
/** Try getting the namespace cookie.
* @since 2020-01-25 Changed to allow for duplicate cookies */
try {
ioNamespace = document.cookie.match(/uibuilder-namespace\s*=.*?\s*;/g)[0].replace('uibuilder-namespace=','').replace(';','')
// eslint-disable-next-line no-empty
} catch(e) {}
// if it wasn't available, try using the current url path
if (ioNamespace === '' ) {
// split url path & eliminate any blank elements, and trailing or double slashes
var u = window.location.pathname.split('/').filter(function(t) { return t.trim() !== '' })
/** @since v2.0.5 Extra check for 0 length, Issue #73. @since 2017-11-06 If the last element of the path is an .html file name, remove it */
if (u.length > 0) if (u[u.length - 1].endsWith('.html')) u.pop()
// Get the last part of the url path, this MUST match the namespace in uibuilder
ioNamespace = u.pop()
self.uiDebug('log', 'uibuilderfe: IO Namespace - Found via url path: ' + ioNamespace)
} else {
self.uiDebug('log', 'uibuilderfe: IO Namespace - Found via cookie: ' + ioNamespace)
}
// Namespace HAS to start with a /
ioNamespace = '/' + ioNamespace
self.uiDebug('log', 'uibuilderfe: IO Namespace: ' + ioNamespace)
return ioNamespace
} // --- End of set IO namespace --- //
//#region --- variables ---
/** Writable (via custom method. read via .get method) */
/** Automatically send a "ready for content" control message on window.load
* Set to false if you want to send this yourself (e.g. when Riot/Moon/etc mounted event triggered)
* see .autoSendReady method
*/
self.autoSendReady= true
/** Externally Writable (via .set method, read via .get method) */
self.allowScript = true // Allow incoming msg to contain msg.script with JavaScript that will be automatically executed
self.allowStyle = true // Allow incoming msg to contain msg.style with CSS that will be automatically executed
self.removeScript = true // Delete msg.code after inserting to DOM if it exists on incoming msg
self.removeStyle = true // Delete msg.style after inserting to DOM if it exists on incoming msg
/** Externally read-only (via .get method) */
self.msg = {}
self.ctrlMsg = {} // copy of last control msg object received from sever
self.sentMsg = {} // copy of last msg object sent via uibuilder.send()
self.sentCtrlMsg = {} // copy of last control msg object sent via uibuilder.send() @since v2.0.0-dev3
self.msgsSent = 0 // track number of messages sent to server since page load
self.msgsReceived = 0 // track number of messages received from server since page load
self.msgsSentCtrl = 0 // track number of control messages sent to server since page load
self.msgsCtrl = 0 // track number of control messages received from server since page load
self.ioConnected = false
self.serverTimeOffset = null // placeholder to track time offset from server, see fn self.socket.on(self.ioChannels.server ...)
self.isAuthorised = false // Set to true if receive 'authorised' msg from server
self.authTokenExpiry = null // Set on successful logon. Timestamp.
self.authData = {} // Additional data returned from logon/logoff requests
// ---- These are unlikely to be needed externally: ----
self.ioChannels = { control: 'uiBuilderControl', client: 'uiBuilderClient', server: 'uiBuilder' }
self.retryMs = 2000 // starting retry ms period for manual socket reconnections workaround
self.retryFactor = 1.5 // starting delay factor for subsequent reconnect attempts
self.timerid = null
self.ioNamespace = self.setIOnamespace() // Get the namespace from the current URL
self.ioTransport = ['polling', 'websocket']
self.loaded = false // Are all browser resources loaded?
// ---- These cannot be access externally via get/set: ----
self.authToken = '' // populated when receive 'authorised' msg from server, must be returned with each msg sent
//#region - Try to make sure client uses Socket.IO version from the uibuilder module (using path) @since v2.0.0 2019-02-24 allows for httpNodeRoot
// split current url path, eliminate any blank elements and trailing or double slashes
var fullPath = window.location.pathname.split('/').filter(function(t) { return t.trim() !== '' })
/** handle url includes file name - @since v2.0.5 Extra check for 0 length, Issue #73. */
if (fullPath.length > 0) if (fullPath[fullPath.length - 1].endsWith('.html')) fullPath.pop()
self.url = fullPath.pop() // not actually used and only gives the last path section of the url anyway
self.httpNodeRoot = '/' + fullPath.join('/')
self.ioPath = urlJoin(self.httpNodeRoot, self.moduleName, 'vendor', 'socket.io')
self.uiDebug('debug', 'uibuilderfe: ioPath: ' + self.ioPath + ', httpNodeRoot: ' + self.httpNodeRoot + ', uibuilder url (not used): ' + self.url)
//#endregion
//#endregion --- variables ---
//#region --- internal functions --- //
/** Function to set uibuilder properties to a new value - works on any property - see uiReturn.set also for external use
* Also triggers any event listeners.
* Example: self.set('msg', {topic:'uibuilder', payload:42});
* @param {string} prop
* @param {*} val
*/
self.set = function (prop, val) {
self[prop] = val
//self.uiDebug('debug', `uibuilderfe: prop set - prop: ${prop}, val: ${(typeof val === 'object') ? JSON.stringify(val) : val}` )
self.uiDebug('debug', 'uibuilderfe: prop set - prop: ' + prop + ', val: ' + ((typeof val === 'object') ? JSON.stringify(val) : val) )
// Trigger this prop's event callbacks (listeners)
self.emit(prop, val)
//self.uiDebug('debug', `uibuilderfe:uibuilder:set Property: ${prop}, Value: ${val}`)
}
/** Function used to check whether Socket.IO is connected to the server, reconnect if not (recursive)
* @param {number} delay Initial delay before checking (ms)
* @param {number} factor Multiplication factor for subsequent checks (delay*factor)
*/
self.checkConnect = function (delay, factor) {
var depth = depth++ || 1
//self.uiDebug('debug', `uibuilderfe:checkConnect. Reconnect - Depth: ${depth}, Delay: ${delay}, Factor: ${factor}`)
self.uiDebug('debug', 'uibuilderfe:checkConnect. Reconnect - Depth: ' + depth + ', Delay: ' + delay + ', Factor: ' + factor, self.ioNamespace, self.ioPath)
// we only want one running at a time
if (self.timerid) window.clearTimeout(self.timerid)
// Create the new timer
self.timerid = window.setTimeout(function () {
//self.uiDebug('debug', `uibuilderfe:checkConnect timeout. SIO reconnect attempt, timeout: ${delay}, depth: ${depth}`)
self.uiDebug('debug', 'uibuilderfe:checkConnect timeout. SIO reconnect attempt, timeout: ' + delay + ', depth: ' + depth, self.ioNamespace, self.ioPath)
console.info('[uibuilderfe:checkConnect:setTimeout] Socket.IO reconnection attempt. Current delay: ' + delay)
// this is necessary sometimes when the socket fails to connect on startup
self.socket.close()
// Try to reconnect
self.socket.connect()
// don't need to check whether we have connected as the timer will have been cleared if we have
self.timerid = null
// Create new timer for next time round with extended delay
self.checkConnect(delay * factor, factor)
}, delay)
} // --- End of checkConnect Fn--- //
/** Check supplied msg from server for a timestamp - if received, work out & store difference to browser time
* @param {Object} receivedMsg A message object recieved from Node-RED
* @returns {void} Updates self.serverTimeOffset if different to previous value
*/
self.checkTimestamp = function (receivedMsg) {
if ( receivedMsg.hasOwnProperty('serverTimestamp') ) {
var serverTimestamp = new Date(receivedMsg.serverTimestamp)
// @ts-ignore
var offset = Math.round( ( (new Date()) - serverTimestamp ) / 3600000 ) // in ms / 3.6m to get hours
if ( offset !== self.serverTimeOffset ) {
self.set('serverTimeOffset', offset )
self.uiDebug('log', 'uibuilderfe:' + self.ioChannels.server + ' (server): Offset changed to: ' + offset )
}
}
}
/** Setup Socket.io
* @since v2.0.0-beta2 Moved to a function and called by the user (uibuilder.start()) so that namespace & path can be passed manually if needed
* @returns {void} Attaches socket.io manager to self.socket and updates self.ioNamespace & self.ioPath as needed
*/
self.ioSetup = function () {
// Create the socket - make sure client uses Socket.IO version from the uibuilder module (using path)
self.uiDebug('debug', 'uibuilderfe:ioSetup: About to create IO. Namespace: ' + self.ioNamespace + ', Path: ' + self.ioPath + ', Transport: [' + self.ioTransport.join(', ') + ']')
self.socketOptions = {
path: self.ioPath,
transports: self.ioTransport,
transportOptions: {
// Can only set headers when polling
polling: {
extraHeaders: {
'x-clientid': 'uibuilderfe',
//Authorization: 'test', //TODO: Replace with self.jwt variable? // Authorization: `Bearer ${your_jwt}`
}
},
},
}
self.socket = io(self.ioNamespace, self.socketOptions)
/** When the socket is connected - set ioConnected flag and reset connect timer */
self.socket.on('connect', function () {
self.uiDebug('info', 'uibuilderfe:ioSetup: SOCKET CONNECTED - Namespace: ' + self.ioNamespace, ' Server Channel: ', self.ioChannels.server, ' Control Channel: ', self.ioChannels.control)
self.set('ioConnected', true)
// Reset any reconnect timers
if (self.timerid) {
window.clearTimeout(self.timerid)
self.timerid = null
}
}) // --- End of socket connection processing ---
// RECEIVE When Node-RED uibuilder node sends a msg over Socket.IO to us ...
self.socket.on(self.ioChannels.server, function (receivedMsg) {
self.uiDebug('debug', 'uibuilderfe:ioSetup:' + self.ioChannels.server + ' (server): msg received - Namespace: ' + self.ioNamespace, receivedMsg)
// Make sure that msg is an object & not null
receivedMsg = makeMeAnObject(receivedMsg, 'payload')
// @since 2018-10-07 v1.0.9: Work out local time offset from server
self.checkTimestamp(receivedMsg)
// Update _auth if needed
if ( receivedMsg._auth ) self.updateAuth(receivedMsg._auth)
// If the msg contains a code property (js), insert to DOM, remove from msg if required
if ( self.allowScript && receivedMsg.hasOwnProperty('script') ) {
self.newScript(receivedMsg.script)
if ( self.removeScript ) delete receivedMsg.script
}
// If the msg contains a style property (css), insert to DOM, remove from msg if required
if ( self.allowStyle && receivedMsg.hasOwnProperty('style') ) {
self.newStyle(receivedMsg.style)
if ( self.removeStyle ) delete receivedMsg.style
}
// Save the msg for further processing
self.set('msg', receivedMsg)
// Track how many messages have been received
self.set('msgsReceived', self.msgsReceived + 1)
/** Test auto-response - not really required but useful when getting started **/
/* if (self.debug) {
self.send({payload: 'From: uibuilderfe - we got a message from you, thanks', origMsg: receivedMsg})
} */
}) // -- End of websocket receive DATA msg from Node-RED -- //
// RECEIVE a CONTROL msg from Node-RED - see also sendCtrl()
self.socket.on(self.ioChannels.control, function (receivedCtrlMsg) {
self.uiDebug('debug', 'uibuilder:ioSetup:' + self.ioChannels.control + ' (control): msg received - Namespace: ' + self.ioNamespace, receivedCtrlMsg)
// Make sure that msg is an object & not null
if (receivedCtrlMsg === null) {
receivedCtrlMsg = {}
} else if (typeof receivedCtrlMsg !== 'object') {
var msg = {}
msg['uibuilderCtrl:'+self.ioChannels.control] = receivedCtrlMsg
receivedCtrlMsg = msg
}
// Allow incoming control msg to change debug state (usually on the connection msg)
if ( receivedCtrlMsg.hasOwnProperty('debug') ) self.debug = receivedCtrlMsg.debug
// @since 2018-10-07 v1.0.9: Work out local time offset from server
self.checkTimestamp(receivedCtrlMsg)
self.set('ctrlMsg', receivedCtrlMsg)
self.set('msgsCtrl', self.msgsCtrl + 1)
/** Process control msg types */
switch(receivedCtrlMsg.uibuilderCtrl) {
// Initial startup msg from Node-RED server
case 'ready for content':
if ( receivedCtrlMsg._auth ) self.updateAuth(receivedCtrlMsg._auth)
self.uiDebug('debug', 'uibuilderfe:ioSetup:' + self.ioChannels.control + ' Received "ready for content" from server')
break
// Node-RED is shutting down
case 'shutdown':
self.uiDebug('debug', 'uibuilderfe:ioSetup:' + self.ioChannels.control + ' Received "shutdown" from server')
self.set('serverShutdown', undefined)
break
// We are connected to the server
case 'client connect':
if ( receivedCtrlMsg._auth ) self.updateAuth(receivedCtrlMsg._auth)
if ( self.autoSendReady === true ) {
self.uiDebug('debug', 'uibuilderfe:ioSetup:' + self.ioChannels.control + ' Received "client connect" from server, auto-sending REPLAY control msg')
// @since 0.4.8c Add cacheControl property for use with node-red-contrib-infocache
self.send({
'uibuilderCtrl':'ready for content',
'cacheControl':'REPLAY',
},self.ioChannels.control)
}
break
// Login was accepted by the Node-RED server - note that payload may contain more info
case 'authorised':
self.uiDebug('debug', 'uibuilderfe:ioSetup:' + self.ioChannels.control + ' Received "authorised" from server')
if ( receivedCtrlMsg._auth ) {
self.updateAuth(receivedCtrlMsg._auth)
} else {
// This should never happen
self.uiDebug('debug', 'uibuilderfe:ioSetup:' + self.ioChannels.control + ' Received "authorised" from server but without a _auth property - logon failed')
self.markLoggedOut('Logon succeeded but no _auth received, logged out')
}
break
// Login was rejected by the Node-RED server - note that payload may contain more info
case 'authorisation failure':
self.uiDebug('debug', 'uibuilderfe:ioSetup:' + self.ioChannels.control + ' Received "authorisation failure" from server')
self.markLoggedOut('Logon authorisation failure', receivedCtrlMsg._auth.authData)
break
// Logoff confirmation from server - note that payload may contain more info
case 'logged off':
self.uiDebug('debug', 'uibuilderfe:ioSetup:' + self.ioChannels.control + ' Received "logged off" from server')
self.markLoggedOut('Logged off by logout() request', receivedCtrlMsg._auth)
break
default:
self.uiDebug('debug', 'uibuilderfe:ioSetup:' + self.ioChannels.control + ' Received "' + receivedCtrlMsg.uibuilderCtrl + '" from server')
if ( receivedCtrlMsg._auth ) self.updateAuth(receivedCtrlMsg._auth)
// Anything else
} // ---- End of process control msg types ---- //
/* Test auto-response
if (self.debug) {
self.send({payload: 'We got a control message from you, thanks'})
}
// */
}) // -- End of websocket receive CONTROL msg from Node-RED -- //
// When the socket is disconnected ..............
self.socket.on('disconnect', function (reason) {
// reason === 'io server disconnect' - redeploy of Node instance
// reason === 'transport close' - Node-RED terminating
// reason === 'ping timeout' - didn't receive a pong response?
self.uiDebug('info', 'uibuilderfe:ioSetup: SOCKET DISCONNECTED - Namespace: ' + self.ioNamespace + ', Reason: ' + reason)
self.set('ioConnected', false)
console.warn('[uibuilderfe:socket-disconnect] Reason: ' + reason)
/** A workaround for SIO's failure to reconnect after a disconnection */
self.checkConnect(self.retryMs, self.retryFactor)
}) // --- End of socket disconnect processing ---
// Socket.io connection error - probably the wrong ioPath
self.socket.on('connect_error', function(err) {
self.uiDebug('error', 'uibuilderfe:ioSetup: SOCKET CONNECT ERROR - Namespace: ' + self.ioNamespace + ' ioPath: ' + self.ioPath + ', Reason: ' + err.message)
//console.dir(err)
}) // --- End of socket connect error processing ---
// Socket.io error - from the server (socket.use middleware triggered an error response)
self.socket.on('error', function(err) {
self.uiDebug('warn', 'uibuilderfe:ioSetup: SOCKET ERROR from server - MESSAGE: ', err)
self.set('socketError', err)
//console.dir(err)
}) // --- End of socket error processing ---
// Ensure we are connected, retry if not
self.checkConnect(self.retryMs, self.retryFactor)
// TODO: Just for testing - remove or do something useful
// self.socket.io.on('packet', function(data){
// // we get one of these for each REAL msg (not ping/pong)
// console.log('PACKET', data)
// })
// self.socket.on('pong', function(latency) {
// console.log('SOCKET PONG - Latency: ', latency)
// //console.dir(self.socket)
// }) // --- End of socket pong processing ---
/* We really don't need these, just for interest
self.socket.io.on('packet', function(data){
// We get one of these for actual messages, not ping/pong
console.log('PACKET', data)
})
self.socket.on('connect_timeout', function(timeout) {
self.uiDebug('log', 'SOCKET CONNECT TIMEOUT - Namespace: ' + ioNamespace + ', Timeout: ' + timeout)
}) // --- End of socket connect timeout processing ---
self.socket.on('reconnect', function(attemptNum) {
self.uiDebug('log', 'SOCKET RECONNECTED - Namespace: ' + ioNamespace + ', Attempt #: ' + attemptNum)
}) // --- End of socket reconnect processing ---
self.socket.on('reconnect_attempt', function(attemptNum) {
self.uiDebug('log', 'SOCKET RECONNECT ATTEMPT - Namespace: ' + ioNamespace + ', Attempt #: ' + attemptNum)
}) // --- End of socket reconnect_attempt processing ---
self.socket.on('reconnecting', function(attemptNum) {
self.uiDebug('log', 'SOCKET RECONNECTING - Namespace: ' + ioNamespace + ', Attempt #: ' + attemptNum)
}) // --- End of socket reconnecting processing ---
self.socket.on('reconnect_error', function(err) {
self.uiDebug('log', 'SOCKET RECONNECT ERROR - Namespace: ' + ioNamespace + ', Reason: ' + err.message)
//console.dir(err)
}) // --- End of socket reconnect_error processing ---
self.socket.on('reconnect_failed', function() {
self.uiDebug('log', 'SOCKET RECONNECT FAILED - Namespace: ' + ioNamespace)
}) // --- End of socket reconnect_failed processing ---
self.socket.on('ping', function() {
self.uiDebug('log', 'SOCKET PING')
}) // --- End of socket ping processing ---
self.socket.on('pong', function(latency) {
self.uiDebug('log', 'SOCKET PONG - Latency: ', latency)
}) // --- End of socket pong processing ---
*/
} // ---- End of ioSetup ---- //
/** Mark client as logged off & delete local auth data
* @param {String=} localReason Optional, give a reason for logoff, will be placed in self.authData
* @param {_auth=} _auth Data from server, uses self._auth if not provided
*/
self.markLoggedOut = function(localReason, _auth) {
// If _auth undefined then ignore
if (self._auth === undefined && _auth === undefined) return
// If _auth not provided, use self._auth
//if ( _auth === undefined ) _auth = self._auth
// Reset auth info
_auth = self.dummyAuth
// Record reason if given
if ( localReason !== undefined ) self._auth.info.message = localReason
// Trigger change in isAuthorised
self.set('isAuthorised', false) // also triggers event
// Trigger change in _auth
self.set('_auth', _auth)
//delete self.socketOptions.transportOptions.polling.extraHeaders.Authorization
} // ---- End of markLoggedOut ---- //
/** Send a logon request control message
* Node-RED will respond with another control msg indicating success or failure,
* the `isAuthorised` variable is set accordingly to true or false.
* @param {_auth=} _auth Logon specific data to be passed to Node-RED, uses self._auth if not provided.
* @param {string=} [api] Optional. If set to a valid API URL, send login data to it and process response. Otherwise send login request as a control msg
*/
self.logon = function(_auth, api) {
// If _auth undefined then ignore
if (self._auth === undefined && _auth === undefined) return
// If _auth not provided, use self._auth
if ( _auth === undefined ) _auth = self._auth
if ( _auth.id === undefined || _auth.id === null || _auth.id.length === 0 ) {
console.error('[uibuilder:logon] No logon id supplied, ignoring request.')
return
}
if ( ! _auth.info ) _auth.info = self.dummyAuth.info
self.set('_auth', _auth)
if (api) {
//TODO (see login method on vue2 test)
} else {
self.send({
'uibuilderCtrl':'logon',
'from': 'client',
'_auth': self._auth,
},self.ioChannels.control)
}
self.uiDebug('log', '[uibuilder:logon] ', self._auth)
} // ---- End of logon ---- //
/** Send a logoff request control message
* Note that the local auth data is NOT removed here.
* That happens when the client receives the control msg "logged off" from the server.
* @param {_auth=} _auth Required. Logon specific data to be passed to Node-RED
* @param {string} [api] Optional. If set to a valid API URL, send login data to it and process response. Otherwise send login request as a control msg
*/
self.logoff = function(_auth, api) {
// If _auth undefined then ignore
if (self._auth === undefined && _auth === undefined) return
// If _auth not provided, use self._auth
if ( _auth === undefined ) _auth = self._auth
if ( _auth.id === undefined || _auth.id === null || _auth.id.length === 0 ) {
console.error('[uibuilder:logoff] No logoff id supplied, ignoring request.')
return
}
if ( ! _auth.info ) _auth.info = self.dummyAuth.info
self._auth = _auth
if (api) {
//TODO (see login method on vue2 test)
} else {
self.send({
'uibuilderCtrl':'logoff',
'from': 'client',
'_auth': self._auth,
},self.ioChannels.control)
}
} // ---- End of logoff ---- //
/** Update client authorisation info from server info
* Note that this may happen after a successful logon request or at any time
* a msg is received (on any channel) that contains a msg._auth object
* @param {_auth=} _auth Authorisation information. Defaults to self._auth if not supplied
*/
self.updateAuth = function(_auth) {
// If _auth undefined then ignore
if (self._auth === undefined && _auth === undefined) return
// If _auth not provided, use self._auth
if ( _auth === undefined ) _auth = self._auth
// Ignore if empty object
//if ( Object.keys(_auth).length === 0 ) return
if ( _auth.id === undefined || _auth.id === null || _auth.id.length === 0 ) {
console.error('[uibuilder:updateAuth] No auth id supplied by server, ignoring server response.')
if (_auth.info && _auth.info.error) console.warn('[uibuilder:updateAuth] Error from Server: ', _auth.info.error)
return
}
if ( ! _auth.info ) _auth.info = self.dummyAuth.info
// Make sure user is valid & jwt is valid and current
if ( _auth.userValidated && _auth.jwt && _auth.info.validJwt === true && (new Date(_auth.sessionExpiry) > (new Date())) ) {
self.set('isAuthorised', true) // also triggers event
self.set('_auth', _auth)
//self.socketOptions.transportOptions.polling.extraHeaders.Authorization = 'Bearer ' + self.authToken
} else {
self.markLoggedOut('Logon succeeded but no token received, logged out')
}
} // ---- End of updateAuth ---- //
/** Returns a standard msg._auth Object either with valid authToken or none
* If token has expired, run the logout to invalidate the retained data.
* @returns {_auth|undefined}
*/
self.sendAuth = function() {
//if ( Object.prototype.toString.call(_auth) !== '[object Object]' ) _auth = self.dummyAuth
// If anything goes wrong, don't send _auth to server
try {
if ( self._auth === undefined ) return undefined
// TODO: Remove extra metadata from _auth beofre sending (info, userValidated, sessionExpiry)
if ( self.isAuthorised ) {
if ( new Date(self._auth.sessionExpiry) > (new Date()) ) {
return self._auth
} else { // Token has expired so mark as logged off
self.markLoggedOut('Automatically logged off. Token expired')
return self._auth
}
} else {
return self._auth
}
} catch(e) {
return undefined
}
} // ---- End of addAuth ---- //
/** Send a standard msg back to Node-RED via Socket.IO
* NR will generally expect the msg to contain a payload topic
* @param {Object} msgToSend The msg object to send.
* @param {string} [channel=uiBuilderClient] The Socket.IO channel to use, must be in self.ioChannels or it will be ignored
*/
self.send = function (msgToSend, channel) {
if ( channel === null || channel === undefined ) channel = self.ioChannels.client
self.uiDebug('log', 'uibuilderfe: sending msg - Namespace: ' + self.ioNamespace + ', Channel: ' + channel, msgToSend)
// Make sure msgToSend is an object
if (channel === self.ioChannels.client) {
msgToSend = makeMeAnObject(msgToSend, 'payload')
} else if (channel === self.ioChannels.control) {
msgToSend = makeMeAnObject(msgToSend, 'uibuilderCtrl')
if ( ! msgToSend.hasOwnProperty('uibuilderCtrl') ) {
msgToSend.uibuilderCtrl = 'manual send'
}
// help remember where this came from as ctrl msgs can come from server or client
msgToSend.from = 'client'
}
/** @since 2020-01-02 Added _socketId which should be the same as the _socketId on the server */
msgToSend._socketId = self.socket.id
/** If we have an authToken and not expired, add `_auth` to output msg */
msgToSend._auth = self.sendAuth()
// Track how many messages have been sent & last msg sent
if (channel === self.ioChannels.client) {
self.set('sentMsg', msgToSend)
self.set('msgsSent', self.msgsSent + 1)
} else if (channel === self.ioChannels.control) {
self.set('sentCtrlMsg', msgToSend)
self.set('msgsSentCtrl', self.msgsSentCtrl + 1)
}
self.socket.emit(channel, msgToSend)
} // --- End of Send Msg Fn --- //
/** Register on-change event listeners
* Make it possible to register a function that will be run when the property changes.
* Note that you can create listeners for non-existant properties becuase
* Example: uibuilder.onChange('msg', function(newValue){ console.log('uibuilder.msg changed! It is now: ', newValue) })
*
* @param {string} prop The property of uibuilder that we want to monitor
* @param {function} callback The function that will run when the property changes, parameter is the new value of the property after change
*/
self.onChange = function(prop, callback) {
// Note: Property does not have to exist yet
//self.uiDebug('log', `uibuilderfe:uibuilder:onchange: pushing new callback (event listener) for property: ${prop}`)
// Create a new array or add to the array of callback functions for the property in the events object
if (self.events[prop]) {
self.events[prop].push(callback)
} else {
self.events[prop] = [callback]
}
} // ---- End of onChange() ---- //
/** Forcably removes all event listeners from the events array
* Use if you need to re-initialise the environment
*/
self.clearEventListeners = function() {
self.events = []
} // ---- End of clearEventListeners() ---- //
//#region ========== Our own event handling system ========== //
self.events = {} // placeholder for event listener callbacks by property name
/** Trigger event listener for a given property
* Called when uibuilder.set is used
*
* @param {*} prop The property for which to run the callback functions
* @param arguments Additional arguments contain the value to pass to the event callback (e.g. newValue)
*/
self.emit = function (prop) {
var evt = self.events[prop]
if (!evt) {
return
}
var args = Array.prototype.slice.call(arguments, 1)
for (var i = 0; i < evt.length; i++) {
evt[i].apply(self, args)
}
}
//#endregion ========== End of event handling system ========== //
//#region ========== Handle incoming code via received msg ========== //
/** Add a new script block to the end of <body> from text or an array of text
* @param {(string[]|string)} script
*/
self.newScript = function(script) {
if ( self.allowScript !== true ) return
if ( script === '' || (typeof script === 'undefined') ) return
//if ( script.constructor === Array ) script.join("\n")
self.uiDebug('log', 'uibuilderfe: newCode - script: ' + script)
var newScript = document.createElement('script')
newScript.type = 'text/javascript'
newScript.defer = true
// @ts-ignore
newScript.textContent = script
document.getElementsByTagName('body')[0].appendChild(newScript)
}
/** Add a new style block to end of <head> from text or an array of text
* @param {(string[]|string)} style
*/
self.newStyle = function(style) {
if ( self.allowStyle !== true ) return
if ( style === '' || (typeof style === 'undefined') ) return
self.uiDebug('log', 'uibuilderfe: newStyle - style: ' + style)
var newStyle = document.createElement('style')
// @ts-ignore
newStyle.textContent = style
document.getElementsByTagName('head')[0].appendChild(newStyle)
}
//#endregion ====== End of Handle incoming code via received msg ====== //
//#region ========== VueJS Specific functions ========== //
/** Simple function to create a bootstrap-vue toast notification from an incoming msg
* Requires a reference to a VueJS instance and a msg object from Node-RED.
* Place inside the uibuilder.on('msg', ...) function inside your Vue app's
* mounted section.
* @see https://bootstrap-vue.org/docs/components/toast
* @param {Object} msg A msg from Node-RED with appropriate formatting
*/
self.showToast = function(msg) {
// We need self.vueApp to be set
if ( ! self.vueApp ) {
console.warn('[uibuilder:toast] Vue app object not available, cannot create a toast')
return
}
/** Make sure that we have Vue loaded with the $bvToast function
* That lets us dynamically create a toast object directly in the virtual DOM */
if ( ! self.vueApp.$bvToast ) {
console.warn('[uibuilder:toast] bootstrap-vue toast component not available, cannot create a toast')
return
}
/** Make sure that we have a msg._uib object */
if ( ! msg._uib || msg._uib === null || msg._uib.constructor.name !== 'Object' ) {
console.warn('[uibuilder:toast] Incoming msg requires msg._uib object, cannot create a toast')
return
}
// $createElement is a Vue function that lets you create Vue virtual DOM
// elements. We use it here to let us render HTML in the toast.
const h = self.vueApp.$createElement
/** Toast options
* @type {Object} toastOptions Optional metadata for the toast.
* @param {String|VNode|VNode[]} [toastOptions.title] Optional title, may be HTML (vNode or array of vNodes)
* @param {Boolean} [toastOptions.appendToast] Optional. Whether to show new toasts below previous ones still on-screen (true). Or to replace previous (false - default)
* @param {Number} [toastOptions.autoHideDelay] Optional. Ms until toast is auto-hidden.
*/
let toastOptions = {}
if ( msg._uib.options ) toastOptions = Object.assign({}, msg._uib.options) // Need a copy here otherwise debug output breaks
/** Main content of the toast
* @type {String|VNode|VNode[]}
*/
let content = ''
// Main body content
if ( msg.payload ) content += msg.payload
if ( toastOptions.content ) content += toastOptions.content
// Assume that the input content is or could be HTML. create a virtual DOM element
const vNodesContent = h(
'p', {
domProps: {
innerHTML: content
}
}
)
// The title is also allowed to have HTML
if ( toastOptions.title ) toastOptions.title = h(
'p', {
domProps: {
innerHTML: toastOptions.title
}
}
)
// Do we want new toasts to be shown at the bottom of the list (true) instead of the top (false - default)?
if ( toastOptions.append ) toastOptions.appendToast = toastOptions.append
// If set, number of ms until toast is auto-hidden
if ( toastOptions.autoHideDelay ) {
toastOptions.autohide = true
toastOptions.delay = toastOptions.autoHideDelay
}
// Toast wont show anyway if content is empty, may as well warn user
if ( content === '' ) {
console.warn('[uibuilder:toast] Toast content is blank. Not shown.')
return
}
// Dynamically insert the toast to the virtual DOM
// Will show at top-right of the HTML element that is the app root
// unless you include a <b-toaster> element
self.vueApp.$bvToast.toast(vNodesContent, toastOptions)
} // --- End of makeToast() --- //
/** Simple function to show a bootstrap-vue alert (globalAlerts) */
self.showAlert = function() {}
/** Return a control msg containing the props/attribs/etc of a given Vue Component instance
* @param {String} componentRef The ref value of the component instance to be queried
* @returns {Object} msg - a uibuilder control msg object
*/
self.showComponentDetails = function(componentRef) {
// Only if Vue is in use and a reference to the Vue master app is available ...
if ( !self.vueApp ) return
if ( ! self.vueApp.$refs[componentRef] ) return
let ref = self.vueApp.$refs[componentRef]
let msg = {}
// It is possible that what looks like a component is only a set of HTML elements
// So we have to test for that.
if ( ref.$options ) {
msg = {
'uibuilderCtrl': 'vue component details',
'componentDetails': {
'ref': componentRef,
'tag': ref.$options._componentTag,
'props': ref.$options._propKeys,
},
}
} else {
let warning = `[uibuilderfe:showComponentDetails] ref="${componentRef}" is not a Vue Component. Details cannot be returned.`
self.uiDebug('warn', warning )
msg = {
'uibuilderCtrl': 'vue component details',
'componentDetails': {
'warning': warning,
'ref': componentRef,
'tag': null,
'props': null,
},
}
}
return msg
}
/** If Vue is in use and we have a reference to the main app, this fn can send data and config direct to a Vue componant instance
* if that component has been written in the right way.
* @param {Object} msg Message object from Node-RED
*/
self.onChange('msg', function(msg) {
// Only if Vue is in use and a reference to the Vue master app is available ...
if ( !self.vueApp ) return
// Do nothing if the msg doesn't have a _uib property
if ( !msg._uib ) return
// Process a client reload request from Node-RED - as the page is reloaded, everything else is ignored
if ( msg._uib.reload === true ) {
console.log('reloading')
location.reload()
}
// Do nothing if the msg doesn't have a component ref
if ( !msg._uib.componentRef ) return
let vueApp = self.vueApp
let componentRef = msg._uib.componentRef
// 1) Return ctrl msg containing component instance details
if ( msg._uib.requestDetails ) {
let m = self.showComponentDetails(componentRef)
if (m) {
if (msg.topic) m.topic = msg.topic
self.send(m, self.ioChannels.control)
}
return
}
// 2) Deal with toast requests (notifications)
if (componentRef === 'globalNotification') {
// This dynamically inserts a toast into the DOM
self.showToast(msg)
return
}
// 3) Deal with alert requests (using b-modal message boxes)
if (componentRef === 'globalAlert') {
// This dynamically inserts an alert into the DOM
self.showAlert(msg)
return
}
// 4) Does the component ref exist? Remember to include a ref="xxxx" on each component instance in your html - note: this doesn't always work, depends on component
if ( componentRef in vueApp.$refs ) {
/** Copy each prop direct into the component (updates the DOM if needed) */
if (