New home dashboard using uibuilder and bootstrap-vue


#1

Hi all, thought I'd share something of a project I've been working on over Christmas.

My family have been complaining that the Drayton Wiser smart heating system and my smart lighting aren't easy enough to use. While I've used Node-RED's native dashboard in the past, it isn't always that mobile friendly (it is very resource hungry for one thing) and it can be hard to get just the layout you want.

So I decided I should start building a custom dashboard.

My uibuilder node lets me choose any front-end tooling I like so after quite some hunting around, I decided to use VueJS (it has come on a long way since v1 and is one of the fastest-growing front-end frameworks with loads of support). Then I needed to choose a visual framework. I stumbled upon bootstrap-vue which uses all the features of Twitter Bootstrap and adds some really useful VueJS components on top.

Tools selected, I could start work. The dashboard needs to let us control lights and see what the heating is doing. Eventually it will let us control the heating too, that's in the next phase.

Some screenshots that may hopefully inspire you:

Lights are listed by room, the numbers correspond to a remote control. These are buttons that, when pressed, toggle the switch. Hovering over the button shows the last time it changed.


image


This is a larger tab showing not only every room that has either a smart heating device but also all those with sensors. clicking on a table row expands the details. Highlighting is dynamic for temperature, humidity, whether the room is asking for heat and whether it has been manually boosted. The small card shows how much demand is being asked for from the boiler and whether the boiler is active and whether any room is on manual boost.


The main table here is produced using a bootstrap-vue table component and all you need to do is throw an array at it then tweak the columns in a second configuration array - really easy.


This tab shows various devices, where they are and whether they are online or not along with the last date/time an update was received.


bootstrap-vue has nice popovers as well:
image

image


And the main flows - yes, it really is this simple! Though the "Create Home Details" function node is 260 lines long):



Hope this inspires you. When things settle down, I will write up everything including the code.

Some stats regarding the custom code for the front-end:

  • A full reload (without cache) loads around 1.1MB in about 1.5s (by comparison, a fairly simple Dashboard loads around 2.4MB in around 7s but subsequent loads are also about 1.5s due to aggressive caching - something that I perhaps need to improve on uibuilder).
  • Lines of HTML (including comments and some uibuilder standard boilerplate): 352
  • Lines of custom CSS: 14
  • Lines of custom JavaScript (including lots of comments): 505
  • Heap size when active: 34-40k (heap size on the example Dashboard is over 50k)

I'll update this thread as new features come online.


#2

Discovered that Dashboard is "cheating" for caching! :slight_smile:

It is using the soon to be deprecated appcache feature. Adding that to my index.html reduces network transfer down to just 4.4k most of which is the initial data load over websockets since I'm loading a relatively hefty dataset.


#3

I would be very interested in all of this once you have wrapped it up etc

regards

Craig


#4

Well done sir. You have indeed inspired me to give uibuilder a shot. I like the fact that it opens many doors and it will cut my teeth on using a visual framework.


#5

Great stuff :slight_smile:

Well, I'm generally around if you want to ask questions. It has actually turned out better than i expected when I started on it. Still much to do though like adding admin ui editors so that you can edit your index.html/css/js files from within the Node-RED admin.

Don't forget to check out the uibuilder WIKI which has loads of examples.


#6

Very good project. I am working on custom IOT front end framework using standard dashboard with about 10 rpieasy devices (https://github.com/enesbcs/rpieasy) .RPIEasy is very new project and I think it has great potential for simple and affordable home IOT platform if paired with node-red as an out of box package. RPIEasy have Json output for most of the device's settings like below which is very useful input for node-red.I use this Json file for automatic detection & to setup basic automatic flow.

{"System":{"Build":"RPIEasy 0.19.033","System libraries":"Python 3.5.3 (default, Sep 27 2018, 17:25:39) [GCC 6.3.0 20170516] Linux-4.14.79-v7+-armv7l-with-debian-9.6","Plugins":24,"Local time":"2019-02-07 15:41:48","Unit":1,"Name":"Livingroom","Uptime":272.68333333333334,"Load":29.59,"Free RAM":523644928.0},"WiFi":{"IP config":"DHCP","IP":"10.1.10.40","Subnet Mask":"255.255.255.0","Gateway IP":"10.1.10.1","MAC address":"removed","DNS 1":"10.1.10.1","SSID":"HTM","RSSI":-58},"Sensors":[{"TaskValues": [{"ValueNumber":1,"Name":"X","NrDecimals":0,"Value":367.0}],"DataAcquisition": [{"Controller":1,"IDX":-1,"Enabled":"False"},{"Controller":2,"IDX":-1,"Enabled":"False"},{"Controller":3,"IDX":-1,"Enabled":"False"},{"Controller":4,"IDX":-1,"Enabled":"False"}],"TaskInterval":2.0,"Type":"Extra IO - ProMini Extender (TESTING)","TaskName":"A1","TaskEnabled":"True","TaskNumber":1},{"TaskValues": [{"ValueNumber":1,"Name":"Y","NrDecimals":0,"Value":367.0}],"DataAcquisition": [{"Controller":1,"IDX":-1,"Enabled":"False"},{"Controller":2,"IDX":-1,"Enabled":"False"},{"Controller":3,"IDX":-1,"Enabled":"False"},{"Controller":4,"IDX":-1,"Enabled":"False"}],"TaskInterval":2.0,"Type":"Extra IO - ProMini Extender (TESTING)","TaskName":"A2","TaskEnabled":"True","TaskNumber":2},{"TaskValues": [{"ValueNumber":1,"Name":"Gesture","NrDecimals":0,"Value":0.0}],"DataAcquisition": [{"Controller":1,"IDX":-1,"Enabled":"False"},{"Controller":2,"IDX":-1,"Enabled":"True"},{"Controller":3,"IDX":-1,"Enabled":"False"},{"Controller":4,"IDX":-1,"Enabled":"False"}],"TaskInterval":0,"Type":"Input - APDS9960 Gesture sensor","TaskName":"9960","TaskEnabled":"True","TaskNumber":3},{"TaskValues": [],"DataAcquisition": [{"Controller":1,"IDX":-1,"Enabled":"False"},{"Controller":2,"IDX":-1,"Enabled":"False"},{"Controller":3,"IDX":-1,"Enabled":"False"},{"Controller":4,"IDX":-1,"Enabled":"False"}],"TaskInterval":1.0,"Type":"Display - Simple OLED","TaskName":"SSD1306","TaskEnabled":"True","TaskNumber":4},{"TaskValues": [{"ValueNumber":1,"Name":"ESPrms","NrDecimals":2,"Value":0.0}],"DataAcquisition": [{"Controller":1,"IDX":0,"Enabled":"True"},{"Controller":2,"IDX":-1,"Enabled":"False"},{"Controller":3,"IDX":-1,"Enabled":"False"},{"Controller":4,"IDX":-1,"Enabled":"False"}],"TaskInterval":2.0,"Type":"Extra IO - ProMini Extender (TESTING)","TaskName":"A30","TaskEnabled":"False","TaskNumber":5},{"TaskValues": [{"ValueNumber":1,"Name":"Volt","NrDecimals":2,"Value":3.67}],"DataAcquisition": [{"Controller":1,"IDX":-1,"Enabled":"False"},{"Controller":2,"IDX":-1,"Enabled":"False"},{"Controller":3,"IDX":-1,"Enabled":"False"},{"Controller":4,"IDX":-1,"Enabled":"False"}],"TaskInterval":2.0,"Type":"Extra IO - ProMini Extender (TESTING)","TaskName":"A0","TaskEnabled":"True","TaskNumber":6},{"TaskValues": [{"ValueNumber":1,"Name":"Data","NrDecimals":-1,"Value":"0"}],"DataAcquisition": [{"Controller":1,"IDX":-1,"Enabled":"False"},{"Controller":2,"IDX":-1,"Enabled":"False"},{"Controller":3,"IDX":-1,"Enabled":"False"},{"Controller":4,"IDX":-1,"Enabled":"False"}],"TaskInterval":60,"Type":"Communication - Serial (TESTING)","TaskName":"Serial","TaskEnabled":"False","TaskNumber":7},{"TaskValues": [{"ValueNumber":1,"Name":"Data","NrDecimals":0,"Value":0.0}],"DataAcquisition": [{"Controller":1,"IDX":-1,"Enabled":"False"},{"Controller":2,"IDX":-1,"Enabled":"False"},{"Controller":3,"IDX":-1,"Enabled":"False"},{"Controller":4,"IDX":-1,"Enabled":"False"}],"TaskInterval":60,"Type":"Input - Generic EvDev (TESTING)","TaskName":"","TaskEnabled":"False","TaskNumber":8},{"TaskValues": [{"ValueNumber":1,"Name":"pin","NrDecimals":0,"Value":7.0},{"ValueNumber":2,"Name":"dimvalue","NrDecimals":0,"Value":"0 # turn off dimmer"},{"ValueNumber":3,"Name":"event","NrDecimals":0,"Value":0.0},{"ValueNumber":4,"Name":"Dummy4","NrDecimals":1,"Value":0.0}],"DataAcquisition": [{"Controller":1,"IDX":-1,"Enabled":"False"},{"Controller":2,"IDX":0,"Enabled":"False"},{"Controller":3,"IDX":-1,"Enabled":"False"},{"Controller":4,"IDX":-1,"Enabled":"False"}],"TaskInterval":0,"Type":"Generic - Dummy Device","TaskName":"dimmer","TaskEnabled":"True","TaskNumber":12},{"TaskValues": [{"ValueNumber":1,"Name":"uptime","NrDecimals":0,"Value":16359.0},{"ValueNumber":2,"Name":"rssi","NrDecimals":0,"Value":-59.0},{"ValueNumber":3,"Name":"ram","NrDecimals":0,"Value":612428.0},{"ValueNumber":4,"Name":"load","NrDecimals":0,"Value":2.0}],"DataAcquisition": [{"Controller":1,"IDX":-1,"Enabled":"False"},{"Controller":2,"IDX":0,"Enabled":"True"},{"Controller":3,"IDX":-1,"Enabled":"False"},{"Controller":4,"IDX":-1,"Enabled":"False"}],"TaskInterval":5.0,"Type":"Generic - System Info","TaskName":"sysinfo","TaskEnabled":"True","TaskNumber":16}],"TTL":1000.0}

My main goal is to have automatic detection of new devices and setting up of new device from dashboard without ever going into editor.I was able to resolve automatic detection and setting up of template flow to be populated on UI dashboard with some basic setup to receive sensor data and control the device but have to work on setting up device from dashboard using some kind of form input to take rpieasy device settings data and somehow setup more flows depending on input from that form on dasbord. Rpieasy have that interface similar in look with lot of forms to setup the device but they do not have interface to control the sensors attached to that device. Here my goal is to have both on node-red custom dashboard one simple web page with tabs like you are showing here. May be I will be able to use your project. It looks to me it is going in right direction. Eagerly waiting for your code.My goal is to have drop down selector at top to select a device and present status and control details for that paticular device like here: https://www.letscontrolit.com/wiki/index.php/Mini_Dashboard

Hopefully one day it will be plug & play home IOT with very little input from a user so anybody who can use a browser can setup whole home IOT easily from their phone.

Thanks
Ken


#7

Thanks. I've not had much chance to work on it recently. Partly because of both work and some building work at home but also partly because I finally decided on a design pattern for the next update to uibuilder itself - allowing editing of the front-end code files (index.html|js|css, etc). So I've been working on that.


#8

@TotallyInformation,
I would very much like to build an app using uibuilder. Although, I am uncertain of the level of knowledge I have to implement it. I can hack and muttle my way through most things, but building a web app from scratch using just a library supplied seems a bit daunting. Could you share some reading topics that one should be familiar with before beginning a project with uibuilder?


New home dashboard using uibuilder and bootstrap
#9

There are some great worked examples and other information on the wiki https://github.com/TotallyInformation/node-red-contrib-uibuilder/wiki


#10

Hi Seth, as Dave has said, the WIKI contains a bunch of simple examples and a couple that are a little more complex.

I think that, even if you just take the built-in starter template that uses jQuery, you will quickly see how it all works.

Indeed, jQuery is enough to do any fairly simple interface. You only need to turn to one of the more comprehensive front-end frameworks once your front-end logic starts to get more complex. Using a framework then can really help keep things organised and keep the code to a minimum.

If you want to jump into the deep end, the VueJS example in the WIKI is a great place to start. There is more code in there than you actually need simply because it is illustrating some of the things you can do. With my dashboard, I also wanted something that would look good out of the box without me having to spend a lot of time hand-crafting CSS. That's why I was happy when I discovered bootstrap-vue as a bolt-on to VueJS. Not only does it give you some really nice extra components to use such as the table component, but it also gives you a nice standard layout and grid framework that isn't too restrictive or overly complex.

Without wishing to scare you away and bearing in mind that this is a relatively complex example, the next post is some example code from my dashboard.

Note that one of the reasons the code is so complex is that the incoming data is really complex as it is an aggregation from the heating system and a bunch of sensors. Also, the html is complex because of the many different displays I want to overlay using the tabs.

If you read through it carefully however, it should start to make sense. Bear in mind that, in practice, you build these things up over time so it makes a lot more sense to you.

There are also undoubtedly better ways of structuring Vue code than the way I've done it, I'm still learning and I'm now experimenting with a build workflow that is also detailed in the WIKI, this will eventually let me break the code down into smaller sections.


#11

index.html

<!doctype html>
<html lang="en" manifest="uibuilder.appcache">

    <head>
        <meta charset="utf-8">
        <meta http-equiv="X-UA-Compatible" content="IE=edge">
        <meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=yes">

        <!-- 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="Home Dashboard">

        <!-- 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="Home Dashboard">

        <!-- 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">
        -->

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

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

        <link type="text/css" rel="stylesheet" href="./vendor/bootstrap/dist/css/bootstrap.min.css" />
        <link type="text/css" rel="stylesheet" href="./vendor/bootstrap-vue/dist/bootstrap-vue.css" />
        <link rel="stylesheet" href="./index.css">

    </head>

    <body>
        <script type="text/x-template" id="lights-tab-template">
            <div>
                <h5>Room Switches</h5>
                <div v-for="room in homeData" :key="room.Name" v-if="room.switches">
                    <b-row class="my-2">
                        <b-col cols="6" sm="3" md="2">
                            {{ room.Name }}
                        </b-col>
                        <b-col>
                            <b-button-group >
                                <b-button v-for="sw in switches" :key="sw.id"
                                        v-if="sw.room === room.Name"
                                        :variant="sw.status === 'On' ? 'success' : ''"
                                        @click="switchClick([sw.id, sw.status])"
                                        v-b-popover.focus.hover.bottomright="{content:`Last Update: ${fmtTime(sw.lastUpdate)}`}">
                                    {{ sw.id.replace('SWITCH','') }} - {{ _.capitalize(sw.status) }}
                                </b-button>
                            </b-button-group>
                        </b-col>
                    </b-row>
                </div>
            </div>
        </script>
        <script type="text/x-template" id="demand-card-template">
            <b-card id="demand_card" header-tag="header" footer-tag="footer" class="text-center shadow"
                    v-b-popover.focus.hover.bottomright="{content:'Bar shows overall % demand. See room details for room demands.'}"
                    >
                <h6 slot="header">Demand</h6>
                <b-progress :max="demandMax" height="2rem">
                    <b-progress-bar :value="percentageDemand" :variant="demandLevel">{{percentageDemand}}%</b-progress-bar>
                </b-progress>
                <div slot="footer">
                    <span :class="classDemandActive">
                        Boiler {{demandOnOffOutput}}
                    </span>
                    ,
                    <span :class="classIsBoosted">
                        Boost {{ isBoostedText }}
                    </span>
                </div>
            </b-card>
        </script>
        <script type="text/x-template" id="device-tab-template">
            <div>
                <h5>Devices</h5>
                <div v-for="device in orderedDevices" :key="device.id">
                    <b-row class="my-2">
                        <b-col cols="6" sm="3" md="2">
                                {{ device.id }}
                        </b-col>
                        <b-col>
                            <b-button :variant="device.status === 'Online' ? 'success' : 'warning'"
                                    v-b-popover.focus.hover.bottomright="{content:`Last Update: ${fmtTime(device.lastUpdate)}`}">
                                {{ _.capitalize(device.status) }}{{ device.room ? ' - ' : '' }}{{ device.room }}
                            </b-button>
                        </b-col>
                        <b-col>
                                {{ fmtTime(device.lastUpdate) }}
                        </b-col>
                    </b-row>
                </div>
            </div>

        </script>

        <!-- The "app" element is where the code for dynamic updates is attached -->
        <div id="app">
            <b-container id="app_container">
                <b-navbar toggleable="md" type="dark" variant="dark">
                    <b-navbar-toggle target="nav_collapse"></b-navbar-toggle>

                    <b-navbar-brand href="#" v-b-popover.focus.hover.bottomright="{content:'Heating information and controls.'}">
                        Home
                    </b-navbar-brand>

                    <b-collapse is-nav id="nav_collapse">
                        <b-navbar-nav>
                            <b-nav-text
                                    v-b-popover.focus.hover.bottomright="{title:'Last update',content:'A warning will appear if no updates have been received in 2 minutes.'}"
                                    >
                                {{lastUpdate}}
                            </b-nav-text>
                            <b-nav-text v-if="demandOnOffOutput === 'On'"
                                    v-b-popover.focus.hover.bottomright="{content:`Boiler is ${demandOnOffOutput}, Boost is ${isBoostedText}`}"
                                    >
                                <svg height="24" style="margin-left:1em" class="octicon octicon-flame" viewBox="0 0 12 16" version="1.1" width="24" aria-hidden="true">
                                    <path :style="isBoostedFill" fill-rule="evenodd" d="M5.05.31c.81 2.17.41 3.38-.52 4.31C3.55 5.67 1.98 6.45.9 7.98c-1.45 2.05-1.7 6.53 3.53 7.7-2.2-1.16-2.67-4.52-.3-6.61-.61 2.03.53 3.33 1.94 2.86 1.39-.47 2.3.53 2.27 1.67-.02.78-.31 1.44-1.13 1.81 3.42-.59 4.78-3.42 4.78-5.56 0-2.84-2.53-3.22-1.25-5.61-1.52.13-2.03 1.13-1.89 2.75.09 1.08-1.02 1.8-1.86 1.33-.67-.41-.66-1.19-.06-1.78C8.18 5.31 8.68 2.45 5.05.32L5.03.3l.02.01z"></path>
                                </svg>
                            </b-nav-text>
                        </b-navbar-nav>

                        <!-- Right aligned nav items -->
                        <b-navbar-nav class="ml-auto">
                            <b-nav-item-dropdown right
                            v-b-popover.focus.hover.bottomright="{content:'Links to other dashboards.'}">
                                <template slot="button-content">
                                    <em>Dashboards</em>
                                </template>
                                <b-dropdown-item href="/ui">Quick Dashboard</b-dropdown-item>
                                <b-dropdown-item href="https://pi3.local:3000/" onclick="javascript:window.location.port=3000">Detailed
                                    Dashboard</b-dropdown-item>
                            </b-nav-item-dropdown>
                            <b-nav-item-dropdown right
                            v-b-popover.focus.hover.bottomright="{content:'Links to admin web pages.'}">
                                <template slot="button-content">
                                    <em>Admin</em>
                                </template>
                                <b-dropdown-item href="/red">Administration</b-dropdown-item>
                            </b-nav-item-dropdown>
                            <b-nav-item-dropdown right
                                    v-b-popover.focus.hover.bottomright="{content:'Links to direct device web pages.'}">
                                <template slot="button-content">
                                    <em>Devices</em>
                                </template>
                                <b-dropdown-item href="http://192.168.1.152/status">D1M02</b-dropdown-item>
                                <b-dropdown-item href="http://192.168.1.187/">D1M04</b-dropdown-item>
                                <b-dropdown-item href="http://192.168.1.188/">D1M05</b-dropdown-item>
                                <b-dropdown-item href="http://192.168.1.159">POW1</b-dropdown-item>
                            </b-nav-item-dropdown>
                        </b-navbar-nav>

                    </b-collapse>
                </b-navbar>

                <b-container id="warnings">
                    <b-alert variant="danger" :show="showNoUpdAlert" @dismissed="showNoUpdAlert=false">
                        <h4 class="alert-heading">Heating Warning:</h4>
                        <p>
                            No heating data update received in over 2 minutes.
                        </p>
                        <hr>
                        <p>
                            Check that the controller (on kitchen wall) is on and isn't showing red lights.
                        </p>
                        <p>
                            If any red lights showing, gently pull forwards the bottom of the controller until the
                            lights go off, wait 30sec then push the bottom back. The lights should go green after about
                            a minute.
                        </p>
                        This alert will go away when data is received again.
                    </b-alert>
                </b-container>

                <b-card no-body id="main">
                    <b-tabs card id="tabs" v-model="tabIndex" @input="changeTab">
                        <b-tab title="Lights">
                            <lights-tab :home-data="homeData" :switches="switches"></lights-tab>
                        </b-tab>

                        <b-tab title="Heating">
                            Sorry, not ready yet
                        </b-tab>

                        <b-tab title="Details">
                            <b-row>
                                <b-col cols="3">
                                    <demand-card
                                        :percentage-demand="percentageDemand"
                                        :demand-level="demandLevel"
                                        :demand-max="demandMax"
                                        :demand-on-off-output="demandOnOffOutput"
                                        :is-boosted="isBoosted">
                                    </demand-card>
                                </b-col>

                                <b-col>
                                    <b-card id="rooms_card" class="shadow">
                                        <b-table responsive flex hover head-variant="dark" small stacked="sm" outlined
                                                :items="homeData" :fields="homeDataFields"
                                                :filter="currentRoomsTblFilter" @row-clicked="onRoomsRowClicked">
                                            <template slot="override" slot-scope="row">
                                                <p class="my-0"
                                                    v-b-popover.focus.hover.bottomright="{content:`Override: ${row.value}, Setpoint Origin: ${row.item.SetPointOrigin}`, title:'Heating Override Active?'}"
                                                    >
                                                    <b-form-checkbox v-model="row.value" disabled></b-form-checkbox>
                                                </p>
                                            </template>
                                            <template slot="details" slot-scope="row" @click="row.toggleDetails">
                                                <b-form-checkbox @click.native.stop @change="row.toggleDetails"
                                                        v-model="row.detailsShowing"
                                                        v-b-popover.focus.hover.bottomright="{content:`Show Details for ${row.item.Name}`}">
                                                </b-form-checkbox>
                                            </template>
                                            <template slot="row-details" slot-scope="row">
                                                <b-card>
                                                    <b-card v-if="row.item.ControlOutputState" border-variant="light">
                                                        <b-row class="my-0">
                                                            <b-col class="text-sm-right"><b>% Demand:</b></b-col>
                                                            <b-col>{{ row.item.percentageDemand }}</b-col>
                                                        </b-row>
                                                        <b-row class="my-0">
                                                            <b-col class="text-sm-right"><b>Ctrl Output State:</b></b-col>
                                                            <b-col>{{ row.item.ControlOutputState }}</b-col>
                                                        </b-row>

                                                        <b-row class="mt-2 mb-0">
                                                            <b-col class="text-sm-right">
                                                                <b>Current/Scheduled Room Setpoint:</b>
                                                            </b-col>
                                                            <b-col>
                                                                {{ row.item.DisplayedSetPoint === -200 ? 'OFF' : (row.item.DisplayedSetPoint/10) }}°c /
                                                                {{ row.item.ScheduledSetPoint === -200 ? 'OFF' : (row.item.ScheduledSetPoint/10) }}°c
                                                            </b-col>
                                                        </b-row>
                                                        <b-row class="my-0">
                                                            <b-col class="text-sm-right">
                                                                <b>Setpoint Origin:</b>
                                                            </b-col>
                                                            <b-col>
                                                                {{ row.item.SetPointOrigin }}
                                                            </b-col>
                                                        </b-row>
                                                        <b-row class="my-0">
                                                            <b-col class="text-sm-right">
                                                                <b>Override Type:</b>
                                                            </b-col>
                                                            <b-col>
                                                                {{ row.item.OverrideType }}
                                                            </b-col>
                                                        </b-row>
                                                    </b-card>

                                                    <b-card border-variant="light" v-if="row.item.devices.length > 0">
                                                        <b-row class="my-0">
                                                            <b-col>
                                                                <h6>Room Heating Devices</h6>
                                                            </b-col>
                                                        </b-row>                                                    <b-row>
                                                        <b-row>
                                                            <b-col>
                                                                <b-table responsive flex small stacked="sm" class="my-0"
                                                                    :items="row.item.devices" :fields="hdDetailsFields">
                                                                </b-table>
                                                            </b-col>
                                                        </b-row>
                                                    </b-card>

                                                    <b-card border-variant="light" v-if="row.item.sensors">
                                                        <b-row class="my-0">
                                                            <b-col><h6>Room Sensors</h6></b-col>
                                                        </b-row>
                                                        <b-row class="my-0">
                                                            <b-col class="text-sm-right"><b>Temperature:</b></b-col>
                                                            <b-col>{{ row.item.sensors.Temperature }}°c</b-col>
                                                        </b-row>
                                                        <b-row class="my-0">
                                                            <b-col class="text-sm-right"><b>Humidity:</b></b-col>
                                                            <b-col>{{ row.item.sensors.Humidity }}%</b-col>
                                                        </b-row>
                                                        <b-row v-if="row.item.sensors.Light" class="my-0">
                                                            <b-col class="text-sm-right"><b>Light:</b></b-col>
                                                            <b-col>{{ row.item.sensors.Light }} Lux</b-col>
                                                        </b-row>
                                                    </b-card>

                                                    <b-button slot="footer" size="sm" @click="row.toggleDetails">Hide Details</b-button>
                                                </b-card>
                                            </template>
                                        </b-table>
                                    </b-card>
                                </b-col>
                            </b-row>
                        </b-tab>

                        <b-tab title="Boost">
                            Sorry, not ready yet
                        </b-tab>

                        <b-tab title="Schedules">
                            Sorry, not ready yet
                        </b-tab>

                        <b-tab title="Devices">
                            <device-tab :home-data="homeData" :devices="devices"></device-tab>
                        </b-tab>

                        <b-tab title="Help" v-b-popover.focus.hover.bottomright="{content:'Information on how to use this dashboard.'}">
                            This is a uibuilder test using <a href="http://vuejs.org/">Vue.js</a> as a front-end
                            library.
                            Along with the <a href="https://bootstrap-vue.js.org/docs/">bootstrap-vue</a> component
                            library.
                            See the
                            <a href="https://github.com/TotallyInformation/node-red-contrib-uibuilder">node-red-contrib-uibuilder</a>
                            README and WIKI for details on how to use UIbuilder.
                        </b-tab>
                    </b-tabs>
                </b-card>

                <b-row no-gutters id="footer" class="text-light p-1 bg-dark">
                    <b-col>
                        &nbsp;
                    </b-col>
                </b-row>
            </b-container>
        </div>

        <!-- These MUST be in the right order. -->
        <script src="/uibuilder/socket.io/socket.io.js"></script>
        <!---->
        <script src="./uibuilderfe.min.js"></script>
        <!-- --- Vendor Libraries - Load in the right order --- -->
        <script src="https://cdn.jsdelivr.net/npm/lodash@4/lodash.min.js"></script>
        <script src="./vendor/vue/dist/vue.js"></script>
        <script src="https://unpkg.com/babel-polyfill@latest/dist/polyfill.min.js"></script>
        <script src="./vendor/bootstrap-vue/dist/bootstrap-vue.js"></script>
        <!-- <script src="./vendor/bootstrap-vue/dist/bootstrap-vue.min.js"></script> -->
        <!-- --- Custom code goes in here --- -->
        <script src="./index.js"></script>

    </body>

</html>

Continued in next post


#12

index.js

Note here that the Vue code is really well structured once you get your head around what it looks like.

/*global document,$,window,uibuilder,Vue,_ */
/** Copyright (c) 2019 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 default, template Front-End JavaScript for uibuilder
 * It is usable as is though you will want to add your own code to
 * process incoming and outgoing messages.
 *
 * uibuilderfe.js (or uibuilderfe.min.js) exposes the following global object:
 * @see https://github.com/TotallyInformation/node-red-contrib-uibuilder/wiki/Front-End-Library---available-properties-and-methods
 **/
'use strict'

/** Get a nested property from an object without returning any errors.
 * If the property or property chain doesn't exist, undefined is returned.
 * Property names with spaces may use either dot or bracket "[]" notation.
 * Note that bracketed property names without surrounding quotes will fail the lookup.
 *      e.g. embedded variables are not supported.
 * @param {Object} obj The object to check
 * @param {string} prop The property or property chain to get (e.g. obj.prop1.prop1a or obj['prop1'].prop2)
 * @returns {*|undefined} The value of the objects property or undefined if the property doesn't exist
 */
function getProp(obj, prop) {
    if (typeof obj !== 'object') throw 'getProp: obj is not an object'
    if (typeof prop !== 'string') throw 'getProp: prop is not a string'

    // Replace [] notation with dot notation
    prop = prop.replace(/\[["'`](.*)["'`]\]/g,".$1")

    return prop.split('.').reduce(function(prev, curr) {
        return prev ? prev[curr] : undefined
    }, obj || self)
} // --- end of fn getProp() --- //

// Initialise Bootstrap-Vue: Not needed if loading via CDN
//Vue.use(BootstrapVue)

// ==== Template Components ====
/** Definitions for the tab called "Lights" */
Vue.component('lights-tab', {
    // NB: prop defined as 'home-data' because it is used as an HTML attribute. BUT use as variable 'homeData'
    props: ['home-data', 'switches'],
    template: '#lights-tab-template',
    data: function() { return {
        dtOpts: {
            timeZone: 'Europe/London',
            weekday: 'short', month: 'short', day: 'numeric',
            hour: 'numeric', minute: 'numeric',
        },
        dtFmt: 'en-GB',
    }},
    computed: {
        // orderedSwitches: function() {
        //     return _.orderBy(this.switches, 'room').filter(function (sw) {
        //         return sw.room === 'NA' ? false : true
        //     })
        // },
    },
    methods: {
        fmtTime: function(t) {
            return new Intl.DateTimeFormat(this.dtFmt, this.dtOpts).format(new Date(t))
        },
        switchClick: function(clickData) {
            let [switchId, switchStatus] = clickData
            //console.log('switchClick', switchId, switchStatus)
            uibuilder.send({
                'topic': `COMMAND/${switchId}`,
                'payload': switchStatus.toLowerCase() === 'on' ? 'Off' : 'On'
            })
        },
    },
})
/** The demand card that is shown on the Details tab */
Vue.component('demand-card', {
    props: [
        'percentage-demand', 'demand-level', 'demand-max',
        'demand-on-off-output', 'is-boosted',
    ],
    template: '#demand-card-template',
    computed: {
        classDemandActive: function() {
            return this.demandOnOffOutput === 'On' ? 'text-danger font-weight-bold': ''
        },
        isBoostedText: function() {
            return this.isBoosted ? 'On' : 'Off'
        },
        classIsBoosted: function() {
            return this.isBoosted ? 'text-danger font-weight-bold': ''
        },
    },
})
/** The device tab containing a table of device details & current status */
Vue.component('device-tab', {
    props: ['home-data', 'devices'],
    template: '#device-tab-template',
    data: function() { return {
        dtOpts: {
            timeZone: 'Europe/London',
            weekday: 'short', month: 'short', day: 'numeric',
            hour: 'numeric', minute: 'numeric',
        },
        dtFmt: 'en-GB',
    }},
    computed: {
        orderedDevices: function() {
            // Use LoDash to reorder the object
            return _.orderBy(this.devices, 'id')
        },
    },
    methods: {
        fmtTime: function(t) {
            return new Intl.DateTimeFormat(this.dtFmt, this.dtOpts).format(new Date(t))
        },
    },
})

// Initialise Vue - this is where the grunt-work all happens
new Vue({
    el: "#app",
    // We don't really need a function here but you do in components - keeping things consistent
    data: function() { return {
        // For formatting dates and times
        dtOpts: {
            timeZone: 'Europe/London',
            weekday: 'short', month: 'short', day: 'numeric',
            hour: 'numeric', minute: 'numeric',
        },
        dtFmt: 'en-GB',
        // Which tab should be active?
        tabIndex: 0,
        // heating
        lastUpdate  : '[None]',
        hTimer      : null,
        showNoUpdAlert    : false,
        demand            : undefined,
        percentageDemand  : undefined,
        demandOnOffOutput : 'N/A',
        demandMax         : 100,
        isBoosted         : false,
        homeData          : [],
        // Field definitions - @see https://bootstrap-vue.js.org/docs/components/table#field-definition-reference
        homeDataFields    : [
            {   key: 'floor',
                label: 'Floor',
                sortable: true,
                class: 'border-right text-center',
                thStyle: {width: '2em !important'},
            },
            {   key: 'Name',
                label: 'Room',
                sortable: true,
                class: 'border-right',
                // Variant applies to the whole column, including the header and footer
                //variant: 'danger'
                tdClass: (value, key, item) => {
                    const c = []

                    if ( item.ControlOutputState === 'On' ) c.push('bg-primary')
                    if ( item.outside === true )            c.push('font-italic')

                    return c.join(' ')
                },
                tdAttr: {'title':'Blue BG = Room requesting heat. Italic = room is outside'},
            },
            {   key: 'CalculatedTemperature',
                label: '°c',
                sortable: true,
                class: 'text-right border-right',
                formatter: (value, key, item) => {
                    // -200 or -32768 are unset or invalid
                    if ( value < -99 ) return ''
                    const t = (value/10).toFixed(1)
                    return isNaN(t) ? value : t
                },
                tdClass: (value, key, item) => {
                    const c = []

                    // Highlight if too cold or too hot
                    if ( item.outside === true ) {
                        // Outdoors
                        c.push('font-italic')
                        if ( value < 0 )
                            // Freezing
                            c.push('bg-danger')
                        else if ( value < 20 )
                            // <2
                            c.push('bg-warning')
                        else if ( value < 50 )
                            // <5
                            c.push(['text-white', 'bg-primary'])
                        else if ( value > 300 )
                            // >30
                            c.push('bg-warning')
                    } else {
                        // Indoors
                        if ( value < 100 )
                            // <10
                            c.push('bg-danger')
                        else if ( value > 230 )
                            // >23
                            c.push('bg-warning')
                    }

                    //if ( item.ControlOutputState === 'On' ) c.push('bg-primary')

                    return c.join(' ')
                },
                tdAttr: {'title':"Temperature. Highlighted if too high or too low."},
            },
            {   key: 'CalculatedHumidity',
                label: 'H%',
                sortable: true,
                class: 'text-right',
                formatter: (value, key, item) => {
                    let h = Math.round(value)
                    h = isNaN(h) ? value : h
                    return h === undefined ? '' : h + '%'
                },
                tdClass: (value, key, item) => {
                    const c = []
                    // Highlight if too high or too low
                    if ( item.outside === true ) {
                        // Outdoors
                        c.push('font-italic')
                        if ( value <40 ) c.push(['text-white', 'bg-primary'])
                    } else {
                        // Indoors
                        if ( value <40 ) c.push(['text-white', 'bg-primary'])
                        else if (value >60 ) c.push('bg-warning')
                    }
                    return c.join(' ')
                },
                tdAttr: {'title':"Humidity. Highlighted if too high or too low."},
            },
            // A virtual column with custom formatter
            {   key: 'override',
                label: 'O/ride',
                class: 'border-left text-right',
                thStyle: {width: '2em !important'},
                formatter: (value, key, item) => {
                    if ( item.SetPointOrigin === undefined ) return false
                    else return item.SetPointOrigin!=='FromSchedule' ? true : false
                },
            },
            {   key: 'details',
                label: 'More',
                class: 'text-right',
                thStyle: {width: '2em !important'},
            },
        ],
        // For the details view of homeData table
        hdDetailsFields: [
            'ProductType','BatteryLevel','DisplayedSignalStrength',
            {   key: 'SetPoint',
                label: 'SetPoint °c',
                class: 'text-right',
                formatter: (value, key, item) => {
                    // -200 or -32768 are unset or invalid
                    if ( value < -99 ) return ''
                    const t = (value/10).toFixed(1)
                    return isNaN(t) ? value : t
                },
            },
            {   key: 'MeasuredTemperature',
                label: 'Measured °c',
                class: 'text-right',
                formatter: (value, key, item) => {
                    // -200 or -32768 are unset or invalid
                    if ( value < -99 ) return ''
                    const t = (value/10).toFixed(1)
                    return isNaN(t) ? value : t
                },
            },
            {   key: 'MeasuredHumidity',
                label: 'Measured Humidity',
                class: 'text-right',
                formatter: (value, key, item) => {
                    return value ? (value + '%') : ''
                },
            },
        ],
        // Current Switch Settings
        switches: {},
        // Current Device statuses
        devices: {},
    }}, // --- End of data --- //
    // computed: dynamic data, used as {{ cName }} - cached
    computed: {
        // Set the FG & BG of the demand card if demand is on
        qDemandBg: function() {
            return this.demandOnOffOutput === 'On' ? 'primary': ''
        },
        qDemandFg: function() {
            return this.demandOnOffOutput === 'On' ? 'white': ''
        },
        classDemandActive: function() {
            return this.demandOnOffOutput === 'On' ? 'text-danger': ''
        },
        // colour the demand bar depending on demand level
        demandLevel: function() {
            let a = null
            switch (true) {
                case ( typeof this.percentageDemand === 'string' ):
                    a = 'dark'
                    break

                case this.percentageDemand <= 30:
                    a = 'success'
                    break

                case this.percentageDemand <= 60:
                    a = 'warning'
                    break

                default:
                    a = 'danger'
                    break
            }
            return a
        },
        // If a room has boost turned on
        isBoostedFill: function() {
            return this.isBoosted ? 'fill:#dc3545' : 'fill:#ffc107'
        },
        isBoostedText: function() {
            return this.isBoosted ? 'On' : 'Off'
        },
    }, // --- End of computed --- //
    // methods:
    methods: {
        /** Return a setInterval timer for the heating update warning
         * @callback cb setInterval function
         * @param {number} timeout The timeout to be passed to the setInterval fn. Optional, defaults to 2 minutes.
         * @returns {cb} setInterval function
         */
        heatingUpdTimer: function(timeout=120000) {
            const viewApp = this
            return setInterval(function(){
                //console.log('Vue:methods:heatingUpdTimer heating update not received in 2 minutes')
                viewApp.showNoUpdAlert = true
            }, timeout)
        },
        /** Handle row-clicked event on rooms table
         * @param {Object} item The Row data for the clicked row
         * @param {number} index The row index for the clicked row
         * @param {Object} event The click event data
         **/
        // TODO: need separate array to maintain display state
        onRoomsRowClicked (item, index, event) {
            item._showDetails = !item._showDetails
        },
        // Filter Fn for heating room table - @see https://bootstrap-vue.js.org/docs/components/table#filtering
        currentRoomsTblFilter: function(item) {
            if ( item.CalculatedTemperature ) return true
            else return false
        },

        /** Invoked when user changes tab - saves current tab - @see mounted
         * @param {number} i Selected tab index number
         */
        changeTab: function(i) {
            // Save to browser's session storage
            sessionStorage.currentTab = i
        },

        // Format date/time
        fmtTime: function(t) {
            return new Intl.DateTimeFormat(this.dtFmt, this.dtOpts).format(new Date(t))
        },

        // return formatted HTML version of JSON object
        syntaxHighlight: function(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>'
            })
        } // --- End of syntaxHighlight --- //

    }, // --- End of methods --- //

    // Available hooks: init,mounted,updated,destroyed
    mounted: function(){
        console.debug('Vue:mounted - setting up uibuilder watchers')

        // Save confusion by keeping a specific reference to this Vue app
        const vueApp = this

        // Start countdown. If lastUpdate not updated in 2 minutes, show a warning.
        vueApp.hTimer = vueApp.heatingUpdTimer()

        // On-load Reset the current tab to the one saved in session storage - strange, stored as number but retrieves as a string
        vueApp.tabIndex =  Number(sessionStorage.currentTab)

        // If msg changes - msg is updated when a standard msg is received from Node-RED over Socket.IO
        // Note that you can also listen for 'msgsReceived' as they are updated at the same time
        // but newVal relates to the attribute being listened to.
        uibuilder.onChange('msg', function(newVal){
            //console.debug('Vue:mounted:UIBUILDER: property msg changed! ', newVal)
            vueApp.msgRecvd = newVal

            // What kind of message did we receive?
            // Use getProp so we don't pollute the original input. Then tidy the topic
            let topic = getProp(newVal, 'topic').replace(/\/SWITCH..$/,'')
            if ( topic.substring(0,8) === 'DEVICES/' ) topic = 'DEVICES'
            switch (topic) {
                // Full homeDetails
                case 'Home Details':
                    console.debug('UIBUILDER:onChange:msg: homeDetails msg received ', newVal)
                    /** To update the home details, we are expecting a msg like:
                     *  msg = {
                     *      'topic'     : 'Home Details',
                     *      'payload'   : {
                     *          'homeDetails': homeDetails,  // ARRAY
                     *          'demand'    : demand,        // OBJECT
                     *          'lastUpdate': new Date(),
                     *      },
                     *  }
                     */
                    // for convenience
                    const data = newVal.payload

                    // Formatted last update
                    vueApp.lastUpdate = vueApp.fmtTime(data.lastUpdate)

                    // clear and restart countdown. If lastUpdate not updated in 2 minutes, show a warning.
                    vueApp.showNoUpdAlert = false; clearInterval(vueApp.hTimer); vueApp.hTimer = null;
                    vueApp.hTimer = vueApp.heatingUpdTimer()

                    vueApp.demand  = data.demand
                    // for convenience ...
                    vueApp.percentageDemand  = data.demand.PercentageDemand
                    vueApp.demandOnOffOutput = data.demand.DemandOnOffOutput
                    vueApp.isBoosted        = data.demand.isBoosted
                    //vueApp.qDemand = data.demand.DemandOnOffOutput === 'On' ? true : false
                    //vueApp.HeatingRelayState = data.demand.HeatingRelayState
                    //vueApp.IsSmartValvePreventingDemand = data.demand.IsSmartValvePreventingDemand

                    // Sorted array of home data
                    vueApp.homeData = data.homeDetails
                    // vvv NB: The below adds the _showDetails field TOO LATE for it to be
                    //         correctly responsive - now added at source
                    // Add _showDetails:false to all members of the array for the table display
                    //vueApp.homeData.map(item => {item._showDetails = false; return item;})

                    // TODO: Should we null/delete the newVal var? Or would that kill vueApp.homeData as well?
                    break

                // Individual switch update
                case 'COMMAND':
                    console.debug('UIBUILDER:onChange:msg: COMMAND/SWITCHnn msg received ', newVal)
                    let sw = newVal.topic.replace('COMMAND/','')
                    vueApp.switches[sw].status = newVal.payload
                    break

                // Full switch update
                case 'SWITCHES':
                    console.debug('UIBUILDER:onChange:msg: SWITCHES/+ msg received ', newVal)
                    vueApp.switches = newVal.payload
                    break

                // Individual device update
                case 'DEVICES':
                    console.debug('UIBUILDER:onChange:msg: DEVICES/+ msg received ', newVal)
                    let dev = newVal.topic.replace('DEVICES/','')
                    vueApp.devices[dev].status = newVal.payload
                    break

                // Full devices update
                case 'DEVICESFULL':
                    vueApp.devices = newVal.payload
                    break

                // Don't process default
                default:
                    //ignore
            }
        }) // ---- End of uibuilder.onChange() watcher function ---- //

    } // --- End of mounted hook --- //

}) // --- End of app1 --- //

// EOF

index.css

Notice how little CSS I need.

pre { background-color: #212121 !important; color: wheat;}
.tcentre { text-align: center; }
.uk-table th {
    position: sticky; top: 0; z-index: 1;
    background-color:black; color:#EEEEEE;
}
card-l-primary, l-primary, btn-l-primary {background-color: #73b7ff!important;}
.nodisplay { display: none; }

/* For displaying JSON data in a sensible way. See the syntaxHighlight method */
pre .string { color: orange; }
.number { color: white; }
.boolean { color: rgb(20, 99, 163); }
.null { color: magenta; }
.key { color: #069fb3;}

#13

Thank you @TotallyInformation,

What seemed so daunting at first was creating the layout. If the bootstrap-vue bolt on is essentially a template that is ready to be customized, I think I could muttle my through it between me and Google.

I just need to get some time to sit down with it and go through your Wiki step by step. I appreciate the code sample, I will take a look.


#14

Here is a screenshot of the simple Vue example from the WIKI but with the first 2 lines of the css file commented out so you can see what it looks like:

Not so good hey!

Now we add the 2 bootstrap CSS files to the html head section

<link type="text/css" rel="stylesheet" href="./vendor/bootstrap/dist/css/bootstrap.min.css" />
<link type="text/css" rel="stylesheet" href="./vendor/bootstrap-vue/dist/bootstrap-vue.css" />

And at the end, just after the vue.js line, add

<script src="https://unpkg.com/babel-polyfill@latest/dist/polyfill.min.js"></script>
<script src="./vendor/bootstrap-vue/dist/bootstrap-vue.js"></script>

You don't need the polyfill if you can exclude older browsers.

Now it looks like this - with no other changes to the code:

Still right up against the edge but already looking much nicer.

Next, wrap everything except the outer "app" div with <b-container>...<b-container>. Now everything is nicely centred.

If you wanted to, you could now apply a grid layout giving you multiple columns. Details are here:


#15

@TotallyInformation,
Thank you for the explanation.
Am I correct in saying that the path,
<script src="./vendor/bootstrap-vue/dist/bootstrap-vue.js"></script>
is the bootstrap-vue that I must download and place in a folder somewhere on the host?

If so, where should I install BS+Vue and Vue?


#16

Not quite, you can access bootstrap-vue via a CDN if you like (an internet location that serves standard libraries - details on the bootstrap-vue website). But, if you've installed it via npm to your userDir folder (usually ~/.node-red) along with vue itself, then the link given points to the version that you installed.

uibuilder takes the modules that you've listed in the settings.js file and creates URL's for them. So if you are running Node-RED on localhost with the default port, you would have a URL http://localhost:1880/vendor/bootstrap-vue/dist/bootstrap-vue.js available, this is what you are putting into the html file.

I just miss off the http://localhost:1800 part because then it doesn't matter if you take your test setup and move it to another, perhaps live/production, location. It also won't matter if you move it from a test server that doesn't have https to a live one that does. It also won't matter if you change the Node-RED url httpPrefix or change the name of the uibuilder url, the links will still work.

All of the modules you list in the settings are added as paths with the vendor prefix so that their front-end components are available to your front-end code.

When you see relative URL links in examples on the Internet, you generally see them written simply with no leading /. That works in exactly the same way but it is very easy to make a mistake and end up with the leading / which points to somewhere different. Putting ./ on the front is, in my view, a lot clearer about your intent.