Current File : //proc/thread-self/root/usr/share/webmin/authentic-theme/extensions/stats/stats.src.js
/*!
 * Authentic Theme (https://github.com/authentic-theme/authentic-theme)
 * Copyright Ilia Rostovtsev <ilia@virtualmin.com>
 * Licensed under MIT (https://github.com/authentic-theme/authentic-theme/blob/master/LICENSE)
 */

/* jshint strict: true */
/* jshint esversion: 6 */
/* jshint jquery: true */

"use strict";

// Stats module
const stats = {
    sys: {
        // Define variables
        error: 0,
        tried: 0,
        activating: 0,
        requery: null,
        socket: null,
        // Import globals
        /* jshint -W117 */
        _: {
            prefix: v___location_prefix,
            error: connection_error,
            language: theme_language,
            convert: {
                size: Convert.nice_size,
            },
            chart: Chartist,
            dayjs: dayjs,
            locale: {
                time: config_portable_theme_locale_format_time,
                offset: () => {
                    return get_utc_offset();
                },
            },
            can_conn_ws: can_conn_ws,
            blocked: theme_updating,
            getHistoryData: function () {
                return vars.stats.history;
            },
        },
        // Define reusable selectors
        selector: {
            chart: {
                container: {
                    parent: "live_stats",
                    data: "data-chart",
                },
                loader: "data-charts-loader",
            },
            collapse: "collapse",
            dashboard: "system-status",
            slider: "info-container",
            piechart: "piechart",
            defaultClassLabel: "bg-semi-transparent",
            defaultSliderClassLabel: "bg-semi-transparent-dark",
        },
        // Get current data to submit to the socket
        getSocketDefs: function () {
            return {
                session: session.server.data("session-hash"),
                paused: !this.canRender() ? 1 : 0,
                interval: this.getInterval(),
                disable: !this.isEnabled() ? 1 : 0,
                shutdown: settings_sysinfo_real_time_shutdown_on_last ? 1 : 0,
            };
        },
        // Update settings for the current client on the socket server
        // updateSocket: function () {
        //     if (this.isEnabled() && this.socket &&
        //         this.socket.readyState === 1) {
        //         const socketData = this.getSocketDefs();
        //         // Update socket settings
        //         this.socket.send(JSON.stringify(socketData));
        //     }
        // },
        // Check if graphs can be rendered
        graphsCanPreRender: function () {
            return document.querySelector(`[${this.selector.chart.loader}]`) ? 1 : 0;
        },
        // Get interval call for the stats update
        getInterval: function () {
            return settings_sysinfo_real_time_run_rate / 1000;
        },
        // Get the stored data duration period (e.g., from 300 to 86400 seconds)
        getStoredDuration: function () {
            return settings_sysinfo_real_time_stored_duration;
        },
        // Check if the received data has multiple datasets
        getRenderType: function(graphs) {
            graphs = graphs.graphs;
            let hasMultipleDatasets = false;
            for (const key in graphs) {
                if (graphs.hasOwnProperty(key) && Array.isArray(graphs[key])) {
                    if (graphs[key].length > 1) {
                        hasMultipleDatasets = true;
                        break;
                    }
                }
            }
            // Received graphs stats have history
            // data (3) or a single slice (null)
            return hasMultipleDatasets ? 3 : null;
        },
        // Can we update the stats in the UI?
        canRender: function() {
            return theme.visibility.get();
        },
        // Check if the stats are enabled
        isEnabled: function () {
            const stats_enabled = settings_sysinfo_real_time_status ? 1 : 0,
                  stats_can = this._.can_conn_ws();
            return stats_enabled && stats_can;
        },
        // Restart the stats by shutting down and enabling them
        restart: function () {
            this.shutdown();
            setTimeout(() => {
                this.enable();
            }, this.getInterval() * 1000 * 4);
        },
        // Disable the stats broadcast for the client
        disable: function () {
            if (this.socket && this.socket.readyState === 1) {
                // console.warn("Disabling stats broadcast");
                const socketData = this.getSocketDefs();
                socketData.paused = 1;
                this.socket.send(JSON.stringify(socketData));
            }
        },
        // Enable the stats broadcast for the client
        enable: function () {
            if (this.isEnabled()) {
                // console.warn("Enabling stats broadcast");
                if (this.graphsCanPreRender()) {
                    this.preRender();
                }
                if (this.socket) {
                    // console.warn("Sending ..", this.socket.readyState === 1),
                    this.socket.readyState === 1 &&
                        this.socket.send(JSON.stringify(this.getSocketDefs()));
                } else {
                    // console.warn("Activating .."),
                    this.activate();
                }
            }
        },
        // Shutdown the stats broadcast for all
        // clients by shutting down the socket
        shutdown: function () {
            if (this.socket && this.socket.readyState === 1) {
                const socketData = this.getSocketDefs();
                socketData.disable = 1;
                this.socket.send(JSON.stringify(socketData));
            }
        },

        // Activate the stats server unless already up and open
        // the socket to receive the data for the current tab
        activate: function () {
            // Already called for this tab?
            if (this.activating++ ||
                this._.blocked() ||
                this.socket) {
                return;
            }
            // Tried too many times?
            if (this.tried++ > 4) {
                return;
            }
            $.ajax({
                context: this,
                url: `${this._.prefix}/stats.cgi`,
                error: function () {
                    // Reset activating flag
                    this.activating = 0;
                    // Show error
                    if (this.error++ > 3) {
                        return;
                    }
                    // Retry again
                    !this.requery &&
                        (this.requery = setTimeout(() => {
                            this.requery = null;
                            this.activate();
                        }, this.getInterval() * 1000 * 4));
                },
                success: function (data) {
                    // Do we have socket opened?
                    if (data.success) {
                        // Open socket
                        // console.warn("WebSocket connection opened", data);
                        this.socket = new WebSocket(data.socket);
                        // On socket open
                        this.socket.onopen = () => {
                            this.tried = 0;
                            this.activating = 0;
                            // console.log("WebSocket connection established",
                            //         this.getSocketDefs());
                            this.socket.send(
                                JSON.stringify(this.getSocketDefs()));
                        };
                        // On socket message
                        this.socket.onmessage = (event) => {
                            const message = JSON.parse(event.data),
                                  renderType = this.getRenderType(message);
                            // Pause stats broadcast for this client
                            // if the tab is not visible
                            // err: no need, as redundant with enable/disable?
                            // if (this.canRender.last != this.canRender()) {
                            //    console.log("Visibility changed", this.canRender());
                            //     this.canRender.last = this.canRender();
                            //     this.updateSocket();
                            // }
                            // console.log("Received stats", renderType, message);
                            this.render(message, renderType);
                        };
                        // On socket close
                        this.socket.onclose = () => {
                            // console.warn("WebSocket connection closed");
                            setTimeout(() => {
                                this.socket = null;
                                this.activating = 0;
                                this.enable();
                            }, this.getInterval() * 1000 * 4);
                        };
                    } else {
                        // Reset activating flag
                        this.activating = 0;
                    }
                    // Reset error counter
                    this.error = 0;
                },
                dataType: "json",
            });
        },

        // Draw initial graphs either using stored data or empty placeholders
        preRender: function () {
            this.render(this._.getHistoryData(), 2);
        },

        // Display changes
        render: function (data, graphs) {
            // Iterate through response
            Object.entries(data).map(([target, data]) => {
                let v = parseInt(data),
                    vo = typeof data === "object" ? data[data.length - 1] : false,
                    vt = vo ? vo : v,
                    $pc = $(
                        `#${this.selector.dashboard} .${this.selector.piechart}[data-charts*="${target}"]`
                    ),
                    $lc = $(`.${this.selector.slider} .${target}_percent`),
                    $od = $(`#${this.selector.dashboard} span[data-id="sysinfo_${target}"], 
                             .${this.selector.slider} span[data-data="${target}"]`),
                    cached = target === "graphs" ? (graphs ? (graphs === 3 ? 3 : 2) : (this.graphsCanPreRender() ? 2 : 1)) : 0;
                if (Number.isInteger(v)) {
                    // Update pie-charts
                    if ($pc.length) {
                        let piechart = $pc.data("easyPieChart");
                        piechart && piechart.update(v);
                    }

                    // Update line-charts
                    if ($lc.length) {
                        $lc.find(".bar").attr("style", "width:" + v + "%");
                        // Update line-charts' text
                        let $dp = $lc.find(".description"),
                            $lb = $dp.text().split(":")[0],
                            uv = $lb + ": " + v + "% (" + vo + ")";
                        // Flatten and plunk the data for some graphs
                        if (target !== "cpu") {
                            uv = plugins.slider.update.stats.graphs.flatten(uv);
                            if (target !== "virt") {
                                uv = plugins.slider.update.stats.graphs.plunk(uv);
                            }
                        }
                        $dp.attr("title", vo).text(uv);
                    }

                    // Update other data
                    if ($od.length) {
                        if ($od.find("a").length) {
                            $od.find("a").text(vt);
                        } else {
                            $od.text(vt);
                        }
                    }
                }

                // Update sensors data
                if (target === "sensors" && vo) {
                    // Iterate through sensors
                    Object.entries(vo).forEach(([sensor, value]) => {
                        let this_ = this,
                            $lb1 = $(`#${this.selector.dashboard} span[data-stats="${sensor}"]`),
                            $lb2 = $(`.${this.selector.slider} span[data-stats="${sensor}"]`);

                        if ($lb1 && $lb1.length) {
                            let lb_count1 = $lb1.length,
                                lb_count2 = $lb2.length;

                            // Sort the values based on 'fan' or 'core' field
                            value.sort((a, b) =>
                                sensor === "fans" ? a.fan - b.fan : a.core - b.core
                            );

                            // Function to update individual label
                            const updateLabel = function ($label, data, isSingleLabel, sideSlider) {
                                if (!data) {
                                    return;
                                }
                                // Update the label text based on count condition
                                if (isSingleLabel) {
                                    // Replace only the numeric part, preserving unit (°C or RPM)
                                    $label.html(function (_, html) {
                                        const iSFahrenheit = html.includes("°F");
                                        return html.replace(
                                            /\d+/,
                                            sensor === "fans"
                                                ? data.rpm
                                                : (iSFahrenheit ? Math.round((data.temp * 9) / 5 + 32) : data.temp));
                                    });
                                } else {
                                    // Replace the numeric part after the colon, preserving prefix and unit
                                    $label.html(function (_, html) {
                                        const iSFahrenheit = html.includes("°F");
                                        return html.replace(
                                            /: \d+/,
                                            `: ${sensor === "fans" ? data.rpm :
                                                (iSFahrenheit ? Math.round((data.temp * 9) / 5 + 32) : data.temp)}`
                                        );
                                    });
                                }

                                const label_text = $label.text().replace(/.*?\d+:\s*/, "");
                                let className =
                                        HTML.label.textMaxLevels(sensor, label_text) ||
                                        (sideSlider
                                            ? this_.selector.defaultSliderClassLabel
                                            : this_.selector.defaultClassLabel);
                                if (sideSlider && className === this_.selector.defaultClassLabel) {
                                    className = this_.selector.defaultSliderClassLabel;
                                }
                                // Update class based on current label text
                                $label
                                    .removeClass((i, c) => (c.match(/\bbg-\S+/g) || []).join(" "))
                                    .addClass(className);
                            };
                            // Handle $lb1 labels
                            if (lb_count1 === 1) {
                                updateLabel($lb1, value[0], true);
                            } else {
                                // Handle multiple labels for $lb1
                                $lb1.each((index, el) => {
                                    if (value[index]) {
                                        updateLabel($(el), value[index], false);
                                    }
                                });
                            }
                            // Handle $lb2 labels similarly
                            if (lb_count2 === 1) {
                                updateLabel($lb2, value[0], true, true);
                            } else {
                                $lb2.each((index, el) => {
                                    if (value[index]) {
                                        updateLabel($(el), value[index], false, true);
                                    }
                                });
                            }
                        }
                    });
                }

                // Draw history graphs
                if (cached) {
                    let lds = `${this.selector.chart.container.parent}-${this.selector.collapse}`,
                        ld = $(`#${lds}`).find(`[${this.selector.chart.loader}]`);

                    // Process each supplied graph
                    Object.entries(data).map(([type, array]) => {
                        let options = {
                                chart: {
                                    type: () => {
                                        return type === "proc" || type === "disk" || type === "net";
                                    },
                                    bandwidth: () => {
                                        return type === "disk" || type === "net";
                                    },
                                    fill: function () {
                                        return this.type() ? false : true;
                                    },
                                    high: function () {
                                        return this.type() ? undefined : 100;
                                    },
                                    threshold: function () {
                                        return this.type() ? -1 : 80;
                                    },
                                    height: "100px",
                                },
                            },
                            lg = this._.language(`${this.selector.chart.container.parent}_${type}`),
                            tg = $(`#${lds}`).find(
                                `[${this.selector.chart.container.data}=${type}]`
                            ),
                            sr = [
                                {
                                    name: `series-${type}`,
                                    data: array,
                                },
                            ];

                        // Don't run further in case there is no container
                        if (!tg.length) {
                            return;
                        }

                        // Extend data object expected way to draw multiple series in single graph
                        if (array[0] && typeof array[0].y === "object") {
                            sr = [];
                            array[0].y.forEach(function (x, i) {
                                let data = [];
                                array.forEach(function (n) {
                                    data.push({
                                        data: { x: n.x, y: n.y[i] },
                                    });
                                });
                                sr.push({
                                    name: `series-${type}-${i}`,
                                    data: data,
                                });
                            });
                        }

                        // Update series if chart already
                        // exist unless it's a re-draw
                        if (tg[0] && tg[0].textContent && cached !== 3) {
                            if (cached === 1) {
                                let lf = parseInt(this.getStoredDuration());
                                if (lf < 300 || lf > 86400) {
                                    lf = 1200;
                                }
                                let tdata = sr,
                                    cdata = this[`chart_${type}`].data.series,
                                    cdata_start,
                                    cdata_end,
                                    cdata_ready = new Promise((resolve) => {
                                        tdata.forEach(function (d, i, a) {
                                            cdata_start =
                                                cdata[i].data[0].x || cdata[i].data[0].data.x;
                                            cdata_end =
                                                cdata[i].data[cdata[i].data.length - 1].x ||
                                                cdata[i].data[cdata[i].data.length - 1].data.x;
                                            cdata[i].data.push(d.data[0]);
                                            if (cdata_end - cdata_start > lf) {
                                                cdata[i].data.shift();
                                            }
                                            if (i === a.length - 1) {
                                                resolve();
                                            }
                                        });
                                    });
                                cdata_ready.then(() => {
                                    this[`chart_${type}`].update({
                                        series: cdata,
                                    });
                                });
                            }
                        }

                        // Initialize chart the first time (2) or fully
                        // update (3) the chart if it's already drawn
                        else if (cached === 2 || cached === 3) {
                            this[`chart_${type}`] = new this._.chart.Line(
                                tg[0],
                                {
                                    series: sr,
                                },
                                {
                                    axisX: {
                                        type: this._.chart.FixedScaleAxis,
                                        divisor: 12,
                                        labelInterpolationFnc: (value) => {
                                            return this._.dayjs(value * 1000)
                                                .utcOffset(this._.locale.offset())
                                                .format(this._.locale.time);
                                        },
                                    },
                                    height: options.chart.height,
                                    showArea: options.chart.fill(),
                                    showPoint: !options.chart.fill(),
                                    high: options.chart.high(),
                                    low: 0,
                                    fullWidth: true,
                                    chartPadding: {
                                        left: 25,
                                    },
                                    axisY: {
                                        onlyInteger: true,
                                        labelInterpolationFnc: (value) => {
                                            if (options.chart.fill()) {
                                                return value ? value + "%" : value;
                                            } else if (options.chart.bandwidth(value)) {
                                                if (type === "net") {
                                                    return value
                                                        ? this._.convert.size(value, {
                                                              fixed: 0,
                                                              bits: 1,
                                                              round: 1,
                                                          })
                                                        : value;
                                                }
                                                return value
                                                    ? this._.convert.size(value * 1024, {
                                                          fixed: 0,
                                                          round: 1,
                                                      })
                                                    : value;
                                            } else {
                                                return value;
                                            }
                                        },
                                    },
                                    plugins: [
                                        this._.chart.plugins.ctAxisTitle({
                                            axisY: {
                                                axisTitle: lg,
                                                axisClass: "ct-axis-title",
                                                offset: {
                                                    x: 0,
                                                    y: 9,
                                                },
                                                flipTitle: true,
                                            },
                                        }),
                                        this._.chart.plugins.ctThreshold({
                                            threshold: options.chart.threshold(),
                                        }),
                                    ],
                                }
                            );

                            // Remove loader
                            this[`chart_${type}`].on("created", (data) => {
                                // Add labels to the first foreign object
                                const ffObj = data.svg.getNode().querySelector('foreignObject');
                                if (ffObj) {
                                    const readLbl = this._.language(`dashboard_chart_${type}_read`),
                                          writeLbl = this._.language(`dashboard_chart_${type}_write`);
                                    if (readLbl && writeLbl) {
                                        ffObj.setAttribute('data-label-read', `▪ ${readLbl}`);
                                        ffObj.setAttribute('data-label-write', `▪ ${writeLbl}`);
                                    }
                                }
                                // Clean-up loader
                                ld.remove();
                              });
                        }
                    });
                }
            });
        },
    },
};