UIbuilder with MoonJS - simple, responsive UI

Here is a quick example using UIbuilder which is an alternative UI for Node-RED.

It demonstrates the use of a lightweight front-end library, MoonJS, which can be a lot faster and easier to work with than Node-RED's built-in Dashboard UI once you reach the point of needing to customise the UI beyond the standard capabilities of Dashboard.

This is a really quick demo of what you can do with uibuilder. MoonJS is a lightweight alternative to the likes of Angular (used by Dashboard), REACT, VueJS, etc. It is fast and really easy to use.

Here are the steps to set up (details given below):

  1. Install MoonJS & uibuilder using npm
  2. Add reference for MoonJS to settings.js
  3. Restart Node-RED and add a uibuilder node with input and output nodes (see the flow for the simplest of examples)
  4. Copy the template html, js and css files from the uibuilder master folder to the uibuilder folder in your userDir. Amend as per the examples below
  5. Open the uibuilder URL and send msg's both ways to see it working.

Note that I will assume your userDir is at ~/.node-red which is the default.

1. Install MoonJS & uibuilder using npm

Open a terminal/command prompt, cd to ~/.node-red (most platforms including Windows PowerShell, %USERPROFILE%\.node-red for Windows cmd prompt).

npm install node-red-contrib-uibuilder moonjs --save

2. Add reference for MoonJS to settings.js

Add the following to your ~/.node-red/settings.js file:

    uibuilder: {
        userVendorPackages: ['moonjs'],
        debug: true
    }

Don't forget to fix any preceding/trailing comma's to ensure that the JavaScript object is valid.

3. Restart Node-RED and add a uibuilder node with input and output nodes

After restarting Node-RED, import the flow given below and deploy. Note that the instance of the uibuilder node in the example flow has its URL set to moon.

4. Copy/amend the template files

You should now have a folder ~/.node-red/uibuilder/moon/src. You need to copy over the files shown below into that folder.

5. Open the uibuilder URL

In your favourite browser, navigate to the moon url. If you are using default settings and running Node-RED on the same machine as the browser, this will be http://localhost:1880/moon.

Now you can use the button in the web page to send a message back to Node-RED, the data will appear in the debug output pane. Then you can use the Inject node to send a message to the browser.

Example front-end files

These go in ~/.node-red/uibuilder/moon/src

index.html

<!doctype html>
<html lang="en">
  <!--
    This is the default, template html for uibuilder.
    It is only meant to demonstrate the use of JQuery to dynamically
    update the ui based on incoming/outgoing messages from/to the
    Node-RED server.

    You will want to alter this to suite your own needs. To do so,
    copy this file to <userDir>/uibuilder/<url>/src.
  -->
<head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, minimum-scale=1, initial-scale=1, user-scalable=yes">

    <title>Node-RED UI Builder</title>
    <meta name="description" content="Node-RED UI Builder">

    <link rel="icon" href="images/node-red.ico">

    <!-- See https://goo.gl/OOhYW5 -->
    <link rel="manifest" href="manifest.json">
    <meta name="theme-color" content="#3f51b5">

    <!-- Used if adding to homescreen for Chrome on Android. Fallback for manifest.json -->
    <meta name="mobile-web-app-capable" content="yes">
    <meta name="application-name" content="Node-RED UI Builder">

    <!-- Used if adding to homescreen for Safari on iOS -->
    <meta name="apple-mobile-web-app-capable" content="yes">
    <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
    <meta name="apple-mobile-web-app-title" content="Node-RED UI Builder">

    <!-- Homescreen icons for Apple mobile use if required
        <link rel="apple-touch-icon" href="/images/manifest/icon-48x48.png">
        <link rel="apple-touch-icon" sizes="72x72" href="/images/manifest/icon-72x72.png">
        <link rel="apple-touch-icon" sizes="96x96" href="/images/manifest/icon-96x96.png">
        <link rel="apple-touch-icon" sizes="144x144" href="/images/manifest/icon-144x144.png">
        <link rel="apple-touch-icon" sizes="192x192" href="/images/manifest/icon-192x192.png">
    -->
    <link rel="stylesheet" href="vendor/normalize.css/normalize.css" media="all">
    <link rel="stylesheet" href="index.css" media="all">

</head>
<body>
    <div id="app">
        <h1>
            UIbuilder + Moon for Node-RED
        </h1>
        <p>
            This is a uibuilder test using <a href="http://moonjs.ga/">Moon.JS</a> as a front-end library.
            See the 
            <a href="https://github.com/TotallyInformation/node-red-contrib-uibuilder">node-red-contrib-uibuilder</a>
            README for details on how to use UIbuilder.
        </p>
        <h2>Dynamic Data (via Moon)</h2>

        <p>
            Messages: Received={{msgsReceived}}, Sent: {{msgsSent}}
        </p><p>
            Control Messages Received: {{msgsControl}}
        </p>

        <pre m-html="hLastRcvd"></pre>
        <pre m-html="hLastSent"></pre>

        <h2>Simple input using Moon</h2>
        <p><button m-on:click="increment">Increment</button> Click Counter: {{counterBtn}}. Click on the button to increment, it sends the data back to Node-RED as well.</p>

    </div>

    <!-- <script>
        // Progressive web app needs a service worker - @see https://github.com/GoogleChromeLabs/airhorn/tree/master/app
        if('serviceWorker' in navigator) {
            navigator.serviceWorker.register('serviceworker.js', { scope: window.location.pathname })
                .then(function(registration) {
                    console.log('Service Worker Registered')
                })
            navigator.serviceWorker.ready.then(function(registration) {
                console.log('Service Worker Ready')
            })
        }
    </script> -->
    <!-- Socket.IO is loaded only once for all instances -->
    <script src="/uibuilder/socket.io/socket.io.js"></script>
    <!-- Note no leading / -->
    <!-- <script src="vendor/jquery/dist/jquery.min.js"></script> -->
    <script src="vendor/moonjs/dist/moon.min.js"></script>    <!-- //prod version -->
    <!-- <script src="vendor/moonjs/dist/moon.js"></script>   //dev version -->
    <script src="index.js"></script>

</body>
</html>

index.js

/*global document,$,window,io */
/*
  Copyright (c) 2017 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 based on the default template js for uibuilder.
 * It has been changed to remove JQuery but use MoonJS
 */

const debug = true,
      ioChannels = {control: 'uiBuilderControl', client: 'uiBuilderClient', server: 'uiBuilder'},
      msgCounter = {control: 0, sent: 0, data: 0},
      //cookies = [],
      retryMs = 2000 // retry ms period for manual socket reconnections workaround

var timerid,
    msg = {},
    ioNamespace = '', // '/' + readCookie('uibuilder-namespace'),
    socket

// Get the namespace from the current URL rather than a cookie which seems unreliable
// if last element is '', take [-1]
var u = window.location.pathname.split('/')
ioNamespace = u.pop()
if (ioNamespace === '') ioNamespace = u.pop()
// Socket.IO namespace HAS to start with a leading slash
ioNamespace = '/' + ioNamespace

debug && console.log('IO Namespace: ' + ioNamespace)

// Create the socket - make sure client uses Socket.IO version from the uibuilder module (using path)
socket = io(ioNamespace, {
    path: '/uibuilder/socket.io',
    transports: ['polling', 'websocket']
})

// Attach a Moon instance to html element with id "app"
const app1 = new Moon({
    el: "#app",
    data: {
        startMsg: "Moon has started, waiting for messages",
        counterBtn: 0,
        msgsReceived: 0,
        msgsControl: 0,
        msgsSent:0,
        msgRecvd: '[Nothing]',
        msgSent: '[Nothing]'
    },
    computed: {
        hLastRcvd: {
            get: function() {
                const msgRecvd = this.get('msgRecvd')
                if (typeof msgRecvd === 'string') return 'Last Message Received = ' + msgRecvd
                else return 'Last Message Received = ' + syntaxHighlight(msgRecvd)
            }
        },
        hLastSent: {
            get: function() {
                const msgSent = this.get('msgSent')
                if (typeof msgSent === 'string') return 'Last Message Sent = ' + msgSent
                else return 'Last Message Sent = ' + syntaxHighlight(msgSent)
            }
        }
    },
    methods: {
      increment: function() {
        // Increment the count by one
        this.set('counterBtn', this.get('counterBtn') + 1);
        sendMsg( { topic: msg.topic, payload: { 'type': 'counterBtn', 'data': this.get('counterBtn') } } )
      }
    }
});

// When the socket is connected .................
socket.on('connect', function() {
    debug && console.log('SOCKET CONNECTED - Namespace: ' + ioNamespace)

    // Reset any reconnect timers
    if (timerid) {
        window.clearTimeout(timerid)
        retryMs = 2000
        timerid = null
    }

    // When Node-RED uibuilder template node sends a msg over Socket.IO...
    socket.on(ioChannels.server, function(receivedMsg) {
        debug && console.info('uibuilder:socket.connect:socket.on.data - msg received - Namespace: ' + ioNamespace)
        debug && console.dir(receivedMsg)

        // Make sure that msg is an object & not null
        if ( receivedMsg === null ) {
            receivedMsg = {}
        } else if ( typeof receivedMsg !== 'object' ) {
            receivedMsg = { 'payload': receivedMsg }
        }

        // Save the msg for further processing
        msg = receivedMsg

        // Track how many messages have been received
        msgCounter.data++

        // update the UI
        app1.set( 'msgRecvd', msg)
        app1.set('msgsReceived', msgCounter.data);

        // TODO: Add a check for a pre-defined global function here
        //       to make it easier for users to add their own code
        //       to process receipt of new msg
        //       OR MAYBE use msg.prototype to add a function?

        // Test auto-response - not really required but useful when getting started
        //if (debug) {
        //    sendMsg({payload: 'We got a message from you, thanks'})
        //}

    }) // -- End of websocket receive DATA msg from Node-RED -- //

    // Receive a CONTROL msg from Node-RED
    socket.on(ioChannels.control, function(receivedCtrlMsg) {
        debug && console.info('uibuilder:socket.connect:socket.on.control - msg received - Namespace: ' + ioNamespace)
        debug && console.dir(receivedCtrlMsg)


        // Make sure that msg is an object & not null
        if ( receivedCtrlMsg === null ) {
            receivedCtrlMsg = {}
        } else if ( typeof receivedCtrlMsg !== 'object' ) {
            receivedCtrlMsg = { 'payload': receivedCtrlMsg }
        }

        msgCounter.control++

        // Update UI
        app1.set('msgsControl', msgCounter.control);

        switch(receivedCtrlMsg.type) {
            case 'shutdown':
                // We are shutting down
                break
            case 'connected':
                // We are connected to the server
                break
            default:
                // Anything else
        }

        // Test auto-response
        //if (debug) {
        //    sendMsg({payload: 'We got a control message from you, thanks'})
        //}

    }) // -- End of websocket receive CONTROL msg from Node-RED -- //

}) // --- End of socket connection processing ---

// When the socket is disconnected ..............
socket.on('disconnect', function(reason) {
    // reason === 'io server disconnect' - redeploy of Node instance
    // reason === 'transport close' - Node-RED terminating
    debug && console.log('SOCKET DISCONNECTED - Namespace: ' + ioNamespace + ', Reason: ' + reason)

    // A workaround for SIO's failure to reconnect after a NR redeploy of the node instance
    if ( reason === 'io server disconnect' ) {
        if (timerid) window.clearTimeout(timerid) // we only want one running at a time
        timerid = window.setTimeout(function(){
            debug && console.log('Manual SIO reconnect attempt, timeout: ' + retryMs)
            socket.connect() // Try to reconnect
            retryMs = retryMs + 1000 // extend timer for next time round
        }, retryMs)
    }
}) // --- End of socket disconnect processing ---

/* We really don't need these, just for interest
    socket.on('connect_error', function(err) {
        debug && console.log('SOCKET CONNECT ERROR - Namespace: ' + ioNamespace + ', Reason: ' + err.message)
        //console.dir(err)
    }) // --- End of socket connect error processing ---
    socket.on('connect_timeout', function(data) {
        debug && console.log('SOCKET CONNECT TIMEOUT - Namespace: ' + ioNamespace)
        console.dir(data)
    }) // --- End of socket connect timeout processing ---
    socket.on('reconnect', function(attemptNum) {
        debug && console.log('SOCKET RECONNECTED - Namespace: ' + ioNamespace + ', Attempt #: ' + attemptNum)
    }) // --- End of socket reconnect processing ---
    socket.on('reconnect_attempt', function(attemptNum) {
        debug && console.log('SOCKET RECONNECT ATTEMPT - Namespace: ' + ioNamespace + ', Attempt #: ' + attemptNum)
    }) // --- End of socket reconnect_attempt processing ---
    socket.on('reconnecting', function(attemptNum) {
        debug && console.log('SOCKET RECONNECTING - Namespace: ' + ioNamespace + ', Attempt #: ' + attemptNum)
    }) // --- End of socket reconnecting processing ---
    socket.on('reconnect_error', function(err) {
        debug && console.log('SOCKET RECONNECT ERROR - Namespace: ' + ioNamespace + ', Reason: ' + err.message)
        //console.dir(err)
    }) // --- End of socket reconnect_error processing ---
    socket.on('reconnect_failed', function(data) {
        debug && console.log('SOCKET RECONNECT FAILED - Namespace: ' + ioNamespace)
        console.dir(data)
    }) // --- End of socket reconnect_failed processing ---
    socket.on('ping', function() {
        debug && console.log('SOCKET PING - Namespace: ' + ioNamespace)
    }) // --- End of socket ping processing ---
    socket.on('pong', function(data) {
        debug && console.log('SOCKET PONG - Namespace: ' + ioNamespace + ', Data: ' + data)
    }) // --- End of socket pong processing ---
*/

// ----- UTILITY FUNCTIONS ----- //
// send a msg back to Node-RED, NR will generally expect the msg to contain a payload topic
var sendMsg = function(msgToSend) {
    debug && console.info('uibuilder:msg sent - Namespace: ' + ioNamespace)
    debug && console.dir(msgToSend)

    // Track how many messages have been sent
    msgCounter.sent++

    app1.set( 'msgSent', msgToSend)
    app1.set( 'msgsSent', msgCounter.sent )

    socket.emit(ioChannels.client, msgToSend)
} // --- End of Send Msg Fn --- //

// return formatted HTML version of JSON object
function syntaxHighlight(json) {
    json = JSON.stringify(json, undefined, 4)
    json = json.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
    return json.replace(/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?)/g, function (match) {
        var cls = 'number';
        if (/^"/.test(match)) {
            if (/:$/.test(match)) {
                cls = 'key';
            } else {
                cls = 'string';
            }
        } else if (/true|false/.test(match)) {
            cls = 'boolean';
        } else if (/null/.test(match)) {
            cls = 'null';
        }
        return '<span class="' + cls + '">' + match + '</span>';
    });
}

function readCookie(name,c,C,i){
    // @see http://stackoverflow.com/questions/5639346/what-is-the-shortest-function-for-reading-a-cookie-by-name-in-javascript
    if(cookies.length > 0){ return cookies[name]; }

    c = document.cookie.split('; ');
    cookies = {};

    for(i=c.length-1; i>=0; i--){
        C = c[i].split('=');
        cookies[C[0]] = C[1];
    }

    return cookies[name];
}
// ----------------------------- //

// EOF

index.css

body {font-family: sans-serif;}
div, p, code, pre { margin:0.3em; padding: 0.3em;}
pre .string { color: green; }
.number { color: darkorange; }
.boolean { color: blue; }
.null { color: magenta; }
.key { color: red; }
[
    {
        "id": "65c207ab.b53e08",
        "type": "debug",
        "z": "106ba95c.ff91e7",
        "name": "moonjs-debug",
        "active": true,
        "console": "false",
        "complete": "true",
        "x": 480,
        "y": 260,
        "wires": []
    },
    {
        "id": "2283ab5e.70d5a4",
        "type": "uibuilder",
        "z": "106ba95c.ff91e7",
        "name": "moonjs",
        "url": "moon",
        "fwdInMessages": false,
        "customFoldersReqd": true,
        "x": 270,
        "y": 260,
        "wires": [
            [
                "65c207ab.b53e08"
            ]
        ]
    },
    {
        "id": "eb50e622.116878",
        "type": "inject",
        "z": "106ba95c.ff91e7",
        "name": "",
        "topic": "uibuilder-moon",
        "payload": "{\"lib\":\"moon\", \"testNum\": 500}",
        "payloadType": "json",
        "repeat": "",
        "crontab": "",
        "once": false,
        "x": 130,
        "y": 260,
        "wires": [
            [
                "2283ab5e.70d5a4"
            ]
        ]
    }
]
TotallyInformation

Flow Info

created 3 weeks, 2 days ago

Node Types

Core
  • debug (x1)
  • inject (x1)
Other

Tags

  • uibuilder
  • ui
  • dashboard
  • front-end
  • MoonJS
Copy this flow JSON to your clipboard and then import into Node-RED using the Import From > Clipboard (Ctrl-I) menu option