UIBuilder button

@TotallyInformation
Hi Julian
in this post - New and Modern Dashboards - #2 by Steve-Mcl
You show this button panel


How are those button made - I'm looking for some "toogle" buttons that I can put in a grid, With a name and a status, just like show here.
Need maybe 50 buttons, and I will think that it's easier to control the layout i UIBuilder, that DB2

Hi Ivan.

Those are actually HTML <article> tags, not buttons. :smiley:

They are arranged on a grid layout - or flex, can't remember without looking. Ah, OK, a grid, yes.

.status-grid {
    --status-grid-min: 14em;
    --status-grid-max: 1fr;
    --status-grid-gap: .5rem;
    grid-template-columns: repeat(auto-fit,minmax(var(--status-grid-min),var(--status-grid-max)));
    gap: var(--status-grid-gap);
    list-style-position: inside;
    display: grid;
}

Each entry is defined like this:

<a href="https://192.168.1.1/" target="_blank" class="status-link">
    <div id="st-router" class="box flex surface4" data-topic="telegraf/ping/router.knightnet.co.uk" title="MQTT Topic: telegraf/ping/router.knightnet.co.uk">
        <div class="success status-side-panel" title="Last update: 2025-09-05T14:24:04.624Z" data-updated="2025-09-05T14:24:04.624Z" data-updatedby="monitor"></div>
        <div>
            <div>Router</div>
            <div class="text-smaller">192.168.1.1</div>
        </div>
    </div>
</a>

All of the classes you see mentioned are already pre-defined in UIBUILDER's uib-brand.css stylesheet. For example:

.status-side-panel {
    border-radius: 9999px;
    width: .5rem;
    height: 100%;
    background-color: var(--surface1);
}

Which gives you the thin vertical line to the left of the card where the background colour is overridden by the success or error classes as needed.

I believe that I have previously published something here in the forum with a flow that generates the entire page using no-code/low-code outputs from the flow.

The lower (1) group creates the layout and basic list/card framework. (2) forms a set of MQTT listeners that are passed to the no-code update node and on to the page via a cache so that the latest data is always available for new connections.

The whole thing uses a retained global context variable that defines everything that I want to monitor along with descriptions and, where appropriate, links to the device/service dedicated web page.

Let me know if you want a copy of the flow.

Of course, if the info you want to display rarely changes, you wouldn't necessarily go to the trouble of setting up a complex flow like that, you could simply manually create the page and then just send updates that change the status colour.

Yes, please! I think @Paul-Reed has made these into Buttons in his PWA flow, but not sure!

OK, here is an html fragment that you can use in a route.

<style>   
    #current-weather {
        display:grid;
        grid-template-areas: 
            "wind temp summary"
            "wind temp .";
        grid-template-columns: 1fr 1fr 1fr;
        gap: 1em;

        & .wind-display {
            grid-area: wind;
            background: var(--surface2);
            transition: all 0.3s ease;
            display: flex;
            justify-content: center;
            align-items: center;

            & #weather-rose {
                width: auto;
                height: 100%;
                max-height: 15em;
                margin: 0;
                padding: 0;
            }

            /* Beaufort Scale Color Classes - put here to give specificity 0,2,0 */
            &.beaufort-0 { background: linear-gradient(135deg, hsl(180, 30%, 85%, 0.4), var(--surface2)); } /* Calm - Light gray */
            &.beaufort-1 { background: linear-gradient(135deg, hsl(210, 40%, 75%, 0.4), var(--surface2)); } /* Light air - Light blue */
            &.beaufort-2 { background: linear-gradient(135deg, hsl(200, 50%, 70%, 0.4), var(--surface2)); } /* Light breeze - Blue */
            &.beaufort-3 { background: linear-gradient(135deg, hsl(190, 60%, 65%, 0.4), var(--surface2)); } /* Gentle breeze - Light blue */
            &.beaufort-4 { background: linear-gradient(135deg, hsl(120, 60%, 60%, 0.4), var(--surface2)); } /* Moderate breeze - Green */
            &.beaufort-5 { background: linear-gradient(135deg, hsl(80, 70%, 55%, 0.4), var(--surface2)); } /* Fresh breeze - Light green */
            &.beaufort-6 { background: linear-gradient(135deg, hsl(45, 80%, 60%, 0.4), var(--surface2)); } /* Strong breeze - Yellow/Orange */
            &.beaufort-7 { background: linear-gradient(135deg, hsl(25, 85%, 65%, 0.4), var(--surface2)); } /* Near gale - Orange */
            &.beaufort-8 { background: linear-gradient(135deg, hsl(10, 80%, 70%, 0.4), var(--surface2)); } /* Gale - Red/Orange */
            &.beaufort-9 { background: linear-gradient(135deg, hsl(320, 70%, 65%, 0.4), var(--surface2)); } /* Severe gale - Purple */
            &.beaufort-10 { background: linear-gradient(135deg, hsl(280, 75%, 60%, 0.4), var(--surface2)); } /* Storm - Dark purple */
            &.beaufort-11 { background: linear-gradient(135deg, hsl(260, 80%, 55%, 0.4), var(--surface2)); } /* Violent storm - Violet */
            &.beaufort-12 { background: linear-gradient(135deg, hsl(240, 90%, 50%, 0.4), var(--surface2)); } /* Hurricane - Blue */

        }

        & .temperature-display {
            grid-area: temp;
            display: flex;
            flex-direction: column;
            justify-content: center;
            align-items: center;
            background: var(--surface2);
            transition: all 0.3s ease;

            /* Temperature color classes - applied directly to background - placed here to give specifity 0,2,0 */
            &.temp-very-cold { background: linear-gradient(135deg, hsl(240, 80%, 60%, 0.3), var(--surface2)); }
            &.temp-cold { background: linear-gradient(135deg, hsl(220, 70%, 70%, 0.3), var(--surface2)); }
            &.temp-cool { background: linear-gradient(135deg, hsl(200, 60%, 75%, 0.3), var(--surface2)); }
            &.temp-mild { background: linear-gradient(135deg, hsl(120, 50%, 70%, 0.3), var(--surface2)); }
            &.temp-warm { background: linear-gradient(135deg, hsl(60, 80%, 70%, 0.3), var(--surface2)); }
            &.temp-hot { background: linear-gradient(135deg, hsl(30, 85%, 65%, 0.3), var(--surface2)); }
            &.temp-very-hot { background: linear-gradient(135deg, hsl(0, 80%, 70%, 0.3), var(--surface2)); }

            & .temp-main {
                font-size: 4rem;
                font-weight: 700;
                color: var(--text1);
                line-height: 1;
                font-variant-numeric: tabular-nums;
                margin-bottom: 0.5rem;
            }
            
            & .temp-feels-like {
                font-size: 1.125rem;
            }
        }

        & .current-summary {
            grid-area: summary;
            display: grid;
            grid-template-areas:
                "icon main"
                "icon description";
            grid-template-columns: 6em auto;
            grid-template-rows: 1.2em auto;
            gap: 0.5em;

            & img {
                margin: 0;
                background: linear-gradient(135deg, var(--text1), var(--surface4));
            }

            & #current-icon {
                grid-area: icon;
                background-color: var(--surface4);
            }

            & #current-main {
                grid-area: main;
            }

            & #current-description {
                grid-area: description;
            }
        }
        
    }

    #last-updated {
        font-size: small;
    }

    #precipHourPlot {
        display: flex;
        gap: 1rem;
        align-items: flex-start;
        padding: 0;
        margin: 0;

        & svg {
            padding: 0;
            margin: 0;
        }
    }

    /* Responsive adjustments */
    @media (max-width: 480px) {
        .temperature-display {
            padding: 1.5rem 2rem;
        }
        
        .temp-main {
            font-size: 3rem;
        }
        
        .temp-feels-like {
            font-size: 1rem;
        }
    }
</style>

<article><h2>Current Weather <span id="last-updated"></span></h2>
    <div id="current-weather">
        <div class="wind-display">
            <svg id="weather-rose"  style="--max-width: 25%; background: transparent;" viewBox="0 0 400 400" xmlns="http://www.w3.org/2000/svg"
                xmlns:bx="https://boxy-svg.com">
                <defs></defs>
                <g id="compass@cx_move">
                    <g transform="matrix(1, 0, 0, 1, 0.000006, 0)" bx:origin="0.5 0.535722">
                        <path
                            d="M 351.501 200 C 351.501 283.672 283.672 351.501 200 351.501 C 116.328 351.501 48.499 283.672 48.499 200 C 48.499 116.328 116.328 48.499 200 48.499 C 283.672 48.499 351.501 116.328 351.501 200 Z M 200 70.355 C 126.79 70.355 67.441 128.399 67.441 200 C 67.441 271.601 126.79 329.645 200 329.645 C 273.21 329.645 332.559 271.601 332.559 200 C 332.559 128.399 273.21 70.355 200 70.355 Z"
                            style="fill: none; stroke: rgb(255, 255, 255);"></path>
                        <path d="M 199.737 82.012 L 218.363 25.186 L 181.112 25.186 L 199.737 82.012 Z"
                            style="fill: rgb(255, 0, 0); stroke: rgb(255, 255, 255);"
                            bx:shape="triangle 181.112 25.186 37.251 56.826 0.5 0 1@f0735005"></path>
                    </g>
                </g>
                <text id="WS@cx_status"
                    style="fill: rgb(255, 255, 255); font-family: &quot;Agency FB&quot;; font-size: 20px; font-weight: 700; text-anchor: middle; white-space: pre;"
                    transform="matrix(6.652061, 0, 0, 5.619027, -893.371887, -830.482849)" x="164.307" y="190.806"
                >
                    00
                </text>
                <!-- Wind Speed Unit - Center Bottom -->
                <text id="wind-unit"
                    style="fill: rgb(255, 255, 255); font-family: Arial, sans-serif; font-size: 32px; font-weight: 600; text-anchor: middle;"
                    x="200" y="290"
                >
                    m/s
                </text>
                <!-- Cardinal Direction Indicators -->
                <!-- North -->
                <text style="fill: rgb(255, 255, 255); font-family: Arial, sans-serif; font-size: 24px; font-weight: 700; text-anchor: middle;"
                    x="200" y="35">N</text>
                <!-- South -->
                <text style="fill: rgb(255, 255, 255); font-family: Arial, sans-serif; font-size: 24px; font-weight: 700; text-anchor: middle;"
                    x="200" y="375">S</text>
                <!-- East -->
                <text style="fill: rgb(255, 255, 255); font-family: Arial, sans-serif; font-size: 24px; font-weight: 700; text-anchor: middle;"
                    x="370" y="210">E</text>
                <!-- West -->
                <text style="fill: rgb(255, 255, 255); font-family: Arial, sans-serif; font-size: 24px; font-weight: 700; text-anchor: middle;"
                    x="30" y="210">W</text>
                <!-- Wind Direction - Bottom Left -->
                <text id="wind-direction"
                    style="fill: rgb(255, 255, 255); font-family: Arial, sans-serif; font-size: 28px; font-weight: 600; text-anchor: start;"
                    x="20" y="380"
                >
                    --°
                </text>
                <!-- Wind Gust Speed - Bottom Right -->
                <text id="wind-gust"
                    style="fill: rgb(255, 255, 255); font-family: Arial, sans-serif; font-size: 28px; font-weight: 600; text-anchor: end;"
                    x="380" y="380"
                >
                    -- gust
                </text>
            </svg>
        </div>
        <div class="temperature-display">
            <h3>Temperature</h3>
            <div class="temp-main" id="current-temp" aria-label="Current temperature">--°</div>
            <div class="temp-feels-like">
                <!-- NB: Filter fns must be available BEFORE the component is rendered -->
                feels like <uib-var variable="weather.current.feels_like" filter="Math.round" aria-label="Feels like temperature">--</uib-var>°C
            </div>
        </div>
        <div class="current-summary">
            <div id="current-icon">
                <img id="current-weather-icon" src="" alt="Current weather icon">
            </div>
            <div id="current-main">
                <uib-var variable="weather.current.weather.0.main">--</uib-var>
            </div>
            <div id="current-description" aria-label="Current weather description">
                <uib-var variable="weather.current.weather.0.description">--</uib-var>
            </div>
        </div>
    </div>
</article>

<article><h2>Precipitation</h2>
    <div id="precipHourPlot"></div>
    <div id="precipHourEChart"></div>
</article>

<article style="--max-width: 25%;"><h2>Weather AI</h2>
    <p>Opens a new page where you can ask about the weather.</p>
    <p>
        <a id="weatherai" href="" title="Waiting for API key">Go to Weather AI</a>
    </p>
</article>

<!-- <article>
    <syntax-highlight id="sh1"></syntax-highlight>
</article> -->

Continued in next post as it is too long for a single post. :frowning:

Continuation of html from last post:

<script>
    /* global Plot, sh1, SVGAnimate, echarts, showOverlay */
    // NOTE: The SVGAnimate class & ObservableHQ Plot are loaded in index.html. sh1 is an id'd element
    // NOTE: ECharts library should be loaded for plotHourPrecip2 function to work

    if (!window.weatherSetup) { // Only run once each page load
        window.weatherSetup = {
            /** Chart: Precipitation over the next hour (using ObservableHQ Plot) */
            plotHourPrecip: () => {
                if (!window.weatherSetup.hourPrecipPlotData) return
                console.log('Plotting precipitation for next hour using Observable Plot')

                const hourPrecipPlotData = window.weatherSetup.hourPrecipPlotData

                // Find the maximum precipitation value for y-axis scaling
                const maxPrecipitation = Math.max(...hourPrecipPlotData.map(d => d.cumulative))
                const maxPrecipPlus = maxPrecipitation * 1.1
                const zoneOpacity = 0.1
    
                // Find the closest x point to the current time
                const now = new Date()
                // Find the closest index
                let closestIndex = 0
                let minDiff = Infinity
                for (let i = 0; i < hourPrecipPlotData.length; i++) {
                    const diff = Math.abs(hourPrecipPlotData[i].dt.getTime() - now.getTime())
                    if (diff < minDiff) {
                        minDiff = diff
                        closestIndex = i
                    }
                }
                // Only show highlight if closestIndex is not zero
                const showHighlight = closestIndex !== 0

                const PlotPrecipHour = Plot.plot({
                    height: 250,
                    width: undefined, // Let Observable Plot use responsive sizing
                    style: { width: '100%', }, // Ensure the SVG takes full width
                    grid: true,
                    x: {
                        type: 'time',
                        label: 'Time',
                    },
                    y: {
                        axis: 'right',
                        // grid: true,
                        nice: true,
                        label: 'Cumulative Precipitation for Next Hour (mm)',
                        domain: [0, maxPrecipitation > 0 ? maxPrecipPlus : 1], // Ensure domain from 0 to max, minimum of 1 for visibility
                        range: [250 - 30, 30], // Use full height (250px minus margins) for better scaling
                    },
                    marks: [
                        // Background warning zones for precipitation levels
                        // Green zone: 0-20mm (Light)
                        Plot.rect([{}], {
                            x1: hourPrecipPlotData[0]?.dt,
                            x2: hourPrecipPlotData[hourPrecipPlotData.length - 1]?.dt,
                            y1: 0,
                            y2: 20,
                            fill: 'hsl(120, 50%, 95%)', // Very light green
                            fillOpacity: zoneOpacity,
                        }),
                        // Yellow zone: 20-40mm (Moderate)
                        Plot.rect([{}], {
                            x1: hourPrecipPlotData[0]?.dt,
                            x2: hourPrecipPlotData[hourPrecipPlotData.length - 1]?.dt,
                            y1: 20,
                            y2: 40,
                            fill: 'hsl(60, 80%, 85%)', // Light yellow
                            fillOpacity: zoneOpacity,
                        }),
                        // Orange zone: 40-60mm (Heavy)
                        Plot.rect([{}], {
                            x1: hourPrecipPlotData[0]?.dt,
                            x2: hourPrecipPlotData[hourPrecipPlotData.length - 1]?.dt,
                            y1: 40,
                            y2: 60,
                            fill: 'hsl(30, 90%, 80%)', // Light orange
                            fillOpacity: zoneOpacity,
                        }),
                        // Red zone: 60mm+ (Extreme)
                        Plot.rect([{}], {
                            x1: hourPrecipPlotData[0]?.dt,
                            x2: hourPrecipPlotData[hourPrecipPlotData.length - 1]?.dt,
                            y1: 60,
                            y2: Math.max(maxPrecipPlus, 80), // Extend to chart top or 80mm minimum
                            fill: 'hsl(0, 80%, 85%)', // Light red
                            fillOpacity: zoneOpacity,
                        }),
                        // Horizontal reference lines for warning thresholds
                        Plot.ruleY([20], { stroke: 'hsl(60, 70%, 50%)', strokeWidth: 1.5, strokeOpacity: 0.7, }),
                        Plot.ruleY([40], { stroke: 'hsl(30, 80%, 45%)', strokeWidth: 1.5, strokeOpacity: 0.7, }),
                        Plot.ruleY([60], { stroke: 'hsl(0, 70%, 50%)', strokeWidth: 1.5, strokeOpacity: 0.7, }),

                        // Threshold labels
                        Plot.text(['Moderate'], { x: hourPrecipPlotData[0]?.dt, y: 20, dx: 5, dy: -5, fontSize: 10, fill: 'hsl(60, 70%, 30%)', fontWeight: 'bold', }),
                        Plot.text(['Heavy'], { x: hourPrecipPlotData[0]?.dt, y: 40, dx: 5, dy: -5, fontSize: 10, fill: 'hsl(30, 80%, 25%)', fontWeight: 'bold', }),
                        Plot.text(['Extreme'], { x: hourPrecipPlotData[0]?.dt, y: 60, dx: 5, dy: -5, fontSize: 10, fill: 'hsl(0, 70%, 30%)', fontWeight: 'bold', }),

                        Plot.text([`${maxPrecipitation > 0 ? '' : 'No precipitation in the next hour'}`], { lineWidth: 30, frameAnchor: 'middle', }),

                        // Vertical bar chart - using rect for time-based data
                        Plot.rect(hourPrecipPlotData, {
                            x1: (d, i) => new Date(d.dt.getTime() - 30000), // Start 30 seconds before
                            x2: (d, i) => new Date(d.dt.getTime() + 30000), // End 30 seconds after
                            y1: 0,
                            y2: 'precipitation',
                            fill: 'aliceblue',
                        }),

                        // Line plot
                        Plot.lineY(hourPrecipPlotData, { x: 'dt', y: 'cumulative', stroke: 'steelblue', }),
                        Plot.dot(hourPrecipPlotData, { x: 'dt', y: 'cumulative', fill: 'steelblue', }),
                        // Highlight closest x point to now (if not zero index)
                        ...(showHighlight
                            ? [
                                Plot.ruleX([hourPrecipPlotData[closestIndex].dt], {
                                    stroke: 'hsl(0, 80%, 50%)',
                                    strokeWidth: 3,
                                    strokeOpacity: 0.9,
                                    ariaLabel: 'Current time',
                                })
                            ]
                            : []
                        ),

                        Plot.crosshair(hourPrecipPlotData, { x: 'dt', y: 'cumulative', }),
                        Plot.ruleX(hourPrecipPlotData, Plot.pointerX({ x: 'dt', y: 'cumulative', stroke: 'brown', })),
                        // Plot.dot(plotData, Plot.pointerX({ x: 'dt', y: 'precipitation', stroke: 'red', })),
                        Plot.text(hourPrecipPlotData, Plot.pointerX({
                            px: 'dt', py: 'precipitation', dy: -17, frameAnchor: 'top-left', fontVariant: 'tabular-nums',
                            text: d => [
                                `Date ${d.dt.toLocaleDateString()} ${d.dt.toLocaleTimeString()}`,
                                `Precipitation ${d.precipitation.toFixed(2)}`,
                                `Cumulative ${d.cumulative.toFixed(2)}`
                            ].join('   '),
                        }))
                    ],
                })
                const precipHourPlot = document.getElementById('precipHourPlot')
                if (precipHourPlot) {
                    // replace rather than append the new plot
                    precipHourPlot.innerHTML = ''
                    precipHourPlot.append(PlotPrecipHour)
                }
            },

            /** Chart: Precipitation over the next hour (using Apache ECharts) */
            plotHourPrecip2: () => {
                if (!window.weatherSetup.hourPrecipPlotData) return

                const hourPrecipPlotData = window.weatherSetup.hourPrecipPlotData

                // Find the maximum precipitation value for y-axis scaling
                const maxPrecipitation = Math.max(...hourPrecipPlotData.map(d => d.cumulative))
                const maxPrecipPlus = maxPrecipitation * 1.1

                // Find the closest x point to the current time
                const now = new Date()
                let closestIndex = 0
                let minDiff = Infinity
                for (let i = 0; i < hourPrecipPlotData.length; i++) {
                    const diff = Math.abs(hourPrecipPlotData[i].dt.getTime() - now.getTime())
                    if (diff < minDiff) {
                        minDiff = diff
                        closestIndex = i
                    }
                }

                // Prepare data for ECharts
                const times = hourPrecipPlotData.map(d => d.dt.toISOString())
                const precipitationData = hourPrecipPlotData.map(d => d.precipitation)
                const cumulativeData = hourPrecipPlotData.map(d => d.cumulative)

                // Create ECharts configuration
                const options = {
                    title: {
                        text: 'Precipitation Forecast (ECharts)',
                        left: 'center',
                        textStyle: {
                            fontSize: 16,
                        },
                    },
                    tooltip: {
                        trigger: 'axis',
                        axisPointer: {
                            type: 'cross',
                        },
                        formatter: (params) => {
                            const time = new Date(params[0].axisValue)
                            let result = `<strong>${time.toLocaleString()}</strong><br/>`
                            params.forEach((param) => {
                                result += `${param.seriesName}: ${param.value.toFixed(2)} mm<br/>`
                            })
                            return result
                        },
                    },
                    legend: {
                        data: ['Individual Precipitation', 'Cumulative Precipitation'],
                        top: 30,
                    },
                    grid: {
                        left: '3%',
                        right: '4%',
                        bottom: '3%',
                        containLabel: true,
                    },
                    xAxis: {
                        type: 'time',
                        name: 'Time',
                        axisLabel: {
                            formatter: (value) => {
                                const date = new Date(value)
                                return `${date.getHours().toString()
                                    .padStart(2, '0')}:${date.getMinutes().toString()
                                    .padStart(2, '0')}`
                            },
                        },
                    },
                    yAxis: {
                        type: 'value',
                        name: 'Precipitation (mm)',
                        max: maxPrecipitation > 0 ? maxPrecipPlus : 1,
                        axisLabel: {
                            formatter: '{value} mm',
                        },
                    },
                    series: [
                        {
                            name: 'Individual Precipitation',
                            type: 'bar',
                            data: times.map((time, index) => [time, precipitationData[index]]),
                            itemStyle: {
                                color: 'rgba(173, 216, 230, 0.8)',
                            },
                            barWidth: '60%',
                        },
                        {
                            name: 'Cumulative Precipitation',
                            type: 'line',
                            data: times.map((time, index) => [time, cumulativeData[index]]),
                            lineStyle: {
                                color: '#4682B4',
                                width: 2,
                            },
                            itemStyle: {
                                color: '#4682B4',
                            },
                            symbol: 'circle',
                            symbolSize: 4,
                        },
                    ],
                    // Add warning zone visual map
                    visualMap: {
                        show: false,
                        pieces: [
                            { min: 0, max: 20, color: 'rgba(144, 238, 144, 0.1)', }, // Light green
                            { min: 20, max: 40, color: 'rgba(255, 255, 0, 0.1)', }, // Yellow
                            { min: 40, max: 60, color: 'rgba(255, 165, 0, 0.1)', }, // Orange
                            { min: 60, max: 1000, color: 'rgba(255, 0, 0, 0.1)', }, // Red
                        ],
                        seriesIndex: [1], // Apply to cumulative line series
                    },
                }

                // Add current time marker if not at zero point
                if (closestIndex !== 0) {
                    options.series.push({
                        name: 'Current Time',
                        type: 'line',
                        data: [[times[closestIndex], 0], [times[closestIndex], maxPrecipPlus]],
                        lineStyle: {
                            color: 'hsl(0, 80%, 50%)',
                            width: 3,
                            type: 'solid',
                        },
                        symbol: 'none',
                        silent: true,
                        animation: false,
                    })
                }

                // Get or create the chart container
                const chartContainer = document.getElementById('precipHourEChart')
                if (!chartContainer) {
                    showOverlay({
                        heading: 'precipHourEChart not found',
                        content: 'The chart container for the ECharts precipitation plot is missing. Please ensure the element with id="precipHourEChart" exists in the HTML.',
                        type: 'error',
                        autoClose: null,
                    })
                    // chartContainer = document.createElement('div')
                    // chartContainer.id = 'precipHourEChart'
                    // chartContainer.style.width = '100%'
                    // chartContainer.style.height = '300px'
                    // chartContainer.style.marginTop = '2rem'
    
                    // const precipArticle = document.querySelector('article:has(#precipHourPlot)')
                    // if (precipArticle) {
                    //     precipArticle.appendChild(chartContainer)
                    // }
                }

                // Initialize or update the chart
                if (typeof echarts !== 'undefined') {
                    console.log('ECharts', chartContainer, echarts)
                    const chart = echarts.init(chartContainer, null, {
                        renderer: 'svg',
                        height: 400,
                    })
                    chart.setOption(options)
    
                    // Make chart responsive
                    window.addEventListener('resize', () => {
                        chart.resize()
                    })
                } else {
                    showOverlay({
                        heading: 'ECharts library not found',
                        content: 'The ECharts library is not loaded. Please ensure that the ECharts library is included in the HTML.',
                        type: 'error',
                        autoClose: null,
                    })
                    chartContainer.innerHTML = '<p>ECharts library not loaded</p>'
                }
            },

            doWeather: (msg) => {
                console.log('doWeather', msg)

                // Save to uibuilder managed variable for easier access
                uibuilder.set('weather', msg.payload)

                if (msg.payload && msg.apikey) {
                    const weatherai = document.getElementById('weatherai')
                    weatherai.href = `https://openweathermap.org/weather-assistant?apikey=${msg.apikey}`
                    weatherai.title = 'Go to Weather AI'
                }

                // #region --- Current Weather ---

                if (msg.payload.lastUpdate !== undefined) {
                    const lastUpdatedEl = document.getElementById('last-updated')
                    if (lastUpdatedEl) lastUpdatedEl.textContent = `(${new Date(msg.payload.lastUpdate).toLocaleString()})`
                }

                // Update temperature displays
                if (msg.payload.current.temp !== undefined) {
                    const currentTempEl = document.getElementById('current-temp')
                    const temperatureDisplay = document.querySelector('.temperature-display')
    
                    // Format temperature with degree symbol
                    window.formatTemp = temp => `${Math.round(temp)}°`
    
                    const currentTemp = msg.payload.current.temp
    
                    if (currentTempEl) currentTempEl.textContent = window.formatTemp(currentTemp)
    
                    // Apply temperature-based color class
                    if (temperatureDisplay) {
                        // Remove existing temperature classes
                        temperatureDisplay.classList.remove('temp-very-cold', 'temp-cold', 'temp-cool', 'temp-mild', 'temp-warm', 'temp-hot', 'temp-very-hot')
    
                        // Add appropriate class based on temperature (assuming Celsius)
                        if (currentTemp < -10) {
                            temperatureDisplay.classList.add('temp-very-cold')
                        } else if (currentTemp < 0) {
                            temperatureDisplay.classList.add('temp-cold')
                        } else if (currentTemp < 10) {
                            temperatureDisplay.classList.add('temp-cool')
                        } else if (currentTemp < 20) {
                            temperatureDisplay.classList.add('temp-mild')
                        } else if (currentTemp < 30) {
                            temperatureDisplay.classList.add('temp-warm')
                        } else if (currentTemp < 40) {
                            temperatureDisplay.classList.add('temp-hot')
                        } else {
                            temperatureDisplay.classList.add('temp-very-hot')
                        }
                    }
                }

                if (msg.payload.current.wind_speed !== undefined) {
                    // Animate the wind compass rose
                    window.svgAnimate.animateObject({
                        x: 0,
                        y: 0,
                        deg: msg.payload.current.wind_deg,
                        pivot: [0.5, 0.53859],
                    }, 'compass@cx_move')
                    // Format wind speed to zero decimal places
                    const formattedWindSpeed = Math.round(msg.payload.current.wind_speed).toString()
                    window.svgAnimate.animateObject(formattedWindSpeed, 'WS@cx_status')

                    // Update wind direction and gust information
                    const windDirectionEl = document.getElementById('wind-direction')
                    const windGustEl = document.getElementById('wind-gust')
                    const windDisplay = document.querySelector('.wind-display')
    
                    if (windDirectionEl && msg.payload.current.wind_deg !== undefined) {
                        windDirectionEl.textContent = `${Math.round(msg.payload.current.wind_deg)}°`
                    }
    
                    if (windGustEl && msg.payload.current.wind_gust !== undefined) {
                        windGustEl.textContent = `${Math.round(msg.payload.current.wind_gust)} gust`
                    } else if (windGustEl) {
                        // Show no gust if data is unavailable
                        windGustEl.textContent = '-- gust'
                    }
    
                    // Apply Beaufort scale color based on wind speed
                    if (windDisplay && msg.payload.current.wind_speed !== undefined) {
                        const windSpeed = msg.payload.current.wind_speed // Assuming m/s
    
                        // Remove existing Beaufort classes
                        windDisplay.classList.remove('beaufort-0', 'beaufort-1', 'beaufort-2', 'beaufort-3',
                            'beaufort-4', 'beaufort-5', 'beaufort-6', 'beaufort-7', 'beaufort-8',
                            'beaufort-9', 'beaufort-10', 'beaufort-11', 'beaufort-12')
    
                        // Determine Beaufort scale and apply appropriate class
                        let beaufortScale
                        if (windSpeed < 0.3) {
                            beaufortScale = 0 // Calm
                        } else if (windSpeed < 2) {
                            beaufortScale = 1 // Light air
                        } else if (windSpeed < 3) {
                            beaufortScale = 2 // Light breeze
                        } else if (windSpeed < 5) {
                            beaufortScale = 3 // Gentle breeze
                        } else if (windSpeed < 8) {
                            beaufortScale = 4 // Moderate breeze
                        } else if (windSpeed < 11) {
                            beaufortScale = 5 // Fresh breeze
                        } else if (windSpeed < 14) {
                            beaufortScale = 6 // Strong breeze
                        } else if (windSpeed < 17) {
                            beaufortScale = 7 // Near gale
                        } else if (windSpeed < 21) {
                            beaufortScale = 8 // Gale
                        } else if (windSpeed < 24) {
                            beaufortScale = 9 // Severe gale
                        } else if (windSpeed < 28) {
                            beaufortScale = 10 // Storm
                        } else if (windSpeed < 33) {
                            beaufortScale = 11 // Violent storm
                        } else {
                            beaufortScale = 12 // Hurricane
                        }

                        windDisplay.classList.add(`beaufort-${beaufortScale}`)
                    }
                }

                if (msg.payload.current.weather[0].icon) {
                    const iconCode = msg.payload.current.weather[0].icon
                    const iconUrl = `https://openweathermap.org/img/wn/${iconCode}@2x.png`
                    const iconImg = document.getElementById('current-weather-icon')
                    iconImg.src = iconUrl
                }
                // #endregion --- Current Weather ---

                // Find first precipitation occurrence
                // const precipitationInfo = document.getElementById('precipitation-info')
                // const firstPrecipEntry = msg.payload.minutely.find((m, index) => (m.precipitation || 0) > 0)
                // if (firstPrecipEntry) {
                //     const firstPrecipIndex = msg.payload.minutely.findIndex((m, index) => (m.precipitation || 0) > 0)
                //     const minutesUntilPrecip = firstPrecipIndex + 1 // +1 because index starts at 0 but we want "in 1 minute" not "in 0 minutes"
                //     if (precipitationInfo) {
                //         precipitationInfo.textContent = `Precipitation expected in ${minutesUntilPrecip} minute${minutesUntilPrecip === 1 ? '' : 's'}`
                //     }
                // } else {
                //     if (precipitationInfo) {
                //         precipitationInfo.textContent = 'No precipitation in the next hour'
                //     }
                // }

                // Create a precipitation plot using minutely data
                window.weatherSetup.hourPrecipPlotData = msg.payload.minutely.map((m, index) => {
                    // Calculate cumulative precipitation up to this point
                    const cumulativePrecipitation = msg.payload.minutely
                        .slice(0, index + 1)
                        .reduce((sum, item) => sum + (item.precipitation || 0), 0)
    
                    return {
                        dt: new Date(m.dt * 1000), // Convert Unix timestamp to Date
                        precipitation: m.precipitation,
                        cumulative: cumulativePrecipitation,
                    }
                })
                window.weatherSetup.plotHourPrecip()
                window.weatherSetup.plotHourPrecip2()

                // Update highlight once per minute
                if (!window.weatherSetup.precipPlotInterval) {
                    window.weatherSetup.precipPlotInterval = setInterval(() => {
                        window.weatherSetup.plotHourPrecip()
                    }, 60000)
                }

                // Modern browsers auto-create an object for any element with an id!
                // sh1.json = msg.payload
            },
        }

        // Listen for weather updates from Node-RED
        uibuilder.onTopic('weather', window.weatherSetup.doWeather)

        // Initialize the SVG animation object - based on https://discourse.nodered.org/t/live-data-on-top-of-changing-picture/76128
        window.svgAnimate = new SVGAnimate('weather-rose')
    }
</script>

You might get an error about a showOverlay function. This is going into uibuilder v7.5.0. If you want to try it sooner, here is the definition, simply add it to your index.js file (in next post).

You also need to load the chart libraries in index.html:

    <!-- #region Supporting Scripts. These MUST be in the right order. Note no leading / -->
    <script defer src="https://cdn.jsdelivr.net/npm/d3@7"></script>
    <script defer src="https://cdn.jsdelivr.net/npm/@observablehq/plot@0.6"></script>
    <script defer src="https://cdn.jsdelivr.net/npm/echarts/dist/echarts.min.js"></script>
    <script defer src="../uibuilder/uibuilder.iife.min.js"></script>
    <script defer src="../uibuilder/utils/uibrouter.iife.min.js"></script>
    <script defer src="./index.js"></script>
    <script defer src="./svg-animate.mjs"></script>
    <!-- #endregion -->

Oh, and you need the svg-animate.mjs file - in the next but 1 post (again!)

showOverlay function:

/** Creates and displays an overlay window with customizable content and behavior
 * @param {object} options - Configuration options for the overlay
 *   @param {string} options.content - Main content (text or HTML) to display
 *   @param {string} [options.title] - Optional title above the main content
 *   @param {string} [options.icon] - Optional icon to display left of title (HTML or text)
 *   @param {string} [options.type] - Overlay type: 'success', 'info', 'warning', or 'error'
 *   @param {boolean} [options.showDismiss] - Whether to show dismiss button (auto-determined if not set)
 *   @param {number|null} [options.autoClose] - Auto-close delay in seconds (null for no auto-close)
 *   @param {boolean} [options.time] - Show timestamp in overlay (default: true)
 * @returns {object} Object with close() method to manually close the overlay
 */
function showOverlay(options = {}) {
    const {
        content = '',
        title = '',
        icon = '',
        type = 'info',
        showDismiss,
        autoClose = 5,
        time = true,
    } = options

    const overlayContainerId = 'uib-info-overlay'

    // Get or create the main overlay container
    let overlayContainer = document.getElementById(overlayContainerId)
    if (!overlayContainer) {
        overlayContainer = document.createElement('div')
        overlayContainer.id = overlayContainerId
        document.body.appendChild(overlayContainer)
    }

    // Generate unique ID for this overlay entry
    const entryId = `overlay-entry-${Date.now()}-${Math.random().toString(36)
        .substr(2, 9)}`

    // Create individual overlay entry
    const overlayEntry = document.createElement('div')
    overlayEntry.id = entryId
    overlayEntry.style.marginBottom = '0.5rem'

    // Define type-specific styles
    const typeStyles = {
        info: {
            iconDefault: 'ℹ️',
            titleDefault: 'Information',
            color: 'hsl(188.2deg 77.78% 40.59%)',
        },
        success: {
            iconDefault: '✅',
            titleDefault: 'Success',
            color: 'hsl(133.7deg 61.35% 40.59%)',
        },
        warning: {
            iconDefault: '⚠️',
            titleDefault: 'Warning',
            color: 'hsl(35.19deg 84.38% 62.35%)',
        },
        error: {
            iconDefault: '❌',
            titleDefault: 'Error',
            color: 'hsl(2.74deg 92.59% 62.94%)',
        },
    }

    const currentTypeStyle = typeStyles[type] || typeStyles.info

    // Determine if dismiss button should be shown
    const shouldShowDismiss = showDismiss !== undefined ? showDismiss : (autoClose === null)

    // Create content HTML
    const iconHtml = icon || currentTypeStyle.iconDefault
    const titleText = title || currentTypeStyle.titleDefault

    // Generate timestamp if time option is enabled
    let timeHtml = ''
    if (time) {
        const now = new Date()
        const year = now.getFullYear()
        const month = String(now.getMonth() + 1).padStart(2, '0')
        const day = String(now.getDate()).padStart(2, '0')
        const hours = String(now.getHours()).padStart(2, '0')
        const minutes = String(now.getMinutes()).padStart(2, '0')
        const seconds = String(now.getSeconds()).padStart(2, '0')
        const timestamp = `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
        timeHtml = `<div class="uib-overlay-time" style="font-size: 0.8em; color: var(--text3, #999); margin-left: auto; margin-right: ${shouldShowDismiss ? '0.5rem' : '0'};">${timestamp}</div>`
    }

    overlayEntry.innerHTML = /* html */ `
        <div class="uib-overlay-entry" style="--callout-color:${currentTypeStyle.color};">
            <div class="uib-overlay-header">
                <div class="uib-overlay-icon">${iconHtml}</div>
                <div class="uib-overlay-title">${titleText}</div>
                ${timeHtml}
                ${shouldShowDismiss
                    ? `<button class="uib-overlay-dismiss" data-entry-id="${entryId}" title="Close">×</button>`
                    : ''}
            </div>
            <div class="uib-overlay-content">
                ${content}
            </div>
        </div>
    `

    // Add to overlay container at the top, sliding existing entries down
    if (overlayContainer.children.length > 0) {
        // Insert new entry at the top
        overlayContainer.insertBefore(overlayEntry, overlayContainer.firstChild)
    } else {
        // First entry, just add it normally
        overlayContainer.appendChild(overlayEntry)
    }

    // Close function for this specific entry
    const closeOverlayEntry = () => {
        const entry = document.getElementById(entryId)
        if (!entry) return

        entry.style.animation = 'slideOut 0.3s ease-in'
        setTimeout(() => {
            if (entry.parentNode) {
                entry.remove()
                // Remove the main container if no entries remain
                const container = document.getElementById(overlayContainerId)
                if (container && container.children.length === 0) {
                    container.remove()
                }
            }
        }, 300)
    }

    // Add dismiss button event listener
    const dismissBtn = overlayEntry.querySelector('.uib-overlay-dismiss')
    if (dismissBtn) {
        dismissBtn.addEventListener('click', closeOverlayEntry)
    }

    // Set up auto-close if specified
    let autoCloseTimer = null
    if (autoClose !== null && autoClose > 0) {
        autoCloseTimer = setTimeout(closeOverlayEntry, autoClose * 1000)
    }

    // Return control object
    return {
        close: () => {
            if (autoCloseTimer) {
                clearTimeout(autoCloseTimer)
            }
            closeOverlayEntry()
        },
        id: entryId,
    }
}

The svg-animate.mjs file:

/** SVGAnimate - A class for animating SVG elements
 * Provides methods to animate SVG elements based on their ID format: <id>@<animation_type>
 * 
 * This module works both as an ES module (import) and as a traditional script (script src="...")
 *
 * SVGAnimatableObject - Interface representing an animatable SVG object
 * @typedef {object} SVGAnimatableObject
 * @property {string} id - The ID of the element
 * @property {SVGElement} element - Reference to the SVG element
 * @property {boolean} cxMove - Whether the element supports movement animation
 * @property {boolean} cxColor - Whether the element supports color animation
 * @property {boolean} cxStatus - Whether the element supports status text changes
 * @property {boolean} cxHide - Whether the element supports visibility toggling
 * @property {boolean} cxStroke - Whether the element supports stroke modifications
 *
 * TransformConfig - Configuration for SVG transformations
 * @typedef {object} TransformConfig
 * @property {number} [x=0] - X translation
 * @property {number} [y=0] - Y translation
 * @property {number} [deg=0] - Rotation in degrees
 * @property {[number, number]} [pivot=[0.5, 0]] - Pivot point for rotation [x, y]
 */

class SVGAnimate {
    /** @type {string} Animation type identifier for movement */
    cxMove = 'cx_move'
    /** @type {string} Animation type identifier for color changes */
    cxColor = 'cx_color'
    /** @type {string} Animation type identifier for status text */
    cxStatus = 'cx_status'
    /** @type {string} Animation type identifier for visibility toggling */
    cxHide = 'cx_hide'
    /** @type {string} Animation type identifier for stroke modifications */
    cxStroke = 'cx_stroke'
    /** @type {number} Maximum length for status text before truncation */
    maxStatusLength = 12
    /** @type {Array<SVGAnimatableObject>|null} Collection of discovered animatable objects */
    graphicObjects = null

    /**
     * Creates a new SVGAnimate instance
     * @param {string} elementId - The ID of the SVG element to animate
     */
    constructor(elementId) {
        this.graphicObjects = []

        if (!this.graphicObjects) {
            this.graphicObjects = []
        }
        const svgElement = document.getElementById(elementId)
        if (!svgElement) {
            console.error(`SVG element with ID '${elementId}' not found`)
            return
        }

        this.recursiveEach(svgElement, this.graphicObjects)
        console.log(`Checking SVG "${elementId}" for animatable graphic objects ... ${this.graphicObjects.length} found`, this.graphicObjects)
    }

    /** Recursively traverse the SVG elements and collect graphic objects
     * @param {Element} element - The current element
     * @param {Array<SVGAnimatableObject>} container - The array to collect graphic objects
     */
    recursiveEach(element, container) {
        [...element.children].forEach((child) => {
            if (child.id && child.id.indexOf('@') > 0) {
                container.push({
                    id: child.id,
                    element: /** @type {SVGElement} */ (child),
                    cxMove: (child.id.indexOf(this.cxMove) > 0),
                    cxColor: (child.id.indexOf(this.cxColor) > 0),
                    cxStatus: (child.id.indexOf(this.cxStatus) > 0),
                    cxHide: (child.id.indexOf(this.cxHide) > 0),
                    cxStroke: (child.id.indexOf(this.cxStroke) > 0),
                })
            }
            this.recursiveEach(child, container)
        })
    }

    /** Animate a graphic object based on the payload and topic
     *
     * @param {any} payload - The data to animate the object with
     * @param {string} topic - The topic associated with the object (format: <id>@<type>)
     * @returns {void}
     *
     * @example
     * // Rotate element with ID 'compass@cx_move'
     * svgAnimate.animateObject({deg: 45, pivot: [0.5, 0.5]}, 'compass@cx_move')
     *
     * // Update text in element with ID 'WS@cx_status'
     * svgAnimate.animateObject('25', 'WS@cx_status')
     */
    animateObject(payload, topic) {
        if (payload === undefined || payload === null || !topic || typeof topic !== 'string' || !topic.includes('@')) {
            return
        }

        const id = topic.split('@')[0]
        const cxType = topic.split('@')[1]

        for (const graphicObject of this.graphicObjects) {
            const objId = graphicObject.id.split('@')[0]
            if (objId !== id || graphicObject.id.indexOf(cxType) === -1) continue

            if (cxType === this.cxColor && graphicObject.cxColor && typeof payload === 'string') {
                graphicObject.element.style.fill = payload
            } else if (cxType === this.cxStatus && graphicObject.cxStatus && typeof payload === 'string') {
                const statusText = (payload.length > this.maxStatusLength)
                    ? payload.substring(0, this.maxStatusLength - 3) + '...'
                    : payload
                graphicObject.element.textContent = statusText
            } else if (cxType === this.cxHide && graphicObject.cxHide && typeof payload === 'boolean') {
                const displayAttr = (payload) ? 'none' : ''
                graphicObject.element.style.display = displayAttr
            } else if (cxType === this.cxMove && graphicObject.cxMove) {
                const box = /** @type {SVGGraphicsElement} */ (graphicObject.element).getBBox()
                const matrix = this.getMatrix(payload, box)
                if (matrix) graphicObject.element.setAttribute('transform', matrix)
            } else if (cxType === this.cxStroke && graphicObject.cxStroke) {
                if (payload.color != null) graphicObject.element.style.stroke = payload.color
                if (payload.width != null) graphicObject.element.style.strokeWidth = payload.width
            }
        }
    }

    /** Get the transformation matrix for an SVG element
     *
     * @param {TransformConfig} config - The configuration object for the transformation
     * @param {DOMRect} box - The bounding box of the SVG element
     * @returns {string|null} The transformation matrix as a string
     */
    getMatrix(config, box) {
        if (!config || !box) return null

        // translation
        const tx = config.x || 0
        const ty = config.y || 0
        // angle in radians
        const rads = config.deg * (Math.PI / 180) || 0
        // rotation centre
        const pivot_x = (config.pivot && config.pivot[0]) ? config.pivot[0] : 0
        const pivot_y = (config.pivot && config.pivot[1]) ? config.pivot[1] : 0
        const cx = box.x + box.width * ((pivot_x) ? pivot_x : 0.5)
        const cy = box.y + box.height * ((pivot_y) ? pivot_y : 0)
        // scaling
        const sx = 1
        const sy = 1

        return `matrix(
            ${sx * Math.cos(rads)}, ${sy * Math.sin(rads)}, 
            ${-sx * Math.sin(rads)}, ${sy * Math.cos(rads)}, 
            ${(-Math.cos(rads) * cx + Math.sin(rads) * cy + cx) * sx + tx}, 
            ${(-Math.sin(rads) * cx - Math.cos(rads) * cy + cy) * sy + ty}
        )`
    }
}

// Auto-register the class globally
if (typeof window !== 'undefined') {
    /** @type {any} */ (window).SVGAnimate = SVGAnimate
}

// For ES module compatibility, also make available for dynamic imports
if (typeof globalThis !== 'undefined') {
    /** @type {any} */ (globalThis).SVGAnimate = SVGAnimate
}

Sorry, longer than I thought! :slight_smile:

Hopefully will make more sense when you've put it together.

1 Like

Doh! Just realised that I crossed-over two threads!

You wanted the flow for the status page here - I shared the weather charts! :frowning:

Let me fix that for you (as ChatGPT would say).

Right, trying again. Here is the flow:

[{"id":"449c2bccca750e21","type":"group","z":"ba86b64531fa3971","name":"System Status Route","style":{"fill":"#bfdbef","fill-opacity":"0.3","label":true,"color":"#000000"},"nodes":["c6b09717781cbef3","489a08be559afc17","4efa56dd74d7e9ec","f769f765dbfae153","536e0e510bcee507","cc684196ea044cc0","ce87ebf2ecdd1e6f","8a13de75076f2b5b","28a03db1d4116ca7"],"x":32.99998092651367,"y":679,"w":1070,"h":354.9999704360962},{"id":"99b68b4208b45e2f","type":"subflow","name":"Reset MSG","info":"","category":"","in":[{"x":60,"y":40,"wires":[{"id":"44c96ae25491502b"}]}],"out":[{"x":320,"y":40,"wires":[{"id":"44c96ae25491502b","port":0}]}],"env":[],"meta":{},"color":"#C0C0C0","icon":"font-awesome/fa-close"},{"id":"44c96ae25491502b","type":"function","z":"99b68b4208b45e2f","name":"delete msg","func":"return {}\n","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":190,"y":40,"wires":[[]]},{"id":"c6b09717781cbef3","type":"uib-update","z":"ba86b64531fa3971","g":"449c2bccca750e21","name":"","topic":"","mode":"update","modeSourceType":"update","cssSelector":"\"[data-topic=\\\"\" & \ttopic & \t\"\\\"] .status-side-panel\"","cssSelectorType":"jsonata","slotSourceProp":"","slotSourcePropType":"msg","attribsSource":"{\t    \"class\": payload in [\"active\", \"Online\", \"online\", true, 1, \"1\"] ? \"success status-side-panel\" : \"error animate-pulse status-side-panel\",\t    \"title\": \"Last update: \" & $now(),\t    \"data-updated\": $now(),\t    \"data-updatedBy\": \"monitor\"\t}","attribsSourceType":"jsonata","slotPropMarkdown":false,"x":650,"y":780,"wires":[["f769f765dbfae153"]]},{"id":"489a08be559afc17","type":"group","z":"ba86b64531fa3971","g":"449c2bccca750e21","name":"1) Create base data. Save to flow.bookmarks(file). Create HTML. Save to `<uibRoot>/home/src/fe-routes/system-status.html`. Reloads page. \\n UPDATE IF STATUS MONITORS ADDED/REMOVED","style":{"fill":"#ffffbf","fill-opacity":"0.33","label":true,"color":"#000000"},"nodes":["b21e10dabba89b53","eda3a8cc439554c8","e5a6036ad8914588","bc1d55b8ecac66d3","d504c4c46b8b0466"],"x":58.99998092651367,"y":843,"w":1018,"h":164.9999704360962},{"id":"b21e10dabba89b53","type":"group","z":"ba86b64531fa3971","g":"489a08be559afc17","name":"Create flow.bookmarks","style":{"label":true},"nodes":["6ba55b0c32f5ba4e","cf3f36e505ed0a59"],"x":84.99998092651367,"y":899.9999704360962,"w":152,"h":82},{"id":"6ba55b0c32f5ba4e","type":"inject","z":"ba86b64531fa3971","g":"b21e10dabba89b53","name":"","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"1-main","payload":"[{\"group\":\"Network\",\"view\":\"open\",\"checks\":[{\"id\":\"router\",\"name\":\"Router\",\"type\":\"Network\",\"link\":\"https://192.168.1.1/\",\"description\":\"192.168.1.1\",\"statusTopic\":\"telegraf/ping/router.knightnet.co.uk\",\"show\":[\"status\",\"bookmark\"]},{\"id\":\"ipcamera1\",\"name\":\"Back Yard Camera\",\"type\":\"Network\",\"link\":\"http://192.168.1.196/\",\"description\":\"192.168.1.196\",\"statusTopic\":\"telegraf/ping/192.168.1.196\",\"show\":[\"status\",\"bookmark\"]},{\"id\":\"switch1\",\"name\":\"TP-Link Managed Switch\",\"type\":\"Network\",\"link\":\"http://192.168.1.155/\",\"description\":\"192.168.1.155\",\"statusTopic\":\"telegraf/ping/192.168.1.155\",\"show\":[\"status\",\"bookmark\"]},{\"id\":\"wifiap2\",\"name\":\"Unifi AP Pro\",\"type\":\"Network\",\"link\":\"https://home.knightnet.co.uk:8443/\",\"description\":\"192.168.1.173\",\"statusTopic\":\"telegraf/ping/192.168.1.173\",\"show\":[\"status\",\"bookmark\"]},{\"id\":\"wifiap1\",\"name\":\"Unifi AP LR\",\"type\":\"Network\",\"link\":\"https://home.knightnet.co.uk:8443/\",\"description\":\"192.168.1.163\",\"statusTopic\":\"telegraf/ping/192.168.1.163\",\"show\":[\"status\",\"bookmark\"]},{\"id\":\"wiser2\",\"name\":\"Wiser Controller (ping)\",\"type\":\"Service\",\"description\":\"192.168.1.185\",\"statusTopic\":\"telegraf/ping/192.168.1.185\",\"show\":[\"status\",\"bookmark\"]}]},{\"group\":\"Services\",\"view\":\"open\",\"checks\":[{\"id\":\"NAS\",\"name\":\"NAS\",\"type\":\"Service\",\"link\":\"https://nas.knightnet.co.uk:5001/\",\"description\":\"nas.knightnet.co.uk:5001\",\"statusTopic\":\"telegraf/ping/192.168.1.161\",\"show\":[\"status\",\"bookmark\"]},{\"id\":\"nrmain\",\"name\":\"Node-RED (Live)\",\"type\":\"Service\",\"link\":\"https://home.knightnet.co.uk:1880/red/\",\"description\":\"home.knightnet.co.uk:1880\",\"statusTopic\":\"telegraf/http_response/https:__localhost:1880_test1\",\"show\":[\"status\",\"bookmark\"]},{\"id\":\"nrui\",\"name\":\"Node-RED (UI)\",\"type\":\"Service\",\"link\":\"https://home.knightnet.co.uk:1880/ui\",\"description\":\"home.knightnet.co.uk:1880/ui\",\"statusTopic\":\"telegraf/http_response/https:__localhost:1880_ui\",\"show\":[\"status\",\"bookmark\"]},{\"id\":\"Grafana\",\"name\":\"Grafana\",\"type\":\"Service\",\"link\":\"http://home.knightnet.co.uk:3000/\",\"description\":\"home.knightnet.co.uk:3000\",\"statusTopic\":\"services/Grafana\",\"show\":[\"status\",\"bookmark\"]},{\"id\":\"Zigbee2MQTT\",\"name\":\"Zigbee2MQTT\",\"type\":\"Service\",\"link\":\"http://home.knightnet.co.uk:8085/#/\",\"description\":\"home.knightnet.co.uk:8085\",\"statusTopic\":\"services/Zigbee2MQTT\",\"show\":[\"status\",\"bookmark\"]},{\"id\":\"wiser\",\"name\":\"Wiser Control (NR)\",\"type\":\"Service\",\"description\":\"192.168.1.185\",\"statusTopic\":\"wiser\",\"show\":[\"status\",\"bookmark\"]},{\"id\":\"Telegraf\",\"name\":\"Telegraf Data Collector\",\"type\":\"Service\",\"description\":\"home.knightnet.co.uk\",\"statusTopic\":\"services/Telegraf\",\"show\":[\"status\",\"bookmark\"]},{\"id\":\"Unifi\",\"name\":\"Unifi Controller\",\"type\":\"Service\",\"link\":\"https://home.knightnet.co.uk:8443/\",\"description\":\"home.knightnet.co.uk:8443\",\"statusTopic\":\"services/Unifi\",\"show\":[\"status\",\"bookmark\"]},{\"id\":\"Mosquitto\",\"name\":\"MQTT Broker\",\"type\":\"Service\",\"description\":\"Mosquitto MQTT Broker\",\"statusTopic\":\"telegraf/procstat_lookup/mosquitto.service\",\"show\":[\"status\"]},{\"id\":\"Docker\",\"name\":\"Docker\",\"type\":\"Service\",\"description\":\"Docker\",\"statusTopic\":\"telegraf/procstat_lookup/docker.service\",\"show\":[\"status\"]},{\"id\":\"InfluxDB\",\"name\":\"InfluxDB\",\"type\":\"Service\",\"description\":\"InfluxDB\",\"statusTopic\":\"telegraf/procstat_lookup/influxdb.service\",\"show\":[\"status\"]}]},{\"group\":\"IoT Devices\",\"view\":\"open\",\"checks\":[{\"id\":\"shelly_rgbw2_01\",\"name\":\"Shelly RGB\",\"type\":\"IoT\",\"link\":\"http://192.168.1.175/\",\"description\":\"192.168.1.175\",\"statusTopic\":\"shellies/shelly_rgbw2_01/online\",\"show\":[\"status\",\"bookmark\"]},{\"id\":\"shelly1PM_01\",\"name\":\"Shelly 1PM\",\"type\":\"IoT\",\"link\":\"http://192.168.1.176/\",\"description\":\"192.168.1.176\",\"statusTopic\":\"known_devices/shelly1PM_01\",\"show\":[\"status\",\"bookmark\"]},{\"id\":\"d1m02\",\"name\":\"d1m02\",\"type\":\"IoT\",\"link\":\"http://192.168.1.152/\",\"description\":\"192.168.1.152\",\"statusTopic\":\"known_devices/d1m02\",\"show\":[\"status\",\"bookmark\"]},{\"id\":\"d1m03\",\"name\":\"d1m03\",\"type\":\"IoT\",\"link\":\"http://192.168.1.118/\",\"description\":\"192.168.1.118\",\"statusTopic\":\"known_devices/d1m03\",\"show\":[\"status\",\"bookmark\"]},{\"id\":\"d1m04\",\"name\":\"d1m04\",\"type\":\"IoT\",\"link\":\"http://192.168.1.187/\",\"description\":\"192.168.1.187\",\"statusTopic\":\"known_devices/d1m04\",\"show\":[\"status\",\"bookmark\"]},{\"id\":\"d1m05\",\"name\":\"Living Room Sensors\",\"type\":\"IoT\",\"link\":\"http://192.168.1.188/\",\"description\":\"192.168.1.188\",\"statusTopic\":\"known_devices/d1m05\",\"show\":[\"status\",\"bookmark\"]},{\"id\":\"m5basic01\",\"name\":\"Office Sensors (M5 Basic)\",\"type\":\"IoT\",\"link\":\"http://192.168.1.193/\",\"description\":\"192.168.1.193\",\"statusTopic\":\"ESP/m5basic01\",\"show\":[\"status\",\"bookmark\"]},{\"id\":\"m5atom01\",\"name\":\"M5 Atom\",\"type\":\"IoT\",\"link\":\"http://192.168.1.194/\",\"description\":\"192.168.1.194\",\"statusTopic\":\"ESP/m5atom01\",\"show\":[\"status\",\"bookmark\"]}]},{\"group\":\"Home Automation\",\"view\":\"collapsed\",\"checks\":[{\"id\":\"lighting\",\"name\":\"Lighting\",\"type\":\"HA\",\"link\":\"https://home.knightnet.co.uk:1880/lights/\",\"description\":\"home.knightnet.co.uk:1880/lights\",\"statusTopic\":\"telegraf/http_response/https:__localhost:1880_lights\",\"show\":[\"status\",\"bookmark\"]}]},{\"group\":\"Websites\",\"view\":\"collapsed\",\"checks\":[{\"id\":\"www\",\"name\":\"Blog\",\"type\":\"Web\",\"link\":\"https://www.totallyinformation.net/\",\"description\":\"www.totallyinformation.net\",\"statusTopic\":\"telegraf/ping/www.totallyinformation.net\",\"show\":[\"status\",\"bookmark\"]},{\"id\":\"wc\",\"name\":\"Web Components\",\"type\":\"Web\",\"link\":\"https://wc.totallyinformation.net/\",\"description\":\"wc.totallyinformation.net\",\"statusTopic\":\"telegraf/ping/wc.totallyinformation.net\",\"show\":[\"status\",\"bookmark\"]},{\"id\":\"uibdocs\",\"name\":\"UIBUILDER Docs\",\"type\":\"Web\",\"link\":\"https://totallyinformation.github.io/node-red-contrib-uibuilder/#/\",\"description\":\"totallyinformation.github.io/node-red-contrib-uibuilder\",\"statusTopic\":\"telegraf/http_response/https:__totallyinformation.github.io_node-red-contrib-uibuilder\",\"show\":[\"status\",\"bookmark\"]},{\"id\":\"maait\",\"name\":\"Much Ado About IT (blog)\",\"type\":\"Web\",\"link\":\"https://it.knightnet.org.uk/\",\"description\":\"it.knightnet.org.uk\",\"statusTopic\":\"telegraf/ping/it.knightnet.org.uk\",\"show\":[\"status\",\"bookmark\"]},{\"id\":\"bbc\",\"name\":\"BBC\",\"type\":\"Web\",\"link\":\"https://bbc.co.uk/\",\"description\":\"bbc.co.uk\",\"statusTopic\":\"telegraf/ping/bbc.co.uk\",\"show\":[\"status\",\"bookmark\"]},{\"id\":\"youtube\",\"name\":\"Youtube\",\"type\":\"Web\",\"link\":\"https://youtube.com/\",\"description\":\"youtube.com\",\"statusTopic\":\"telegraf/ping/youtube.com\",\"show\":[\"status\",\"bookmark\"]},{\"id\":\"nrforum\",\"name\":\"Node-RED Forum\",\"type\":\"Web\",\"link\":\"https://discourse.nodered.org/latest\",\"description\":\"discourse.nodered.org\",\"statusTopic\":\"telegraf/http_response/https:__discourse.nodered.org_latest\",\"show\":[\"status\",\"bookmark\"]}]}]","payloadType":"json","x":145.99998092651367,"y":940.9999704360962,"wires":[["cf3f36e505ed0a59"]],"l":false},{"id":"cf3f36e505ed0a59","type":"change","z":"ba86b64531fa3971","g":"b21e10dabba89b53","name":"","rules":[{"t":"set","p":"#:(file)::bookmarks","pt":"flow","to":"payload","tot":"msg"},{"t":"set","p":"updated","pt":"msg","to":"","tot":"date"}],"action":"","property":"","from":"","to":"","reg":false,"x":195.99998092651367,"y":940.9999704360962,"wires":[["1953e485f2994ab1"]],"l":false},{"id":"eda3a8cc439554c8","type":"group","z":"ba86b64531fa3971","g":"489a08be559afc17","name":"Page HTML (flow[file].bookmarks)","style":{"label":true},"nodes":["1953e485f2994ab1","2941df54d1ab3578"],"x":264.9999809265137,"y":899.9999704360962,"w":282,"h":82},{"id":"1953e485f2994ab1","type":"template","z":"ba86b64531fa3971","g":"eda3a8cc439554c8","name":"","field":"payload","fieldType":"msg","format":"handlebars","syntax":"mustache","template":"<style>\n    .status summary {\n        cursor: pointer;\n    }\n    .status summary > h2 {\n        display:inline-block;\n    }\n</style>\n<div id=\"status\" class=\"status\">\n    {{#flow[file].bookmarks}}\n    <details {{view}}>\n        <summary role=\"heading\" aria-level=\"2\"><h2 xclass=\"status-heading\">{{group}}</h2></summary>\n        <div class=\"status-grid\">\n            {{#checks}}\n            {{#link}}<a href=\"{{link}}\" target=\"_blank\" class=\"status-link\">{{/link}}\n            <div id=\"st-{{id}}\" class=\"box flex surface4\" data-topic=\"{{statusTopic}}\" title=\"MQTT Topic: {{statusTopic}}\">\n                <div class=\"status-side-panel\"></div>\n                <div>\n                    <div>{{name}}</div>\n                    <div class=\"text-smaller\">{{description}}</div>\n                </div>\n            </div>\n            {{#link}}</a>{{/link}}\n            {{/checks}}\n        </div>\n    </details>\n    {{/flow[file].bookmarks}}\n</div>\n","output":"str","x":305.9999809265137,"y":940.9999704360962,"wires":[["2941df54d1ab3578"]],"l":false},{"id":"2941df54d1ab3578","type":"uib-element","z":"ba86b64531fa3971","g":"eda3a8cc439554c8","name":"","topic":"","elementtype":"html","parent":"body","parentSource":"","parentSourceType":"str","elementid":"status","elementId":"","elementIdSourceType":"str","heading":"","headingSourceType":"str","headingLevel":"h2","data":"payload","dataSourceType":"msg","position":"last","positionSourceType":"str","passthrough":false,"confData":{},"x":430.9999809265137,"y":940.9999704360962,"wires":[["e5a6036ad8914588"]]},{"id":"e5a6036ad8914588","type":"uib-html","z":"ba86b64531fa3971","g":"489a08be559afc17","name":"","topic":"","useTemplate":false,"x":650.9999809265137,"y":940.9999704360962,"wires":[["bc1d55b8ecac66d3","d504c4c46b8b0466"]]},{"id":"bc1d55b8ecac66d3","type":"uib-save","z":"ba86b64531fa3971","g":"489a08be559afc17","url":"home","uibId":"8f4068233f5df004","folder":"src/fe-routes","fname":"system-status.html","createFolder":false,"reload":true,"usePageName":false,"encoding":"utf8","mode":438,"name":"home:src/fe-routes/system-status.html","topic":"","x":900.9999809265137,"y":940.9999704360962,"wires":[]},{"id":"d504c4c46b8b0466","type":"debug","z":"ba86b64531fa3971","g":"489a08be559afc17","name":"debug 35","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"false","statusVal":"","statusType":"auto","x":800,"y":900,"wires":[]},{"id":"4efa56dd74d7e9ec","type":"group","z":"ba86b64531fa3971","g":"449c2bccca750e21","name":"------- 2) Start Monitoring. Dynamic subscriptions using flow.bookmarks. -------","style":{"label":true,"color":"#000000","fill":"#d1d1d1","fill-opacity":"0.3"},"nodes":["56a8c57c3ece2c3b","3d4b7c17594abd24","952c33956803e4e6","a5bbe6e2d887de57","21a9f7f01cde56b5","0271469e157ef571","72efa345c8308384","bedbb50ff03fa4d6"],"x":64.99998092651367,"y":739.9999704360962,"w":486,"h":82},{"id":"56a8c57c3ece2c3b","type":"mqtt in","z":"ba86b64531fa3971","g":"4efa56dd74d7e9ec","name":"","topic":"","qos":"2","datatype":"auto-detect","broker":"3784c9f0.57bab6","nl":false,"rap":true,"rh":0,"inputs":1,"x":475.9999809265137,"y":780.9999704360962,"wires":[["c6b09717781cbef3"]],"l":false},{"id":"3d4b7c17594abd24","type":"split","z":"ba86b64531fa3971","g":"4efa56dd74d7e9ec","name":"","splt":"\\n","spltType":"str","arraySplt":1,"arraySpltType":"len","stream":false,"addname":"","x":175.99998092651367,"y":780.9999704360962,"wires":[["a5bbe6e2d887de57"]],"l":false},{"id":"952c33956803e4e6","type":"inject","z":"ba86b64531fa3971","g":"4efa56dd74d7e9ec","name":"","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":true,"onceDelay":"2","topic":"Start-Checks","payload":"#:(file)::bookmarks","payloadType":"flow","x":125.99998092651367,"y":780.9999704360962,"wires":[["3d4b7c17594abd24"]],"l":false},{"id":"a5bbe6e2d887de57","type":"split","z":"ba86b64531fa3971","g":"4efa56dd74d7e9ec","name":"","splt":"\\n","spltType":"str","arraySplt":1,"arraySpltType":"len","stream":false,"addname":"group","x":225.99998092651367,"y":780.9999704360962,"wires":[["21a9f7f01cde56b5"]],"l":false},{"id":"21a9f7f01cde56b5","type":"switch","z":"ba86b64531fa3971","g":"4efa56dd74d7e9ec","name":"","property":"group","propertyType":"msg","rules":[{"t":"eq","v":"checks","vt":"str"}],"checkall":"true","repair":false,"outputs":1,"x":275.9999809265137,"y":780.9999704360962,"wires":[["0271469e157ef571"]],"l":false},{"id":"0271469e157ef571","type":"split","z":"ba86b64531fa3971","g":"4efa56dd74d7e9ec","name":"","splt":"\\n","spltType":"str","arraySplt":1,"arraySpltType":"len","stream":false,"addname":"type","x":325.9999809265137,"y":780.9999704360962,"wires":[["bedbb50ff03fa4d6"]],"l":false},{"id":"72efa345c8308384","type":"change","z":"ba86b64531fa3971","g":"4efa56dd74d7e9ec","name":"","rules":[{"t":"set","p":"topic","pt":"msg","to":"payload.statusTopic","tot":"msg","dc":true},{"t":"set","p":"action","pt":"msg","to":"subscribe","tot":"str"},{"t":"delete","p":"payload","pt":"msg"},{"t":"delete","p":"parts","pt":"msg"},{"t":"delete","p":"group","pt":"msg"}],"action":"","property":"","from":"","to":"","reg":false,"x":425.9999809265137,"y":780.9999704360962,"wires":[["56a8c57c3ece2c3b"]],"l":false},{"id":"bedbb50ff03fa4d6","type":"switch","z":"ba86b64531fa3971","g":"4efa56dd74d7e9ec","name":"Input has Status Topic Property","property":"payload","propertyType":"msg","rules":[{"t":"hask","v":"statusTopic","vt":"str"}],"checkall":"true","repair":false,"outputs":1,"x":375.9999809265137,"y":780.9999704360962,"wires":[["72efa345c8308384"]],"l":false},{"id":"3784c9f0.57bab6","type":"mqtt-broker","name":"nrmain-local (v5)","broker":"localhost","port":"1883","clientid":"nrmain-local","autoConnect":true,"usetls":false,"compatmode":false,"protocolVersion":"5","keepalive":"60","cleansession":true,"autoUnsubscribe":true,"birthTopic":"services/nrmain","birthQos":"0","birthRetain":"true","birthPayload":"Online","birthMsg":{},"closeTopic":"services/nrmain","closeQos":"0","closeRetain":"true","closePayload":"Offline","closeMsg":{},"willTopic":"services/nrmain","willQos":"0","willRetain":"true","willPayload":"Offline","willMsg":{},"userProps":"","sessionExpiry":""},{"id":"f769f765dbfae153","type":"uib-cache","z":"ba86b64531fa3971","g":"449c2bccca750e21","cacheall":false,"cacheKey":"topic","newcache":true,"num":1,"storeName":"default","name":"cache","storeContext":"context","varName":"uib_cache","x":890,"y":780,"wires":[["28a03db1d4116ca7"]]},{"id":"536e0e510bcee507","type":"subflow:99b68b4208b45e2f","z":"ba86b64531fa3971","g":"449c2bccca750e21","name":"","x":715,"y":740,"wires":[["cc684196ea044cc0"]],"l":false},{"id":"cc684196ea044cc0","type":"change","z":"ba86b64531fa3971","g":"449c2bccca750e21","name":"Cache replay","rules":[{"t":"set","p":"uibuilderCtrl","pt":"msg","to":"Route Change","tot":"str"},{"t":"set","p":"cacheControl","pt":"msg","to":"REPLAY","tot":"str"}],"action":"","property":"","from":"","to":"","reg":false,"x":765,"y":740,"wires":[["f769f765dbfae153"]],"l":false},{"id":"ce87ebf2ecdd1e6f","type":"inject","z":"ba86b64531fa3971","g":"449c2bccca750e21","name":"","props":[],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","x":595,"y":740,"wires":[["536e0e510bcee507"]],"l":false},{"id":"8a13de75076f2b5b","type":"link in","z":"ba86b64531fa3971","g":"449c2bccca750e21","name":"link in 1","links":["73a73c96848614cc"],"x":645,"y":720,"wires":[["536e0e510bcee507"]]},{"id":"28a03db1d4116ca7","type":"link out","z":"ba86b64531fa3971","g":"449c2bccca750e21","name":"link out 69","mode":"link","links":["55646c84699d14f1"],"x":1005,"y":780,"wires":[]},{"id":"85e61f32edf90623","type":"global-config","env":[],"modules":{"node-red-contrib-uibuilder":"7.4.3"}}]

Note that the link-in node comes from the uibuilder node's control output when you switch to the appropriate SPA "page".

As you can see, as my SPA got more complex, I've split the control outputs. The first level separates out connection and route change messages:

[{"id":"82e10fdbb05efa3e","type":"switch","z":"ba86b64531fa3971","g":"77eaa81c51dc913e","name":"ctrl","property":"uibuilderCtrl","propertyType":"msg","rules":[{"t":"eq","v":"client connect","vt":"str"},{"t":"eq","v":"route change","vt":"str"},{"t":"eq","v":"visibility","vt":"str"},{"t":"eq","v":"client disconnect","vt":"str"},{"t":"else"}],"checkall":"false","repair":false,"outputs":5,"x":770,"y":140,"wires":[["0a982d7cfd4d87fa"],["67cfadcf89cf7ea6"],[],[],["9c288ee097514452"]],"outputLabels":["client connect","route change","visibility","client disconnect","otherwise"]}]

The "Handle Route Ctrl" switch node simply acts as a switchboard to trigger any flows (if any) needed for each route.

That approach makes the whole logic structure a lot easier to adapt as the SPA matures.

I have also used separate cache nodes for each route. Again, this greatly simplifies the control logic. The link-in for the system status page simply does a cache replay.

Did you mean for two ‘client connect’?

XCPT - one is Client Disconnect!! Must take a break…

1 Like

This Handle Route Control switch, does it save putting the code in ‘index.js’ like in the justgage PWA flow, as it would seem simpler (to me!).

You still need to define the router object in index.js - though you've triggered a thought there - perhaps there should be a way to set up the router from Node-RED? To be totally honest, I set up the original router library quite quickly in response to someone asking for an SPA style solution without the need for using a full framework or additional external library (other router/SPA libraries do exist). My actual use of the router is relatively limited as I only really have a test one and a live one.

The router library already recognises if the uibuilder library is loaded. So it would be easy to create a listener that could grab the config from the msg and build the SPA. Alternatively, I could built it into the uib-element node so that it becomes a no-code (and by extension a low-code) feature. The only possible problem might be timing. It might be a little slow to start up. I'll add to the backlog for testing. Thanks for the idea! :smiley:

So all the control input does right now is trigger the cache replay so that your route gets the last available stored data for the route when you switch to it. This allows for the route being unloaded when switching pages and allows for the fact that the route usually won't yet have been even loaded until you switch to the page for the first time.

1 Like

I haven’t used Julian’s example, I wrote my own to control my EV charger (it works OK, but probably not the best example to follow though :wink: )

JP2

Links to the html, javascript, and css code

In node-RED, the output from the UIBUILDER node is fed into a function node, containing;

if (msg.topic === 'chargeButton' && msg.payload != null) {
let state = flow.get("chargeButton") || "on"
if (state == "on") state = "off"
else if (state == "off") state = "on"
node.send({ payload: state, topic: msg.topic })
flow.set("chargeButton", state)
}
return

…which feeds back into the UIBUILDER node via a uib-sender node.

1 Like

Not sure if this helps at all. But the button-send web component in my library will take any HTML as button content. That might work simply adding a div with the class status-side-panel in it, something like:

<button-send id="btnSend2" topic="my-topic2" name="btn-2" 
  data-something="Something 2 from the button"
>
  <div class="status-side-panel"></div>
  Send
</button-send>

Now, I've not tried it so not sure whether it will work, but it might be interesting. That component is one of the uibuilder-aware components as well so you don't need to add a click event to it.