FlowFuse / node-red-dashboard

https://dashboard.flowfuse.com
Apache License 2.0
182 stars 46 forks source link

ui-template nodes not receiving messages after network disconnection/recovery. #747

Open colinl opened 5 months ago

colinl commented 5 months ago

Current Behavior

If the network connection between the browser and the server is broken for even a few seconds and then restored, ui-template nodes reset to their initial state and stop receiving messages. The same symptom can be seen on Android if the page is put in the background for a while, or even left in the foreground with an extended period of non-interaction with the user. I suspect Android is putting the page to sleep which effectively means that it is disconnected from the network.

Expected Behavior

The dashboard should recover after re-connection.

Steps To Reproduce

2024-04-25 Edited below to reflect slightly different symptoms with dashboard 1.8.0

With the dashboard browser on a different machine to the server, import the flow below (which is slightly extended version of the default ui-template) and deploy.
Refresh the dashboard browser. Initially the counter shows a count of 0. With the developer console open on the dashboard page, click the Inject button in the editor. The received message is shown in the developer console and the counter is incremented by 5 as expected. Disconnect the network from the machine showing the dashboard for a few seconds. A connection error is shown in the developer console and the Connection Lost popup is shown. Reconnect the network. The connection restored popup appears. Click the Inject button. No message is shown in the console. Click the Increment button on the dashboard and the counter does increment. Refreshing the browser restores a working system.

[{"id":"9ea21274b5061628","type":"inject","z":"eff7ca5fd9394ba5","name":"","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","payload":"","payloadType":"date","x":200,"y":2720,"wires":[["dec16dcbe7aa673d"]]},{"id":"dec16dcbe7aa673d","type":"ui-template","z":"eff7ca5fd9394ba5","group":"9339a318b3f1cfb9","name":"#747 test","order":0,"width":0,"height":0,"head":"","format":"<template>\n    <div>\n        <h2>Counter</h2>\n        <p>Current Count: {{ count }}</p>\n        <p class=\"my-class\">Formatted Count: {{ formattedCount }}</p>\n        <v-btn @click=\"increase()\">Increment</v-btn>\n    </div>\n</template>\n\n<script>\n    export default {\n        data() {\n            // define variables available component-wide\n            // (in <template> and component functions)\n            return {\n                count: 0\n            }\n        },\n        watch: {\n            msg: function() {\n                console.log(`msg: ${JSON.stringify(this.msg)}`)\n                this.count += 5\n            },\n            // watch for any changes of \"count\"\n            count: function () {\n                if (this.count % 5 === 0) {\n                    this.send({payload: 'Multiple of 5'})\n                }\n            }\n        },\n        computed: {\n            // automatically compute this variable\n            // whenever VueJS deems appropriate\n            formattedCount: function () {\n                return this.count + ' Apples'\n            }\n        },\n        methods: {\n            // expose a method to our <template> and Vue Application\n            increase: function () {\n                this.count++\n            }\n        },\n        mounted() {\n            // code here when the component is first loaded\n        },\n        unmounted() {\n            // code here when the component is removed from the Dashboard\n            // i.e. when the user navigates away from the page\n        }\n    }\n</script>\n<style>\n    /* define any styles here - supports raw CSS */\n    .my-class {\n        color: red;\n    }\n</style>","storeOutMessages":true,"passthru":true,"resendOnRefresh":true,"templateScope":"local","className":"","x":370,"y":2720,"wires":[[]]},{"id":"9339a318b3f1cfb9","type":"ui-group","name":"Test","page":"1d9a70bb7c3ff3f5","width":"6","height":"1","order":-1,"showTitle":false,"className":"","visible":"true","disabled":"false"},{"id":"1d9a70bb7c3ff3f5","type":"ui-page","name":"Test","ui":"04ee189a49c54f22","path":"/test","icon":"home","layout":"grid","theme":"23aa79f4e489b044","order":12,"className":"","visible":"true","disabled":"false"},{"id":"04ee189a49c54f22","type":"ui-base","name":"Dashboard","path":"/dashboard","showPathInSidebar":false,"navigationStyle":"temporary"},{"id":"23aa79f4e489b044","type":"ui-theme","name":"Theme 1","colors":{"surface":"#ffffff","primary":"#514fba","bgPage":"#eeeeee","groupBg":"#ffffff","groupOutline":"#cccccc"},"sizes":{"pagePadding":"1px","groupGap":"1px","groupBorderRadius":"4px","widgetGap":"4px"}}]

Environment

Have you provided an initial effort estimate for this issue?

I am not a FlowFuse employee

colinl commented 5 months ago

Also see possibly related issues #620 and #444 and forum discussion https://discourse.nodered.org/t/ui-template-fully-kiosk-browser-update-problem-dashboard-2/87003

colinl commented 4 months ago

I had hoped that this would be fixed by the comms enhancements in 1.8.0 but it seems not. I have updated the description to reflect the slightly changed symptoms with 1.8.0

colinl commented 3 months ago

Note that just switching pages and back again is enough restore normal operation.

cstns commented 3 months ago

I'll give this a go in the coming days

Paul-Reed commented 2 months ago

Any further forward with this issue pls, it's proving to be a pain!

cstns commented 2 months ago

I apologize, I lost track of this issue. Will try an make some headway this week!

cstns commented 2 months ago

Follow up: Haven't lost track of this issue but I'll have to postpone the investigation to next week

cstns commented 2 months ago

After a lot of poking/prodding, wild goose chases and theories, I may have found the culprit for this specific scenario

The UITemplate's anonymous component is unmounted and re-mounted on socket reconnect. The unmounted method calls this.$socket.off('msg-input:${this.id}') from line ui/src/widgets/ui-template/UITemplate.vue:184 right before calling any custom unmounted functionality that it may have been passed in.

this.$socket.off('msg-input:${this.id}') methods cuts all component reactivity from then onward.

As to why anonymous components are getting unmounted/remounted on socket disconnect is still unclear to me and would need more time to investigate while the socket.off() seems to be a good practice, and a common one across the repo.

I'll try and pick up where I left off in a couple of days time.

cstns commented 2 months ago

Fighting anonymous components reactivity on socket disconnect was a dead end, as there are a lot of factors would have side effects on the socket - anon component relationship.

Embracing side effects is the way to go. Not 100% sure if the dataTracker was intended to be bound to the parent component, but binding it to the anonymous component seems to have no side effects plus the added benefit of re connecting on component lifecycle refresh caused by the socket disconnect.

Creating a PR to address the problem

Paul-Reed commented 1 month ago

Using DB v1.14.0 this is still not working for me when viewing on a android phone with the dashboard installed as a PWA app. The below video shows a dashboard when I've allowed the screen to sleep for just 15 seconds, upon wakeup the data has reverted to zero, and can only be resumed by refreshing the screen. The data update period in 3 seconds.

https://github.com/user-attachments/assets/6b2772e4-7890-4a96-81d3-6c5c0532d59b

Example flow -

[{"id":"baeb76b9d06f520c","type":"ui-template","z":"1326aadbacf36704","group":"25f065c072b233c0","page":"","ui":"","name":"Grid (Zero Cross ver)","order":2,"width":"7","height":"1","head":"","format":"// See https://discourse.nodered.org/t/gauges-for-dashboard-2-0-made-with-ui-template/85955/59\n<template>\n    <div ref=\"hng\" :class=\"icon ? 'ag-wrapper-2' : 'ag-wrapper-1'\" :style=\"`--line-color:${colors[0]};`\">\n        <div v-if=\"icon\" class=\"ag-icon\">\n            <v-icon aria-hidden=\"false\">{{icon}}</v-icon>\n        </div>\n        <div class=\"ag-content\">\n            <div class=\"ag-text\">\n                <span class=\"ag-label\">{{label}}</span>\n                <span class=\"ag-value\">{{formattedValue}}<span class=\"ag-unit\">{{unit}}</span></span>\n            </div>\n            <div class=\"ag-track\" ref=\"agLine\">\n                <div class=\"ag-track-background\"></div>\n                <div class=\"ag-track-foreground\" :style=\"{'width': linesize +'%'}\"></div>\n            </div>\n            <div class=\"ag-limits\">\n                <span class=\"ag-min\">{{min}}</span>\n                <span class=\"ag-max\">{{max}}</span>\n            </div>\n        </div>\n    </div>\n</template>\n\n<script>\n    export default {\n    data(){\n        return {\n            //Define me here\n                                             \n            label:\"Grid\", // The label\n            icon:\"mdi-power-socket\", // (type: artless) (optional) the icon\n            zeroCross:true,// (type: artless) line changes color depending on value being positive or negative (at least 2 colors must be defined)\n            min:0, // Smallest expected value\n            max:5000, // Highest expected value\n            unit:\"W\",// The unit of the measurement           \n            animate:true, // Animating led's is not most performant thing in the world.                          \n            \n            // Define colors           \n            colors:[\n                    \"#0fb60f\",\n                    \"#0fb60f\",\n                    \"#0fb60f\",\n                    \"#0fb60f\",\n                    \"#0fb60f\",\n                    \"orange\",\n                    \"orange\",                    \n                    \"red\"\n                   ],            \n            \n            //no need to change those\n            value:0,          \n            inited:false\n        }\n    },\n\n\n   \n    methods: {        \n        getElement: function(name,base){        \n            if(base){\n                return this.$refs[name]\n            }\n            return this.$refs[name][0]\n        },\n        validate(data){\n            let ret\n            if(typeof data !== \"number\"){\n                ret = parseFloat(data)\n                if(isNaN(ret)){\n                    console.log(\"BAD DATA! gauge type:\",this.type, \"id:\",this.id,\"data:\",data)\n                    return null\n                }   \n            }\n            else{\n                ret = data\n            }            \n            return ret\n        },\n        changeLine:function(){\n            const line = this.getElement(\"agLine\",true);\n            if(!line){\n                console.log(\"no line found\")\n                return            \n            }\n           \n            let c = Math.floor(this.colors.length * this.percentage / 100)\n            if(c >= this.colors.length){\n                c = this.colors.length - 1\n            }\n            if(c < 0){\n                c = 0\n            }\n            if(this.zeroCross){\n                c = this.value > 0 ? (this.colors.length - 1) : 0\n            }\n            line.style.setProperty('--line-color',this.colors[c])\n\n        }\n    },\n       \n    watch: {\n        msg: function(){    \n            if(this.msg.payload !== undefined){  \n                const v = this.validate(this.msg.payload)                \n                if(v === null){\n                    return\n                }         \n                this.value = v\n                this.changeLine()              \n            }\n        }\n    },\n    computed: {\n        formattedValue: function () {\n            return this.value.toFixed(2)\n        },\n        percentage: function(){\n            return Math.floor(((this.value - this.min) / (this.max - this.min)) * 100);\n        },\n        linesize:function(){\n            if(this.zeroCross){\n                return Math.floor(((Math.abs(this.value) - this.min) / (this.max - this.min)) * 100);           \n            }\n            else{\n                return Math.max(0,this.percentage)\n            }\n        }\n    },\n    mounted(){\n        const line = this.getElement(\"agLine\",true);\n        line.style.setProperty('--line-color',this.colors[0])\n        if(this.animate == true){\n            if(!line){\n                console.log(\"artless init() no line found\")\n                return\n            }\n            line.style.transition = \"width 0.5s\";\n        }\n       \n        this.inited = true;\n    }\n}\n</script>","storeOutMessages":true,"passthru":true,"resendOnRefresh":true,"templateScope":"local","className":"","x":900,"y":140,"wires":[[]]},{"id":"cf31acb3987e6ed8","type":"inject","z":"1326aadbacf36704","name":"","props":[{"p":"payload"}],"repeat":"3","crontab":"","once":true,"onceDelay":0.1,"topic":"","payload":"$round($random()*19)+200","payloadType":"jsonata","x":710,"y":140,"wires":[["baeb76b9d06f520c"]]},{"id":"25f065c072b233c0","type":"ui-group","name":"gauges","page":"d6557c66b0d2c002","width":"7","height":"6","order":1,"showTitle":false,"className":"","visible":true,"disabled":"false"},{"id":"d6557c66b0d2c002","type":"ui-page","name":"Energy","ui":"ae3d4aeb3f977a90","path":"/energy","icon":"home","layout":"flex","theme":"a965ccfef139317a","order":2,"className":"","visible":true,"disabled":"false"},{"id":"ae3d4aeb3f977a90","type":"ui-base","name":"Dashboard","path":"/dashboard","includeClientData":true,"acceptsClientConfig":["ui-notification","ui-control"],"showPathInSidebar":false,"navigationStyle":"temporary"},{"id":"a965ccfef139317a","type":"ui-theme","name":"Default","colors":{"surface":"#ffffff","primary":"#0094ce","bgPage":"#eeeeee","groupBg":"#ffffff","groupOutline":"#cccccc"},"sizes":{"pagePadding":"12px","groupGap":"12px","groupBorderRadius":"4px","widgetGap":"12px"}}]

Note: I have tried deleting the app from my phone, and re-installing it, and same result.

joepavitt commented 1 month ago

Thanks Paul - have re-opened the issue and will investigate - maybe @cstns can take another look?

cstns commented 1 month ago

Is this happening anywhere else other than mobile/pwa?

colinl commented 1 month ago

I have managed to replicate the issue on Android, but not otherwise. It does not always fail, I am trying to work out what the trigger is. @Paul-Reed do you see a Disconnected, then Reconnected message when you re-activate? If you refresh the page and then let it sleep again does it still fail?

colinl commented 1 month ago

Running in Android with current main from github, using remote debugging, if I break the network connection to the server then I see

Baseline.vue:191 
 Uncaught 
TypeError: Cannot read properties of null (reading 'close')
    at Array.<anonymous> (Baseline.vue:191:30)
    at Object.emit (alerts.js:17:30)
    at Socket.<anonymous> (main.mjs:134:20)
    at Emitter.emit (index.mjs:136:20)
    at Socket.onclose (socket.js:432:14)
    at Emitter.emit (index.mjs:136:20)
    at Manager.onclose (manager.js:299:14)
    at Emitter.emit (index.mjs:136:20)
    at lt.onClose (socket.js:570:18)
    at WS.<anonymous> (socket.js:189:43)

Where Baseline.vue has

 mounted () {
        this.updateTheme()
        Alerts.subscribe((title, description, color, alertOptions) => {
            this.$refs.alert.close()                                      <-- Line 191 **********
            this.alert = alertOptions
            this.$nextTick(() => {
                this.$refs.alert.onMsgInput({
                    payload: `<h3>${title}</h3><p>${description}</p>`,
                    color
                })
            })
        })
    },

After that all is gone to pot.

joepavitt commented 1 month ago

Ooh - good find @colinl - that's odd that the alert isn't mounted though 🤔

Paul-Reed commented 1 month ago

@cstns - I've only noticed it on mobile/PWA.

@colinl - I always get the reconnecting alert, so it is reconnecting (my screen recorder didn't capture that as it's set to record the app and not the screen).

If I refresh the page, the data again flows, but yes, if I let it sleep, it almost always fails again.

colinl commented 1 month ago

I can make it fail with that error when installed as an app in Edge on a PC with the flow below. It is running the prototype of my classic gauge widget when it was implemented in a ui-template. It does not fail when not running as an app.

In fact I am not convinced that this is the same problem that @Paul-Reed is seeing as the symptom is different, but I think that if there is a repeatable error then then it is worth fixing that issue, even if one is not convinced that it is the same problem.

[{"id":"eb81f7b4e75d3b1e","type":"inject","z":"6e4dd70a4b804955","name":"","props":[{"p":"payload"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","payload":"0","payloadType":"num","x":190,"y":1660,"wires":[["6c5fe238f2739cdf"]]},{"id":"12b5d3fefe6faf5b","type":"inject","z":"6e4dd70a4b804955","name":"","props":[{"p":"payload"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","payload":"0.6","payloadType":"num","x":190,"y":1700,"wires":[["6c5fe238f2739cdf"]]},{"id":"bb5ff38c6ca6062c","type":"inject","z":"6e4dd70a4b804955","name":"","props":[{"p":"payload"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","payload":"1.4","payloadType":"num","x":190,"y":1740,"wires":[["6c5fe238f2739cdf"]]},{"id":"6c5fe238f2739cdf","type":"ui-template","z":"6e4dd70a4b804955","group":"31f2da0d04a5d5e2","page":"","ui":"","name":"Gauge template master","order":1,"width":"3","height":"3","head":"","format":"<!-- Gauge Template CDL v1.4.0\n  Based on original work by @HotNipi \n-->\n<script>\n    export default{\n        data(){\n            let data = {\n                //define settings here\n                // Min and max scale values.  Max may be less that min.\n                //min: -50,\n                //max: 50,\n                min:0,\n                max:1.4,\n                //min:0,\n                //max:-1.4,\n                majorDivision: 0.2,       // number of input units for each (numbered) major division\n                minorDivision: 0.05,       // number of input units for each minor division\n                unit:\"°C\",\n                label:\"Hot Water\",\n                measurement:\"Temperature\",\n                valueDecimalPlaces: 2,    // number of decimal places to show in the value display\n                majorDecimalPlaces: 1,    // number of decimal places to show on the scale\n                // Coloured sectors around the scale.  Sectors can be in any order and it makes no difference if \n                // start and end are reversed.\n                //  Any gaps are left at background colour\n                sectors:[{start:0,end:0.4,color:\"skyblue\"},{start:0.4,end:0.75,color:\"green\"},{start:0.75,end:1.4,color:\"red\"}],\n                //sectors:[{start:0,end:-0.4,color:\"skyblue\"},{start:-0.4,end:-0.75,color:\"green\"},{start:-0.75,end:-1.4,color:\"red\"}],\n\n                // The position and alignment of the gauge inside the 100x100 svg box for the widget can be changed by modifying the settings below\n                // The origin of the svg box is the top left hand corner. The bottom right hand corner is 100,100\n                // Obviously, if you move the gauge you may have to move the text fields also.\n                // Take care with these settings, if you put silly values in the browser showing the dashboard may lock up. If this happens,\n                // close the dashboard browser tab (which may take some time as it is locked up).\n                arc: {\n                    cx:50,              // the x and y coordinates of the centre of the gauge arc\n                    cy: 64, \n                    radius: 47.5,       // the radius of the arc\n                    startDegrees: -123, // the angle of the start and end points of the arc.  Zero is vertically up from the centre\n                    endDegrees: 123,    // +ve values are clockwise\n                },\n                //arc: {cx:50, cy: 64.383, radius: 47.5, startDegrees: -120, endDegrees:45}  //??\n                //arc: {cx:50, cy: 64.383, radius: 47.5, startDegrees: 60, endDegrees:45}  //??\n\n                //don't change this\n                value: null,\n            }\n            // calculate derived values\n            // make sure startDegrees < endDegrees, but the difference is <= 360\n            while (data.arc.startDegrees >= data.arc.endDegrees) {\n                data.arc.startDegrees -= 360\n            }\n            while (data.arc.endDegrees - data.arc.startDegrees > 360) {\n                data.arc.startDegrees += 360\n            }\n            const startRadians = data.arc.startDegrees * Math.PI/180\n            const endRadians = data.arc.endDegrees * Math.PI/180\n            data.arc.startx = data.arc.cx - data.arc.radius * Math.sin(startRadians-Math.PI)\n            data.arc.starty = data.arc.cy + data.arc.radius * Math.cos(startRadians-Math.PI)\n            data.arc.endx = data.arc.cx + data.arc.radius * Math.sin(Math.PI-endRadians)\n            data.arc.endy = data.arc.cy + data.arc.radius * Math.cos(Math.PI-endRadians)\n            data.arc.arcLength = 2 * Math.PI * data.arc.radius * (data.arc.endDegrees - data.arc.startDegrees)/360\n\n            // sanity checks - probably there should be more of these\n            this.majorDivision = this.majorDivision <= 0  ?  1  : this.majorDivision\n            this.minorDivision = this.minorDivision <= 0  ?  1  : this.minorDivision\n            //console.log({arc: data.arc})\n            return data\n        }\n    }\n</script>\n\n<template>\n    <div class=\"hn-sng\">\n        <div class=\"label\">{{label}}</div>\n        <svg ref=\"hn-gauge\" width=\"100%\" height=\"100%\" viewBox=\"0 0 100 100\">\n            <g>\n                <path v-for=\"(item, index) in sectors\" :key=\"index\" :ref=\"'sector-' + index\" class=\"sector\" stroke-width=\"5\" :d=\"arcspec\" ></path>                \n            </g>\n            <g>\n                <path class=\"tick-minor\" stroke-width=\"5\" :d=\"arcspec\" :style=\"tickStyle(this.minorDivision, 0.5)\"></path>\n                <path ref=\"arc\" class=\"tick-major\" stroke-width=\"5\" :d=\"arcspec\" :style=\"tickStyle(this.majorDivision, 1)\"></path>\n                \n            </g>         \n            <g>\n                <text v-for=\"(item, index) in numbers\" :key=\"index\" class=\"num\" text-anchor=\"middle\" :y=\"`${10.5-this.arc.radius}`\" \n                  :style=\"`rotate: ${item.r}deg; transform-origin: ${this.arc.cx}% ${this.arc.cy}%; transform: translate(${this.arc.cx}%, ${this.arc.cy}%)`\">\n                  {{item.n}}</text>\n            </g>\n            <g>\n                <text class=\"measurement\" y=\"48\" x=\"50%\" text-anchor=\"middle\">{{measurement}}</text>\n                <text class=\"unit\" y=\"75\" x=\"50%\" text-anchor=\"middle\">{{unit}}</text>\n                <text class=\"value\" y=\"90\" x=\"50%\" text-anchor=\"middle\">{{formattedValue}}</text>\n            </g>\n            <g ref=\"o-needle\" class=\"o-needle\" v-html=\"needle\">\n            </g>\n        </svg>\n    </div>\n</template>\n\n<script>\n    export default{\n        methods:{\n            getElement: function(name,base){\n                if(base){\n                    return this.$refs[name]\n                }\n                return this.$refs[name][0]\n            },\n            validate: function(data){\n                let ret                \n                if(typeof data !== \"number\"){\n                    ret = parseFloat(data)\n                    if(isNaN(ret)){\n                        console.log(\"BAD DATA! gauge id:\",this.id,\"data:\",data)\n                        ret = null\n                    }\n                }                    \n                else{\n                    ret = data\n                }                \n                return ret\n            },\n            range:function (n, p, r) {\n                // clamp n to be within input range\n                if (p.maxIn > p.minIn) {\n                    n = Math.min(n, p.maxIn)\n                    n = Math.max(n, p.minIn)\n                } else {\n                    n = Math.min(n, p.minIn)\n                    n = Math.max(n, p.maxIn)\n                }\n                if(r){\n                    return Math.round(((n - p.minIn) / (p.maxIn - p.minIn) * (p.maxOut - p.minOut)) + p.minOut);\n                }\n                return ((n - p.minIn) / (p.maxIn - p.minIn) * (p.maxOut - p.minOut)) + p.minOut;\n            },\n            generateNumbers:function(min,max,majorDivision){    \n                let minDegrees, maxDegrees, startValue\n                if (max > min) {\n                    minDegrees = this.arc.startDegrees\n                    maxDegrees = this.arc.endDegrees\n                    startValue = min    \n                } else {\n                    minDegrees = this.arc.endDegrees\n                    maxDegrees = this.arc.startDegrees\n                    startValue = max              \n                }\n                // Calculate number of major divisions, adding on a bit and rounding down in case last one is just off the end\n                const numDivs = Math.floor(Math.abs(max-min) / majorDivision + 0.1)\n                const degRange = maxDegrees-minDegrees\n                const degPerDiv = degRange * majorDivision/Math.abs(max-min)\n                let nums = []\n                for (let div=0; div<=numDivs; div++) {\n                    let degrees = div*degPerDiv + minDegrees\n                    const n = (startValue + div * majorDivision).toFixed(this.majorDecimalPlaces)\n                    nums.push({r: degrees, n: n})\n                }\n                return nums \n            },\n            sectorData:function(full){               \n                let ret = []\n                this.sectors.forEach((sector,idx) => {\n                    let sec = {name:'sector-'+idx,color:sector.color}\n                    const params = {minIn:this.min, maxIn:this.max, minOut:0, maxOut:full}\n                    const start = this.range(sector.start,params,false)\n                    const end = this.range(sector.end,params,false)\n                    const pos = Math.min(start, end)\n                    const span = Math.max(start, end) - pos\n                    sec.css = `0 ${pos} ${span} var(--dash)`\n                    ret.push(sec)\n                })\n                return ret\n            },\n            rotation:function(v){\n                // allow pointer to go 10% off ends of scale, but not more than half way to the other end of the scale\n                const deltaDeg = this.arc.endDegrees - this.arc.startDegrees\n                const gapDeg = 360 - deltaDeg\n                const overflowFactor = Math.min(0.1, gapDeg/2/deltaDeg)\n                const overflow = (this.max-this.min)*overflowFactor\n                const angleOverflow = (deltaDeg)*overflowFactor \n                const min = this.min - overflow\n                const max = this.max + overflow\n                const minAngle = this.arc.startDegrees - angleOverflow\n                const maxAngle = this.arc.endDegrees + angleOverflow\n                const params = {minIn:min, maxIn:max, minOut:minAngle, maxOut:maxAngle};\n                if (v === null) {\n                    v = Math.min(min, max)\n                }\n                return `${this.range(v,params,false)}deg`\n            },\n            tickStyle: function(division, width) {\n                // division is the number of input units per tick\n                // width is the width (length?) of the tick in svg units\n\n                // total arc length in svg units\n                const arcLength = this.arc.arcLength\n                // length in user units\n                const range = Math.abs(this.max - this.min)\n                const tickPeriod = division/range * arcLength\n                // marker is width wide, so gap is tickPeriod-width\n                // stroke-dashoffset sets the first tick to half width\n                return `stroke-dasharray: ${width} ${tickPeriod-width}; stroke-dashoffset: ${width/2};`\n            },\n        },\n        watch: {\n            msg: function(){\n                // allow undefined payload through as it will show the invalid data state\n                const v = this.validate(this.msg.payload)                   \n                // v is null if payload is invalid, this is coped with then it is displayed\n                this.value = v\n                this.getElement('o-needle',true).style.rotate = this.rotation(this.value)\n            }\n        },\n        computed: {\n            needle: function() {\n                const cx = this.arc.cx\n                const cy = this.arc.cy\n                const length = this.arc.radius - 4.5\n                return `<path d=\"M ${cx},${cy} ${cx-1.5},${cy} ${cx-0.15},${cy-length} ${cx+0.15},${cy-length} ${cx+1.5},${cy} z\"></path> \n                  <circle cx=\"${this.arc.cx}\" cy=\"${this.arc.cy}\" r=\"3\"></circle>`\n            },\n            arcspec: function() {\n                const delta = this.arc.endDegrees - this.arc.startDegrees\n                // if more than 180 deg sweep then large-arg-flag should be 1\n                const largeArcFlag = delta > 180  ?  1  :  0\n\n                return `M ${this.arc.startx} ${this.arc.starty} A ${this.arc.radius} ${this.arc.radius} 0 ${largeArcFlag} 1 ${this.arc.endx} ${this.arc.endy}`\n            },\n            formattedValue: function () {\n                // Show --- for the value until a valid value is recevied\n                return this.value !== null  ?  this.value.toFixed(this.valueDecimalPlaces)  :  \"---\"\n            },\n            numbers:function(){\n                return this.generateNumbers(this.min,this.max,this.majorDivision)\n            },\n        },\n        mounted(){\n           \n            const dal = this.getElement('arc',true).getTotalLength()\n            const sec = this.sectorData(dal)              \n            const gauge = this.getElement('hn-gauge',true)\n            gauge.style.setProperty('--dash',dal)\n            sec.forEach(s =>{\n                const sector = this.getElement(s.name,false)\n                sector.style.setProperty(\"stroke-dasharray\",s.css)\n                sector.style.setProperty(\"stroke\",s.color)\n            })\n            // set the needle centre of rotation\n            this.getElement('o-needle',true).style[\"transform-origin\"] = `${this.arc.cx}% ${this.arc.cy}%`\n            // initialise the needle off the bottom\n            this.getElement('o-needle',true).style.rotate = this.rotation(null) \n        }\n\n    }\n</script>\n\n<style>\n    .hn-sng{\n        position:relative;\n    }\n    .hn-sng .label{\n        position:absolute;\n        font-size:1rem;\n        color:currentColor;\n        text-align:center;\n        width:100%;\n        overflow: hidden;\n        white-space: nowrap;\n        text-overflow: ellipsis;\n    }\n    .hn-sng .value {\n        fill:currentColor;\n    }\n    .hn-sng .unit {\n        fill:currentColor;\n        font-size:0.4rem;\n    }\n    .hn-sng .measurement {\n        fill:currentColor;\n        font-size:0.5rem;\n    }\n    .hn-sng .num{\n        fill:currentColor;\n        fill-opacity:0.6;\n        font-size:.35rem;\n    }\n    .hn-sng .tick-minor{\n        fill:none;\n        stroke:currentColor;\n        stroke-opacity:0.6;\n    }\n    .hn-sng .tick-major{\n        fill:none;\n        stroke:currentColor;\n    }\n    .hn-sng .sector{        \n        fill:none;\n        stroke:transparent;      \n    } \n    .hn-sng .o-needle{        \n        transition:.5s;\n    }\n    .hn-sng .o-needle path, .hn-sng .o-needle circle{\n        fill:black;\n    }\n    \n</style>","storeOutMessages":true,"passthru":true,"resendOnRefresh":true,"templateScope":"local","className":"Gauge template master","x":510,"y":1700,"wires":[[]]},{"id":"31f2da0d04a5d5e2","type":"ui-group","name":"Group 2","page":"2290baf8322d936b","width":"6","height":"1","order":1,"showTitle":true,"className":"","visible":"true","disabled":"false"},{"id":"2290baf8322d936b","type":"ui-page","name":"d2 P2","ui":"04ee189a49c54f22","path":"/d2p2","icon":"home","layout":"grid","theme":"f9b6670b127dc219","order":6,"className":"","visible":"true","disabled":"false"},{"id":"04ee189a49c54f22","type":"ui-base","name":"Dashboard","path":"/dashboard","includeClientData":true,"acceptsClientConfig":["ui-control","ui-notification"],"showPathInSidebar":false,"navigationStyle":"temporary"},{"id":"f9b6670b127dc219","type":"ui-theme","name":"FlowForge Theme","colors":{"surface":"#152a47","primary":"#005aff","bgPage":"#ffffff","groupBg":"#ffffff","groupOutline":"#cc3e3e"},"sizes":{"pagePadding":"12px","groupGap":"12px","groupBorderRadius":"4px","widgetGap":"12px"}}]
colinl commented 1 month ago

I think it must be something to do with the template contents as I have other templates that do not give the problem. Or perhaps it is some sort of race condition so it varies unpredictably. Are others able to see the problem with the flow I posted?