Event Timeline Dashboard Graph

This is a ui_template that displays a Gantt Chart view of events using a chartjs plugin. Useful for plotting duration and frequency of events over time.

You can customize the maximum chart duration & bar colors. The chart tooltip shows the duration of each event and how long ago it occurred.

This flow pairs well with the Persist node for saving the chart state between node-red restarts. Wire the persist nodes to the Track Events node (don't wire the persist to the ChartJS Timeline node).

Event Timeline Screenshot

This flow includes these nodes:

  1. ChartJS Timeline Library - a JavaScript plugin for ChartJS for rendering timeline charts
  2. ChartJS Timeline - a UI Template node for rendering the chart in the Dashboard.
  3. Track Events - a function node which dynamically tracks events. Input message format: {topic: "Gantt Bar Series Name", payload: true|false} - the node will begin counting up time from when a true message is received until a false message is received. Events which are older than the configured chart duration are purged. Configuration options for chart duration and gantt bar colors are at the top of this node.
  4. Redraw - an inject node which fires every 1 second to redraw the chart in real time. This node is optional.
  5. Test On - Inject node which gives an example of what an input message to the Track Events node should look like when an event is started.
  6. Test Off - same as above but for stopping an event.

Limitations:

  • There is a hard coded dom id in the ui_template. If you wan to have more than one timeline chart, you need to manually change this value to something unique for each chart that you want to display.
  • The chart stops live updating if no events are currently active. I intend to fix this, but its not ready yet.
  • The tool tip behavior is hard coded in the timeline library to show duration and age
  • Chart animations had to be disabled because I could not find a simple way to send both the historical data plus new data and have the new data animate and not the historical data. This flow passes the entire data set to the chart every time for rendering (not just the new data points).
[{"id":"f7ab208f.bd123","type":"inject","z":"406fa678.1ea2b8","name":"Test On","topic":"Garage","payload":"true","payloadType":"json","repeat":"","crontab":"","once":false,"x":210,"y":320,"wires":[["5c9c18be.dde8e"]]},{"id":"4b6fa841.706688","type":"inject","z":"406fa678.1ea2b8","name":"Test Off","topic":"Garage","payload":"false","payloadType":"json","repeat":"","crontab":"","once":false,"x":210,"y":360,"wires":[["5c9c18be.dde8e"]]},{"id":"5c9c18be.dde8e","type":"function","z":"406fa678.1ea2b8","name":"Track Events","func":"var max_duration = .1;\nvar colorArray = ['#FF6633','#4D4D4D','#5DA5DA','#FAA43A','#60BD68','#F17CB0','#B2912F','#B276B2','#DECF3F','#F15854'];\n\n// If cache was passed in, then assume it came from the perist node.\n// Set it in context to restore the chart state\nif(typeof(msg.cache) === \"object\"){\n    context.set('cache',msg.cache);\n}\n\nvar cache = context.get('cache');\nif(typeof(cache) === \"undefined\"){\n    // node.warn(\"Init cache\");\n    cache = {\n        labels: [],\n        datasets: [],\n        \n    }\n}\n\nif(typeof(msg.topic) === \"string\" && typeof(msg.payload) === \"boolean\"){\n    // Begin history tracking\n    // This topic is new so init the object and push it onto the labels and datasets arrays\n    if(cache.labels.indexOf(msg.topic) == -1){\n        // node.warn(\"create new label\");\n        cache.labels.push(msg.topic);\n        \n        cache.datasets.push({\n            label: msg.topic,\n            current: false,\n            timestamp: new Date(),\n            data: []\n        });\n    }\n\n    var thisIndex = cache.labels.indexOf(msg.topic);\n    \n    // The state has changed\n    if(msg.payload != cache.datasets[thisIndex].current){\n        cache.datasets[thisIndex].current = msg.payload;\n        cache.datasets[thisIndex].timestamp = new Date();\n        \n        // Only add a new event if the state switched from false to true\n        if(msg.payload === true){\n            // add a new start/stop event to the topics history\n            var event = [new Date(), new Date(), colorArray[thisIndex]];\n            cache.datasets[thisIndex].data.push(event);\n        }\n    }\n}\n\n\ncache.datasets.forEach(function(topic){\n    // The event is still active so\n    if(topic.current === true && topic.data.length){\n        // Update the event stop time to the current time\n        topic.data[topic.data.length - 1][1] = new Date();\n    }\n    \n\n    topic.data.forEach(function(event, index, object){\n        // cleanup old history (anything with an ending time more than max_duration hours old)\n        if(new Date() - event[1] > max_duration * 3600000){\n            // Delete the event\n            object.splice(index,1);\n        }\n    });\n});\n\n\ncontext.set('cache',cache);\n\nmsg.cache = cache;\n\nreturn msg;","outputs":1,"noerr":0,"x":430,"y":340,"wires":[["fe4bdde7.da7e4"]]},{"id":"3e544d85.93acea","type":"ui_template","z":"406fa678.1ea2b8","group":"64704468.613964","name":"ChartJS Timeline Library","order":0,"width":0,"height":0,"format":"<script>\nconst helpers = Chart.helpers;\nconst isArray = helpers.isArray;\n\nvar time = {\n\t\tunits: [{\n\t\t\tname: 'millisecond',\n\t\t\tsteps: [1, 2, 5, 10, 20, 50, 100, 250, 500]\n\t\t}, {\n\t\t\tname: 'second',\n\t\t\tsteps: [1, 2, 5, 10, 30]\n\t\t}, {\n\t\t\tname: 'minute',\n\t\t\tsteps: [1, 2, 5, 10, 30]\n\t\t}, {\n\t\t\tname: 'hour',\n\t\t\tsteps: [1, 2, 3, 6, 12]\n\t\t}, {\n\t\t\tname: 'day',\n\t\t\tsteps: [1, 2, 3, 5]\n\t\t}, {\n\t\t\tname: 'week',\n\t\t\tmaxStep: 4\n\t\t}, {\n\t\t\tname: 'month',\n\t\t\tmaxStep: 3\n\t\t}, {\n\t\t\tname: 'quarter',\n\t\t\tmaxStep: 4\n\t\t}, {\n\t\t\tname: 'year',\n\t\t\tmaxStep: false\n\t\t}]\n};\n\nvar myConfig = {\n    myTime : {\n        redoLabels: false\n    },\n    position: 'bottom',\n\n    time: {\n        parser: false, // false == a pattern string from http://momentjs.com/docs/#/parsing/string-format/ or a custom callback that converts its argument to a moment\n        format: false, // DEPRECATED false == date objects, moment object, callback or a pattern string from http://momentjs.com/docs/#/parsing/string-format/\n        unit: false, // false == automatic or override with week, month, year, etc.\n        round: false, // none, or override with week, month, year, etc.\n        displayFormat: false, // DEPRECATED\n        isoWeekday: false, // override week start day - see http://momentjs.com/docs/#/get-set/iso-weekday/\n        minUnit: 'millisecond',\n\n        // defaults to unit's corresponding unitFormat below or override using pattern string from http://momentjs.com/docs/#/displaying/format/\n        displayFormats: {\n            millisecond: 'h:mm:ss.SSS a', // 11:20:01.123 AM,\n            second: 'h:mm:ss a', // 11:20:01 AM\n            minute: 'h:mm:ss a', // 11:20:01 AM\n            quarter: '[Q]Q - YYYY', // Q3\n            year: 'YYYY', // 2015        \n            hour: 'MMM D, hA', // Sept 4, 5PM\n            day: 'll', // Sep 4 2015\n            week: 'll', // Week 46, or maybe \"[W]WW - YYYY\" ?\n            month: 'MMM YYYY', // Sept 2015\n            }\n    },\n    ticks: {\n        autoSkip: false\n    }\n};\n\n\nvar myTimeScale = Chart.scaleService.getScaleConstructor('time').extend({\n\n    determineDataLimits: function() {\n        var me = this;\n        me.labelMoments = [];\n\n        // We parse all date labels here, for each entry we parse its initial and end date\n        var scaleLabelMoments = [];\n        if (me.chart.data.datasets && me.chart.data.datasets.length > 0) {\n            helpers.each(me.chart.data.datasets, function(datasets) {\n                var data = datasets.data;\n                var length = data.length;\n                for (var i = 0; i < length; i++) {\n                    // We consider 0 to have initial date\n                    var initialLabelMoment = me.parseTime(data[i][0]);\n                    // we consider 1 to have end date\n                    // TODO maybe add a check to see which one is bigger, but right now i don't know the\n                    // TODO implications off that check\n                    var finalLabelMoment = me.parseTime(data[i][1]);\n                    if (initialLabelMoment.isValid()) {\n                        if (me.options.time.round) {\n                            initialLabelMoment.startOf(me.options.time.round);\n                        }\n                        scaleLabelMoments.push(initialLabelMoment);\n                    }\n                    if (finalLabelMoment.isValid()) {\n                        if (me.options.time.round) {\n                            finalLabelMoment.startOf(me.options.time.round);\n                        }\n                        scaleLabelMoments.push(finalLabelMoment);\n                    }\n                }\n            }, me);\n\n            me.firstTick = moment.min.call(me, scaleLabelMoments);\n            me.lastTick = moment.max.call(me, scaleLabelMoments);\n        } else {\n            me.firstTick = null;\n            me.lastTick = null;\n        }\n\n        // In this case label moments are the same as scale moments because this chart only supports\n        // dates as data and not labels like normal time scale. We are doing this to keep\n        // coordination between parent(TimeScale) calls\n        me.labelMoments.push(scaleLabelMoments);\n\n        // Set these after we've done all the data\n        if (me.options.time.min) {\n            me.firstTick = me.parseTime(me.options.time.min);\n        }\n\n        if (me.options.time.max) {\n            me.lastTick = me.parseTime(me.options.time.max);\n        }\n\n        // We will modify these, so clone for later\n        me.firstTick = (me.firstTick || moment()).clone();\n        me.lastTick = (me.lastTick || moment()).clone();\n    },\n    buildLabelDiffs: function() {\n        var me = this;\n        me.labelDiffs = [];\n        var scaleLabelDiffs = [];\n        // Parse common labels once\n        if (me.chart.data.datasets && me.chart.data.datasets.length > 0) {\n            helpers.each(me.chart.data.datasets, function(datasets, datasetIndex) {\n                var data = datasets.data;\n                var length = data.length;\n                for (var i = 0; i < length; i++) {\n                    // We consider 0 to have initial date\n                    var initialLabelMoment = me.parseTime(data[i][0]);\n                    // we consider 1 to have end date\n                    // TODO maybe add a check to see which one is bigger, but right now i don't know the\n                    // TODO implications off that check\n                    var finalLabelMoment = me.parseTime(data[i][1]);\n                    var diff;\n                    if (initialLabelMoment.isValid()) {\n                        if (me.options.time.round) {\n                            diff = initialLabelMoment.diff(me.firstTick, me.tickUnit, false);\n                        }\n                        else {\n                            if (me.isInTicks(initialLabelMoment, me.tickUnit))\n                            // No floor needed since we are one of the ticks\n                                diff = initialLabelMoment.diff(me.firstTick, me.tickUnit, false);\n                            else\n                                diff = initialLabelMoment.diff(me.firstTick, me.tickUnit, true);\n                        }\n                        scaleLabelDiffs.push(diff);\n                    }\n                    if (finalLabelMoment.isValid()) {\n                        if (me.options.time.round) {\n                            // Moment doesn't round on diff anymore\n                            diff = finalLabelMoment.diff(me.firstTick, me.tickUnit, false);\n                        }\n                        else\n                        {\n                            if (me.isInTicks(finalLabelMoment, me.tickUnit))\n                            // No floor needed since we are one of the ticks\n                                diff = finalLabelMoment.diff(me.firstTick, me.tickUnit, false);\n                            else\n                                diff = finalLabelMoment.diff(me.firstTick, me.tickUnit, true);\n                        }\n                        scaleLabelDiffs.push(diff);\n                    }\n                }\n                me.labelDiffs[datasetIndex] = scaleLabelDiffs;\n                scaleLabelDiffs = [];\n            }, me);\n        }\n\n\n    },\n\n    // This function is different from parent because the second argument of the index inside the array of dates\n    // e.g [initialDate, endDate]. Since we built the diffs in date order, which means that every 2 entries in\n    // me.labelDiffs represent one set of date with initial and end dates by order.\n    getLabelDiff: function (datasetIndex, dateIndex) {\n        var me = this;\n        if (datasetIndex === null || dateIndex === null)\n            return null;\n\n        if (me.labelDiffs === undefined)\n            me.buildLabelDiffs();\n\n        if (me.labelDiffs[datasetIndex] != undefined)\n            return me.labelDiffs[datasetIndex][dateIndex];\n\n        return null;\n    },\n\n    getPixelForValue: function(value, index, datasetIndex) {\n        var me = this;\n        var offset = null;\n        if (index !== undefined && datasetIndex !== undefined) {\n            offset = me.getLabelDiff(datasetIndex, index);\n        }\n\n        if (offset === null) {\n            if (!value || !value.isValid) {\n                // not already a moment object\n                value = me.parseTime(me.getRightValue(value));\n            }\n            if (value && value.isValid && value.isValid()) {\n                offset = value.diff(me.firstTick, me.tickUnit, false);\n            }\n        }\n\n        if (offset !== null) {\n            var decimal = offset !== 0 ? offset / me.scaleSizeInUnits : offset;\n\n            if (me.isHorizontal()) {\n                var valueOffset = (me.width * decimal);\n                return me.left + Math.round(valueOffset);\n            }\n\n            var heightOffset = (me.height * decimal);\n            return me.top + Math.round(heightOffset);\n        }\n    },\n\n    // Checks if some date object is a tickMoment\n    isInTicks: function (date, unit) {\n        var result = false;\n        var length = this.tickMoments.length;\n        var ticks = this.tickMoments;\n        for(var i = 0; i < length; i++)\n        {\n            var tick = ticks[i];\n            if (date.isSame(tick, unit))\n            {\n                result = true;\n                break;\n            }\n        }\n        return result;\n    }\n});\n\n\nChart.scaleService.registerScaleType('myTime', myTimeScale, myConfig);\n\n\n\nChart.controllers.timeLine = Chart.controllers.bar.extend({\n\n    getBarBounds : function (bar) {\n        var vm =   bar._view;\n        var x1, x2, y1, y2;\n\n        x1 = vm.x;\n        x2 = vm.x + vm.width;\n        y1 = vm.y;\n        y2 = vm.y + vm.height;\n\n        return {\n            left : x1,\n            top: y1,\n            right: x2,\n            bottom: y2\n        };\n\n    },\n\n    update: function(reset) {\n        var me = this;\n        var meta = me.getMeta();\n        helpers.each(meta.data, function(rectangle, index) {\n            me.updateElement(rectangle, index, reset);\n        }, me);\n    },\n\n    updateElement: function(rectangle, index, reset) {\n        var me = this;\n        var meta = me.getMeta();\n        var xScale = me.getScaleForId(meta.xAxisID);\n        var yScale = me.getScaleForId(meta.yAxisID);\n        var dataset = me.getDataset();\n        var data = dataset.data[index];\n        var custom = rectangle.custom || {};\n        var datasetIndex = me.index;\n        var rectangleElementOptions = me.chart.options.elements.rectangle;\n\n        rectangle._xScale = xScale;\n        rectangle._yScale = yScale;\n        rectangle._datasetIndex = me.index;\n        rectangle._index = index;\n\n        var ruler = me.getRuler(index);\n\n        if (index !== 0)\n            index = index * 2;\n\n        var x = xScale.getPixelForValue(data, index , datasetIndex);\n        index++;\n        var end = xScale.getPixelForValue(data, index, datasetIndex);\n\n        var y = yScale.getPixelForValue(data, datasetIndex, datasetIndex);\n        var width = end - x;\n        var height = me.calculateBarHeight(ruler);\n        var color = me.chart.options.colorFunction(data);\n\n        // This one has in account the size of the tick and the height of the bar, so we just\n        // divide both of them by two and subtract the height part and add the tick part\n        // to the real position of the element y. The purpose here is to place the bar\n        // in the middle of the tick.\n        var boxY = y + (ruler.tickHeight / 2) - (height / 2);\n\n        // console.log(me.chart.data.labels[index] + ' box x ' + index + ' : ' + x);\n        // console.log(me.chart.data.labels[index] + ' box y ' + index + ' : ' + boxY);\n        rectangle._model = {\n            x: reset ?  x - width : x,   // Top left of rectangle\n            y: boxY , // Top left of rectangle\n            width: width,\n            height: height,\n            base: x + width,\n            backgroundColor: color,\n            borderSkipped: custom.borderSkipped ? custom.borderSkipped : rectangleElementOptions.borderSkipped,\n            borderColor: custom.borderColor ? custom.borderColor : helpers.getValueAtIndexOrDefault(dataset.borderColor, index, rectangleElementOptions.borderColor),\n            borderWidth: custom.borderWidth ? custom.borderWidth : helpers.getValueAtIndexOrDefault(dataset.borderWidth, index, rectangleElementOptions.borderWidth),\n            // Tooltip\n            label: me.chart.data.labels[index],\n            datasetLabel: dataset.label\n        };\n\n\n\n        rectangle.draw = function() {\n            var ctx = this._chart.ctx;\n            var vm = this._view;\n            ctx.fillStyle = vm.backgroundColor;\n            ctx.lineWidth = vm.borderWidth;\n            helpers.drawRoundedRectangle(ctx, vm.x, vm.y, vm.width, vm.height, 1);\n            ctx.fill();\n        };\n\n        rectangle.inXRange = function (mouseX) {\n            var bounds = me.getBarBounds(this);\n            return mouseX >= bounds.left && mouseX <= bounds.right;\n        };\n        rectangle.tooltipPosition = function () {\n            var vm = this.getCenterPoint();\n            return {\n                x: vm.x ,\n                y: vm.y\n            };\n        };\n\n        rectangle.getCenterPoint = function () {\n            var vm = this._view;\n            var x, y;\n            x = vm.x + (vm.width / 2);\n            y = vm.y + (vm.height / 2);\n\n            return {\n                x : x,\n                y : y\n            };\n        };\n\n        rectangle.inRange = function (mouseX, mouseY) {\n            var inRange = false;\n\n            if(this._view)\n            {\n                var bounds = me.getBarBounds(this);\n                inRange = mouseX >= bounds.left && mouseX <= bounds.right &&\n                    mouseY >= bounds.top && mouseY <= bounds.bottom;\n            }\n            return inRange;\n        };\n\n        rectangle.pivot();\n    },\n\n    // From controller.bar\n    getRuler: function(index) {\n        var me = this;\n        var meta = me.getMeta();\n        var yScale = me.getScaleForId(meta.yAxisID);\n        var datasetCount = me.getBarCount();\n\n        var tickHeight;\n        if (yScale.options.type === 'category') {\n            tickHeight = yScale.getPixelForTick(index + 1) - yScale.getPixelForTick(index);\n        } else {\n            // Average width\n            tickHeight = yScale.width / yScale.ticks.length;\n        }\n        var categoryHeight = tickHeight * yScale.options.categoryPercentage;\n        var categorySpacing = (tickHeight - (tickHeight * yScale.options.categoryPercentage)) / 2;\n        var fullBarHeight = categoryHeight / datasetCount;\n\n        if (yScale.ticks.length !== me.chart.data.labels.length) {\n            var perc = yScale.ticks.length / me.chart.data.labels.length;\n            fullBarHeight = fullBarHeight * perc;\n        }\n\n        var barHeight = fullBarHeight * yScale.options.barPercentage;\n        var barSpacing = fullBarHeight - (fullBarHeight * yScale.options.barPercentage);\n\n        return {\n            datasetCount: datasetCount,\n            tickHeight: tickHeight,\n            categoryHeight: categoryHeight,\n            categorySpacing: categorySpacing,\n            fullBarHeight: fullBarHeight,\n            barHeight: barHeight,\n            barSpacing: barSpacing\n        };\n    },\n\n    // From controller.bar\n    getBarCount: function() {\n        var me = this;\n        var barCount = 0;\n        helpers.each(me.chart.data.datasets, function(dataset, datasetIndex) {\n            var meta = me.chart.getDatasetMeta(datasetIndex);\n            if (meta.bar && me.chart.isDatasetVisible(datasetIndex)) {\n                ++barCount;\n            }\n        }, me);\n        return barCount;\n    },\n\n\n    // draw\n    draw: function (ease) {\n        var easingDecimal = ease || 1;\n        var i, len;\n        var metaData = this.getMeta().data;\n        for (i = 0, len = metaData.length; i < len; i++)\n        {\n            metaData[i].transition(easingDecimal).draw();\n        }\n    },\n\n    // From controller.bar\n    calculateBarHeight: function(ruler) {\n        var me = this;\n        var yScale = me.getScaleForId(me.getMeta().yAxisID);\n        if (yScale.options.barThickness) {\n            return yScale.options.barThickness;\n        }\n        return yScale.options.stacked ? ruler.categoryHeight : ruler.barHeight;\n    },\n\n    removeHoverStyle: function(e) {\n        // TODO\n    },\n\n    setHoverStyle: function(e) {\n        // TODO: Implement this\n    }\n\n});\n\n\nChart.defaults.timeLine = {\n\n    colorFunction: function() {\n        return 'black';\n    },\n\n    layout: {\n        padding: {\n            left: 5,\n            right: 5,\n            top: 0\n        }\n    },\n\n    legend: {\n        display: false\n    },\n\n    scales: {\n        xAxes: [{\n            type: 'myTime',\n            position: 'bottom',\n            gridLines: {\n                display: true,\n                offsetGridLines: true,\n                drawBorder: true,\n                drawTicks: true\n            },\n            ticks: {\n                maxRotation: 0\n            },\n            unit: 'day'\n        }],\n        yAxes: [{\n            type: 'category',\n            position: 'left',\n            barThickness : 20,\n            gridLines: {\n                display: true,\n                offsetGridLines: true,\n                drawBorder: true,\n                drawTicks: true\n            }\n        }]\n    },\n    tooltips: {\n        mode: 'single',\n        callbacks: {\n            title: function(tooltipItems, data) {\n                return data.labels[tooltipItems[0].datasetIndex];\n            },\n            label: function(tooltipItem, data) {\n                \n                return [\"Started: \" + moment(data.datasets[tooltipItem.datasetIndex].data[tooltipItem.index][0]).fromNow(),\n                    \"Duration: \" + moment(data.datasets[tooltipItem.datasetIndex].data[tooltipItem.index][1]).from(data.datasets[tooltipItem.datasetIndex].data[tooltipItem.index][0], true)\n                    ]\n            }\n        }\n    }\n};\n\n</script>","storeOutMessages":true,"fwdInMessages":true,"templateScope":"global","x":650,"y":300,"wires":[[]]},{"id":"fe4bdde7.da7e4","type":"ui_template","z":"406fa678.1ea2b8","group":"64704468.613964","name":"ChartJS Timeline","order":0,"width":"16","height":"9","format":"<script>\nvar ctx = document.getElementById(\"chartjs_timeline_1\").getContext(\"2d\");\n// console.log(scope);\n\n\nscope.chart = new Chart(ctx, {\n    type: 'timeLine',\n    options: {\n        animation: false,\n        responsive: true,\n        colorFunction: function(data){\n            // data is the dataset event point.\n            // The first and second entries are the start/stop date\n            //The third entry specifies the color to use\n            return (typeof data[2] === 'undefined' ? 'black' : data[2]);\n        }\n    },\n    data: {\n        labels: [],\n        datasets: []\n    }\n});\n\n    \n(function(scope){\n    scope.$watch('msg', function(msg) {\n        if(typeof(msg) !== \"object\") return;\n\n        if(typeof(msg.cache) === \"object\"){\n            scope.chart.config.data.labels = msg.cache.labels;\n            scope.chart.config.data.datasets = msg.cache.datasets;\n        }\n\n        //redraw\n        scope.chart.update();\n    });\n})(scope);\n\n</script>\n\n<div style=\"width: 100%;\">\n    <canvas id=\"chartjs_timeline_1\"></canvas>\n</div>","storeOutMessages":true,"fwdInMessages":true,"templateScope":"local","x":630,"y":340,"wires":[[]]},{"id":"d45adc3d.7140e","type":"inject","z":"406fa678.1ea2b8","name":"Redraw","topic":"","payload":"","payloadType":"date","repeat":"1","crontab":"","once":false,"x":200,"y":200,"wires":[["5c9c18be.dde8e"]]},{"id":"64704468.613964","type":"ui_group","z":"","name":"Default Group","tab":"ef22f042.de6a1","disp":true,"width":"16"},{"id":"ef22f042.de6a1","type":"ui_tab","z":"","name":"Home Tab","icon":"dashboard"}]

Flow Info

Created 6 years, 9 months ago
Updated 4 years, 9 months ago
Rating: 4 1

Owner

Actions

Rate:

Node Types

Core
  • function (x1)
  • inject (x3)
Other
  • ui_group (x1)
  • ui_tab (x1)
  • ui_template (x2)

Tags

  • dashboard
  • template
  • chart
  • timeline
  • gantt
  • event
  • ui_template
Copy this flow JSON to your clipboard and then import into Node-RED using the Import From > Clipboard (Ctrl-I) menu option