FlowFuse / node-red-dashboard

https://dashboard.flowfuse.com
Apache License 2.0
204 stars 48 forks source link

WebSocket Connection Issues #784

Open joepavitt opened 6 months ago

joepavitt commented 6 months ago

Description

Using this as a centralised store for websocket connection-related problems.

Have you provided an initial effort estimate for this issue?

I have provided an initial effort estimate

joepavitt commented 6 months ago

When we lost connection between the client and server, we don't make any attempts to re-connect, nor do we inform the user of a lost connection.

omrid01 commented 6 months ago

Adding a scenario - when the user deploys or restarts the flows on the editor, the dashboard clients realize it and try to re-render, but the 'onSocket' listeners on the server node stop receiving custom client messages. reloading the client browser (F5) fixes the issue. The temp work around I currently implement, is upon startup, the server node waits 200 ms and then sends a special message to all clients which tells them to reload themselves.

Paul-Reed commented 6 months ago

I'm assuming this is the same issue, but thought you may wish to consider PWA when fixing.

EDIT 27/4 This appears to be covered by https://github.com/FlowFuse/node-red-dashboard/issues/747 as after further checks it only affects template nodes, and not the default palette nodes.

Saved as an App (PWA) on a android phone, the data stream is updated every 5 seconds. At 7 seconds in the video, I restart node-RED which produces a 'connection lost' message as expected. When it 'reconnects' at 12 seconds, all of the values then show zero, and remain at zero, despite node-RED updating normally (I'm also watching the dashboard on a laptop)

Dashboard v1.8.1

https://github.com/FlowFuse/node-red-dashboard/assets/973580/22ae5cad-54bc-47c0-8913-55b7c4378103

colinl commented 6 months ago

@joepavitt do you want ongoing issues over connections in here, or in individual issues? I would vote for putting everything related here as several of the issues are related. Except maybe the ui-template issue which it appears is not directly related.

I find that if, on a PC, I open the dashboard and disconnect the network and leave it for 30 mins (not sure what the min to show the problem is) then it never reconnects when I connect the network again, it just sits there with Connection Lost popup. I see the same on Android sometimes when I re-open the dashboard window after it has been in the background for some time. I suspect it is the same issue.

joepavitt commented 6 months ago

Each in a new issue, them link them here so I can add to the list please.

I need to double check, but I believe that's expected. @Steve-Mcl recommended I add a "give up trying to connect" timeout, and I believe I set it to 30 minutes

joepavitt commented 6 months ago

Actually, I chose 5 minutes:

https://github.com/FlowFuse/node-red-dashboard/blob/main/ui%2Fsrc%2Fmain.mjs#L97

colinl commented 6 months ago

See issue #810

colinl commented 6 months ago

Also #811

colinl commented 6 months ago

When it 'reconnects' at 12 seconds, all of the values then show zero, and remain at zero, despite node-RED updating normally

@Paul-Reed I am not seeing that running on Android with it saved as an app. Does it happen with text nodes? Can you build a simple flow that shows the problem so I can test it.

Paul-Reed commented 6 months ago

@colinl In fact, this only occurs when using template nodes. I'm only using template nodes for gauges etc, but I've just run a test by adding the default palette gauge, chart & text nodes instead, and they do re-connect OK.

Template Example;

[{"id":"d9f948314558a48c","type":"ui-template","z":"be4568a984cb4685","group":"48fc19d1161f11e6","page":"","ui":"","name":"Diverted","order":3,"width":"0","height":"0","head":"","format":"// See https://discourse.nodered.org/t/gauges-for-dashboard-2-0-made-with-ui-template/85955\n<template>\n    <div>\n        <template v-if=\"type === 'round'\">\n            <div ref=\"hng\" class=\"round-led-level\" :style=\"`--size:${size}; --shadow:${shadow}; --ledsize:${ledSize};`\">\n                <header>\n                    <div class=\"round-led-level-text\">\n                        <span class=\"round-led-level-label\">{{label}}</span>\n                    </div>\n                </header>\n                <div class=\"round-led-level-graph\">\n                    <div class=\"round-led-level-stripe\" :class=\"{'led-level-flat': flat }\">\n                        <div v-for=\"(color, index) in colors\" :key=\"index\" class=\"round-led-level-led\"\n                            :ref=\"'dot-' + index\">\n                        </div>\n                    </div>\n                    <div class=\"round-led-level-centered-text\">\n                        <span class=\"round-led-level-value\">{{formattedValue}}</span>\n                        <span class=\"round-led-level-unit\">{{unit}}</span>\n                    </div>\n                    <div class=\"round-led-level-limits\">\n                        <span>{{min}}</span>\n                        <span>{{max}}</span>\n                    </div>\n                </div>\n                <div>\n        </template>\n        <template v-if=\"type === 'linear'\">\n            <div ref=\"hng\" class=\"led-level\" :style=\"`--shadow:${shadow};`\">\n                <div class=\"led-level-text\">\n                    <span class=\"led-level-label\">{{label}}</span>\n                    <span class=\"led-level-value\">{{formattedValue}}<span class=\"led-level-unit\">{{unit}}</span></span>\n                </div>\n                <div class=\"led-level-stripe\" :class=\"{'led-level-flat': flat }\">\n                    <div v-for=\"(color, index) in colors\" :key=\"index\" class=\"led-level-led\" :ref=\"'dot-' + index\">\n                    </div>\n                </div>\n                <div class=\"led-level-limits\">\n                    <span>{{min}}</span>\n                    <span>{{max}}</span>\n                </div>\n                <div>\n        </template>\n        <template v-if=\"type === 'vertical'\">\n            <div ref=\"hng\" class=\"led-level-vertical\" :style=\"`--shadow:${shadow}; --size:${size};`\">\n                <header>\n                    <div class=\"round-led-level-text\">\n                        <span class=\"round-led-level-label\">{{label}}</span>\n                    </div>\n                </header>\n                <div class=\"led-level-vertical-content\">\n                    <div class=\"led-level-stripe\" :class=\"{'led-level-flat': flat }\">\n                        <div v-for=\"(color, index) in colors\" :key=\"index\" class=\"led-level-led\" :ref=\"'dot-' + index\">\n                        </div>\n                    </div>\n                    <div class=\"led-level-limits\">\n                        <span>{{max}}</span>\n                        <span>{{min}}</span>\n                    </div>\n                    <div class=\"round-led-level-centered-text\">\n                        <span class=\"round-led-level-value\">{{formattedValue}}</span>\n                        <span class=\"round-led-level-unit\">{{unit}}</span>\n                    </div>\n                </div>\n            </div>\n\n        </template>\n        <template v-if=\"type === 'artless'\">\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': percentage +'%'}\"></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    </div>\n</template>\n\n\n\n<script>\n    export default {\n    data(){\n        return {\n            //Define me here\n            type:\"artless\", // Gauge type. \"artless\", \"linear\", \"vertical\" or \"round\"                                          \n            label:\"Diverted\", // The label\n            icon:\"mdi-electric-switch\", // (type: artless) (optional) the icon\n            min:0, // Smallest expected value\n            max:100, // Highest expected value\n            unit:\"W\",// The unit of the measurement\n            dim:0.3, //(type: round, linear, vertical) How dim is led when not glowing\n            shadow:0, //(type: round, linear, vertical) Led shadow intensity (too much makes graphics muddy, 0 removes shadows)\n            filterFunction:\"brightness\", // (type: round, linear, vertical) \"brightness\" for dark themes, \"opacity\" for light themes  \n            animate:true, // Animating led's is not most performant thing in the world.                          \n            \n            // Define colors\n\n            // For type \"round\", \"vertical\" and \"linear\" the count of colors equals count of led's.\n            // For type \"artless\" the line changes color based on percentage of value turned index of colors array.  \n            // For type \"round\" the led size depends on how many colors is defined. About 20 is optimal.\n            // Color can be defined as:\n            // HEX - \"#FF00FF\" \n            // RGB - rgb(0,65,88)\n            // named color - \"red\"\n            // or depend on some defined CSS variable      \n            colors:[\"#0fb60f\",\n                    \"#0fb60f\",\n                    \"#0fb60f\",\n                    \"#0fb60f\",\n                    \"#0fb60f\",\n                    \"#0fb60f\",\n                    \"#0fb60f\",\n                    \"#0fb60f\",\n                    \"#0fb60f\",\n                    \"#0fb60f\",\n                    \"#0fb60f\",\n                   ],            \n            \n            //no need to change those\n            value:0,\n            previousValue:0,\n            size:100,           \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\n        full: function(){\n            return Math.floor(this.colors.length*this.percentage/100)\n        },\n        half: function (){\n            let p = this.colors.length*this.percentage/100;\n            p -= this.full()\n            p *= .5\n            p += this.dim;\n            return p;\n        },\n        filter: function(amount){\n            let f\n            switch(amount){\n                case \"full\":{\n                    f = this.filterFunction == \"brightness\" ? \"brightness(1.1)\" : \"opacity(1)\";\n                    break\n                }\n                case \"half\":{\n                    f = this.filterFunction == \"brightness\" ? \"brightness(\" +this.half()+\")\" : \"opacity(\" +this.half()+\")\";\n                    break\n                }\n                default:{\n                    f = this.filterFunction == \"brightness\" ? \"brightness(\" +this.dim+\")\"  : \"opacity(\" +this.dim+\")\";\n                    break\n                }\n            }\n            return f\n        },\n\n        lit: function(){\n            if(this.inited == false){\n                return\n            }\n            const down = this.previousValue > this.value\n\n            let time = .01            \n            this.colors.forEach((color,i) => {\n                let dot = this.getElement(\"dot-\"+i);\n                if(!dot){\n                    console.log(\"lit() no dots found\")\n                    return\n                }                \n                if(i<this.full()){\n                    dot.style.filter=this.filter(\"full\");                   \n                }\n                else if(i==this.full()){\n                    dot.style.filter= this.filter(\"half\");                   \n                }\n                else{\n                    dot.style.filter= this.filter(\"dim\");                   \n                }\n                if(down){\n                    time = (this.colors.length - i) * .12                    \n                }else{\n                    time = i * 0.08\n                }\n                dot.style.transition = this.animate ? \"filter \"+time+\"s\" : \"unset\";\n            })\n            this.previousValue = this.value\n        },\n        changeLine:function(){\n            const line = this.getElement(\"agLine\",true);\n            if(!line){\n                console.log(\"no line found\")\n                return            \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            line.style.setProperty('--line-color',this.colors[c])\n\n        },\n        onResize:function(){            \n            let g = this.getElement(\"hng\",true)\n            if(!g){\n                return\n            }           \n            this.$nextTick(() => {\n                let last = this.size \n                let changed = this.type == \"vertical\" ? g.clientHeight : g.clientWidth;   \n                if(Math.abs(last - changed) < 3){\n                    return\n                }\n                this.size = changed\n                g.style.setProperty('--size',this.size);            \n                if(this.type == \"round\"){\n                    this.updateLayout()\n                }\n            })\n                              \n        },\n        updateLayout:function(){\n            let angle;\n            const step = 270 / this.colors.length;\n            const radius = (this.size - (this.size*0.1))/2\n            const s = this.ledSize / -2;\n            const outline = this.filterFunction == \"opacity\" ? \"black\" : \"white\";        \n            this.colors.forEach((c,i) => {\n                    let dot = this.getElement(\"dot-\"+i);\n                    if(!dot){\n                        console.log(\"round init()  no dots found\")\n                        return\n                    }\n                    dot.style.backgroundColor = c\n                    dot.style.outlineColor=\"color-mix(in srgb, \"+c+\", \"+outline+\" 35%)\";\n                    dot.style.transition = \"filter 0.1s\";\n                    dot.style.setProperty('--dot',i);\n                    angle = ((i+1)*step) * Math.PI / 180;\n                    dot.style.left = s + radius * Math.cos(angle) + 'px';\n                    dot.style.top = s + radius * Math.sin(angle) + 'px';\n                    dot.style.transform = 'translate('+s+'px, '+s+'px)'; \n                    dot.style.rotate = (angle - 0.08)+\"rad\"               \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                if(this.type != \"artless\"){\n                    this.lit()\n                }\n                else{\n                    this.changeLine()\n                } \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        ledSize:function(){\n            const s = 4.71239 * ((this.size - (this.size*0.3))/2)            \n            return s / this.colors.length\n        },\n        flat:function(){\n            return this.shadow == 0\n        }\n\n    },\n    mounted(){\n        const outline = this.filterFunction == \"opacity\" ? \"black\" : \"white\";\n        if(this.type == \"round\"){\n            let g = this.getElement(\"hng\",true)\n            if(!g){\n                return\n            }            \n            this.resizeObserver = new ResizeObserver((entries) => {\n                this.onResize()\n            });\n            this.resizeObserver.observe(g);           \n            \n            setTimeout(()=>{\n                this.onResize()\n            },20)\n        }\n        else if(this.type == \"linear\"){\n            \n            this.colors.forEach((c,i) => {\n                    let dot = this.getElement(\"dot-\"+i);\n                    if(!dot){\n                        console.log(\"linear init() no dots found\")\n                        return\n                    }\n                    dot.style.outlineColor=\"color-mix(in srgb, \"+c+\", \"+outline+\" 35%)\";\n                    dot.style.backgroundColor = c               \n                    dot.style.transition = \"filter 0.1s\";\n                }\n            )\n        }\n        else if(this.type == \"vertical\"){\n            let g = this.getElement(\"hng\",true)\n            if(!g){\n                return\n            }\n            this.resizeObserver = new ResizeObserver((entries) => {\n                    this.onResize()\n                });\n                this.resizeObserver.observe(g);                \n                setTimeout(()=>{\n                this.onResize()\n            },20)\n            this.colors.forEach((c,i) => {\n                let dot = this.getElement(\"dot-\"+i);\n                if(!dot){\n                    console.log(\"linear init() no dots found\")\n                    return\n                }\n                dot.style.outlineColor=\"color-mix(in srgb, \"+c+\", \"+outline+\" 35%)\";\n                dot.style.backgroundColor = c\n                dot.style.transition = \"filter 0.1s\";\n            })\n        }\n        else if(this.type == \"artless\"){\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       \n        this.inited = true;\n    },\n    unmounted () {\n        if(this.resizeObserver){\n            this.resizeObserver.disconnect();\n            this.resizeObserver = null;            \n        }\n    }\n}\n</script>","storeOutMessages":true,"passthru":true,"resendOnRefresh":true,"templateScope":"local","className":"","x":660,"y":1620,"wires":[[]]},{"id":"f0c817fab51fe05a","type":"inject","z":"be4568a984cb4685","name":"","props":[{"p":"payload"}],"repeat":"2","crontab":"","once":false,"onceDelay":0.1,"topic":"","payload":"$floor($random() * 100) + 1","payloadType":"jsonata","x":430,"y":1620,"wires":[["d9f948314558a48c"]]},{"id":"48fc19d1161f11e6","type":"ui-group","name":"Test","page":"19eb6d108e9275e2","width":"20","height":"13","order":-1,"showTitle":true,"className":"","visible":"true","disabled":"false"},{"id":"19eb6d108e9275e2","type":"ui-page","name":"Examples","ui":"ae3d4aeb3f977a90","path":"/examples","icon":"","layout":"notebook","theme":"a965ccfef139317a","order":-1,"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"}}]
colinl commented 6 months ago

I'm only using template nodes

That is covered, at least partly, by #747.

joepavitt commented 6 months ago

I'm only using template nodes for gauges etc, but I've just run a test by adding the default palette gauge, chart & text nodes instead, and they do re-connect OK.

Our challenge here is that when we re-connect, the client requests the latest ui-config, which details the widget types, etc. to render. With ui-template, it includes Vue Component to render. With. the received ui-config, we then re-render the ui-template with whatever is received. It's difficult to do a diff/comparison here, and then only assign widget.component if something has changed, but it's the best bet I have I think to fixing this problem.

colinl commented 5 months ago

On my laptop, if I suspend the laptop for some time, then when I resume I see the connection lost message on permanent display. The connection is not restored.

image

It says it is attempting to reconnect but appears not to be. Refreshing the page recovers the situation.

colinl commented 4 months ago

620 and #444 have been fixed and closed.

This leaves just ui-template issues #619 and #747 which appear to be duplicates, even though I submitted them both myself.

colinl commented 4 months ago

I have closed #619 as a duplicate. This leaves only #747. It would be good to get that fixed as then, as far as I know, all the reconnection issues will be sorted.

joepavitt commented 4 months ago

Thanks Colin, #747 is going to be a beast, as it's more linked to Vue internals than the socket/connection pieces. I have had a look in the not-so-distant past, but didn't make much headway. May get @cstns to put some eyes on it and see if he can view things differently to myself

colinl commented 4 months ago

Does the fact that it recovers if one switches to a different page and back again help? Perhaps there is at least a simple workaround there. On reconnection do whatever happens when one switches to a page.

joepavitt commented 4 months ago

On reconnection do whatever happens when one switches to a page.

The reason this works is that it re-renders the entire template, which we can't trigger without a page refresh

colinl commented 4 months ago

Understood.