• socket.js
  • import { io } from "socket.io-client";
    import { useToast } from "vue-toastification";
    import jwtDecode from "jwt-decode";
    import Favico from "favico.js";
    import dayjs from "dayjs";
    import mitt from "mitt";
    
    import { DOWN, MAINTENANCE, PENDING, UP } from "../util.ts";
    import { getDevContainerServerHostname, isDevContainer, getToastSuccessTimeout, getToastErrorTimeout } from "../util-frontend.js";
    const toast = useToast();
    
    let socket;
    
    const noSocketIOPages = [
        /^\/status-page$/,  //  /status-page
        /^\/status/,    // /status**
        /^\/$/      //  /
    ];
    
    const favicon = new Favico({
        animation: "none"
    });
    
    export default {
    
        data() {
            return {
                info: { },
                socket: {
                    token: null,
                    firstConnect: true,
                    connected: false,
                    connectCount: 0,
                    initedSocketIO: false,
                },
                username: null,
                remember: (localStorage.remember !== "0"),
                allowLoginDialog: false,        // Allowed to show login dialog, but "loggedIn" have to be true too. This exists because prevent the login dialog show 0.1s in first before the socket server auth-ed.
                loggedIn: false,
                monitorList: { },
                monitorTypeList: {},
                maintenanceList: {},
                apiKeyList: {},
                heartbeatList: { },
                avgPingList: { },
                uptimeList: { },
                tlsInfoList: {},
                notificationList: [],
                dockerHostList: [],
                remoteBrowserList: [],
                statusPageListLoaded: false,
                statusPageList: [],
                proxyList: [],
                connectionErrorMsg: `${this.$t("Cannot connect to the socket server.")} ${this.$t("Reconnecting...")}`,
                showReverseProxyGuide: true,
                cloudflared: {
                    cloudflareTunnelToken: "",
                    installed: null,
                    running: false,
                    message: "",
                    errorMessage: "",
                    currentPassword: "",
                },
                faviconUpdateDebounce: null,
                emitter: mitt(),
            };
        },
    
        created() {
            this.initSocketIO();
        },
    
        methods: {
    
            /**
             * Initialize connection to socket server
             * @param {boolean} bypass Should the check for if we
             * are on a status page be bypassed?
             * @returns {void}
             */
            initSocketIO(bypass = false) {
                // No need to re-init
                if (this.socket.initedSocketIO) {
                    return;
                }
    
                // No need to connect to the socket.io for status page
                if (! bypass && location.pathname) {
                    for (let page of noSocketIOPages) {
                        if (location.pathname.match(page)) {
                            return;
                        }
                    }
                }
    
                // Also don't need to connect to the socket.io for setup database page
                if (location.pathname === "/setup-database") {
                    return;
                }
    
                this.socket.initedSocketIO = true;
    
                let protocol = location.protocol + "//";
    
                let url;
                const env = process.env.NODE_ENV || "production";
                if (env === "development" && isDevContainer()) {
                    url = protocol + getDevContainerServerHostname();
                } else if (env === "development" || localStorage.dev === "dev") {
                    url = protocol + location.hostname + ":3001";
                } else {
                    // Connect to the current url
                    url = undefined;
                }
    
                socket = io(url);
    
                socket.on("info", (info) => {
                    this.info = info;
                });
    
                socket.on("setup", (monitorID, data) => {
                    this.$router.push("/setup");
                });
    
                socket.on("autoLogin", (monitorID, data) => {
                    this.loggedIn = true;
                    this.storage().token = "autoLogin";
                    this.socket.token = "autoLogin";
                    this.allowLoginDialog = false;
                });
    
                socket.on("loginRequired", () => {
                    let token = this.storage().token;
                    if (token && token !== "autoLogin") {
                        this.loginByToken(token);
                    } else {
                        this.$root.storage().removeItem("token");
                        this.allowLoginDialog = true;
                    }
                });
    
                socket.on("monitorList", (data) => {
                    this.assignMonitorUrlParser(data);
                    this.monitorList = data;
                });
    
                socket.on("updateMonitorIntoList", (data) => {
                    this.assignMonitorUrlParser(data);
                    Object.entries(data).forEach(([ monitorID, updatedMonitor ]) => {
                        this.monitorList[monitorID] = updatedMonitor;
                    });
                });
    
                socket.on("deleteMonitorFromList", (monitorID) => {
                    if (this.monitorList[monitorID]) {
                        delete this.monitorList[monitorID];
                    }
                });
    
                socket.on("monitorTypeList", (data) => {
                    this.monitorTypeList = data;
                });
    
                socket.on("maintenanceList", (data) => {
                    this.maintenanceList = data;
                });
    
                socket.on("apiKeyList", (data) => {
                    this.apiKeyList = data;
                });
    
                socket.on("notificationList", (data) => {
                    this.notificationList = data;
                });
    
                socket.on("statusPageList", (data) => {
                    this.statusPageListLoaded = true;
                    this.statusPageList = data;
                });
    
                socket.on("proxyList", (data) => {
                    this.proxyList = data.map(item => {
                        item.auth = !!item.auth;
                        item.active = !!item.active;
                        item.default = !!item.default;
    
                        return item;
                    });
                });
    
                socket.on("dockerHostList", (data) => {
                    this.dockerHostList = data;
                });
    
                socket.on("remoteBrowserList", (data) => {
                    this.remoteBrowserList = data;
                });
    
                socket.on("heartbeat", (data) => {
                    if (! (data.monitorID in this.heartbeatList)) {
                        this.heartbeatList[data.monitorID] = [];
                    }
    
                    this.heartbeatList[data.monitorID].push(data);
    
                    if (this.heartbeatList[data.monitorID].length >= 150) {
                        this.heartbeatList[data.monitorID].shift();
                    }
    
                    // Add to important list if it is important
                    // Also toast
                    if (data.important) {
    
                        if (this.monitorList[data.monitorID] !== undefined) {
                            if (data.status === 0) {
                                toast.error(`[${this.monitorList[data.monitorID].name}] [DOWN] ${data.msg}`, {
                                    timeout: getToastErrorTimeout(),
                                });
                            } else if (data.status === 1) {
                                toast.success(`[${this.monitorList[data.monitorID].name}] [Up] ${data.msg}`, {
                                    timeout: getToastSuccessTimeout(),
                                });
                            } else {
                                toast(`[${this.monitorList[data.monitorID].name}] ${data.msg}`);
                            }
                        }
    
                        this.emitter.emit("newImportantHeartbeat", data);
                    }
                });
    
                socket.on("heartbeatList", (monitorID, data, overwrite = false) => {
                    if (! (monitorID in this.heartbeatList) || overwrite) {
                        this.heartbeatList[monitorID] = data;
                    } else {
                        this.heartbeatList[monitorID] = data.concat(this.heartbeatList[monitorID]);
                    }
                });
    
                socket.on("avgPing", (monitorID, data) => {
                    this.avgPingList[monitorID] = data;
                });
    
                socket.on("uptime", (monitorID, type, data) => {
                    this.uptimeList[`${monitorID}_${type}`] = data;
                });
    
                socket.on("certInfo", (monitorID, data) => {
                    this.tlsInfoList[monitorID] = JSON.parse(data);
                });
    
                socket.on("connect_error", (err) => {
                    console.error(`Failed to connect to the backend. Socket.io connect_error: ${err.message}`);
                    this.connectionErrorMsg = `${this.$t("Cannot connect to the socket server.")} [${err}] ${this.$t("Reconnecting...")}`;
                    this.showReverseProxyGuide = true;
                    this.socket.connected = false;
                    this.socket.firstConnect = false;
                });
    
                socket.on("disconnect", () => {
                    console.log("disconnect");
                    this.connectionErrorMsg = `${this.$t("Lost connection to the socket server.")} ${this.$t("Reconnecting...")}`;
                    this.socket.connected = false;
                });
    
                socket.on("connect", () => {
                    console.log("Connected to the socket server");
                    this.socket.connectCount++;
                    this.socket.connected = true;
                    this.showReverseProxyGuide = false;
    
                    // Reset Heartbeat list if it is re-connect
                    if (this.socket.connectCount >= 2) {
                        this.clearData();
                    }
    
                    this.socket.firstConnect = false;
                });
    
                // cloudflared
                socket.on("cloudflared_installed", (res) => this.cloudflared.installed = res);
                socket.on("cloudflared_running", (res) => this.cloudflared.running = res);
                socket.on("cloudflared_message", (res) => this.cloudflared.message = res);
                socket.on("cloudflared_errorMessage", (res) => this.cloudflared.errorMessage = res);
                socket.on("cloudflared_token", (res) => this.cloudflared.cloudflareTunnelToken = res);
    
                socket.on("initServerTimezone", () => {
                    socket.emit("initServerTimezone", dayjs.tz.guess());
                });
    
                socket.on("refresh", () => {
                    location.reload();
                });
            },
            /**
             * parse all urls from list.
             * @param {object} data Monitor data to modify
             * @returns {object} list
             */
            assignMonitorUrlParser(data) {
                Object.entries(data).forEach(([ monitorID, monitor ]) => {
                    monitor.getUrl = () => {
                        try {
                            return new URL(monitor.url);
                        } catch (_) {
                            return null;
                        }
                    };
                });
                return data;
            },
    
            /**
             * The storage currently in use
             * @returns {Storage} Current storage
             */
            storage() {
                return (this.remember) ? localStorage : sessionStorage;
            },
    
            /**
             * Get payload of JWT cookie
             * @returns {(object | undefined)} JWT payload
             */
            getJWTPayload() {
                const jwtToken = this.$root.storage().token;
    
                if (jwtToken && jwtToken !== "autoLogin") {
                    return jwtDecode(jwtToken);
                }
                return undefined;
            },
    
            /**
             * Get current socket
             * @returns {Socket} Current socket
             */
            getSocket() {
                return socket;
            },
    
            /**
             * Show success or error toast dependent on response status code
             * @param {object} res Response object
             * @returns {void}
             */
            toastRes(res) {
                let msg = res.msg;
                if (res.msgi18n) {
                    if (msg != null && typeof msg === "object") {
                        msg = this.$t(msg.key, msg.values);
                    } else {
                        msg = this.$t(msg);
                    }
                }
    
                if (res.ok) {
                    toast.success(msg);
                } else {
                    toast.error(msg);
                }
            },
    
            /**
             * Show a success toast
             * @param {string} msg Message to show
             * @returns {void}
             */
            toastSuccess(msg) {
                toast.success(this.$t(msg));
            },
    
            /**
             * Show an error toast
             * @param {string} msg Message to show
             * @returns {void}
             */
            toastError(msg) {
                toast.error(this.$t(msg));
            },
    
            /**
             * Callback for login
             * @callback loginCB
             * @param {object} res Response object
             */
    
            /**
             * Send request to log user in
             * @param {string} username Username to log in with
             * @param {string} password Password to log in with
             * @param {string} token User token
             * @param {loginCB} callback Callback to call with result
             * @returns {void}
             */
            login(username, password, token, callback) {
                socket.emit("login", {
                    username,
                    password,
                    token,
                }, (res) => {
                    if (res.tokenRequired) {
                        callback(res);
                    }
    
                    if (res.ok) {
                        this.storage().token = res.token;
                        this.socket.token = res.token;
                        this.loggedIn = true;
                        this.username = this.getJWTPayload()?.username;
    
                        // Trigger Chrome Save Password
                        history.pushState({}, "");
                    }
    
                    callback(res);
                });
            },
    
            /**
             * Log in using a token
             * @param {string} token Token to log in with
             * @returns {void}
             */
            loginByToken(token) {
                socket.emit("loginByToken", token, (res) => {
                    this.allowLoginDialog = true;
    
                    if (! res.ok) {
                        this.logout();
                    } else {
                        this.loggedIn = true;
                        this.username = this.getJWTPayload()?.username;
                    }
                });
            },
    
            /**
             * Log out of the web application
             * @returns {void}
             */
            logout() {
                socket.emit("logout", () => { });
                this.storage().removeItem("token");
                this.socket.token = null;
                this.loggedIn = false;
                this.username = null;
                this.clearData();
            },
    
            /**
             * Callback for general socket requests
             * @callback socketCB
             * @param {object} res Result of operation
             */
            /**
             * Prepare 2FA configuration
             * @param {socketCB} callback Callback for socket response
             * @returns {void}
             */
            prepare2FA(callback) {
                socket.emit("prepare2FA", callback);
            },
    
            /**
             * Save the current 2FA configuration
             * @param {any} secret Unused
             * @param {socketCB} callback Callback for socket response
             * @returns {void}
             */
            save2FA(secret, callback) {
                socket.emit("save2FA", callback);
            },
    
            /**
             * Disable 2FA for this user
             * @param {socketCB} callback Callback for socket response
             * @returns {void}
             */
            disable2FA(callback) {
                socket.emit("disable2FA", callback);
            },
    
            /**
             * Verify the provided 2FA token
             * @param {string} token Token to verify
             * @param {socketCB} callback Callback for socket response
             * @returns {void}
             */
            verifyToken(token, callback) {
                socket.emit("verifyToken", token, callback);
            },
    
            /**
             * Get current 2FA status
             * @param {socketCB} callback Callback for socket response
             * @returns {void}
             */
            twoFAStatus(callback) {
                socket.emit("twoFAStatus", callback);
            },
    
            /**
             * Get list of monitors
             * @param {socketCB} callback Callback for socket response
             * @returns {void}
             */
            getMonitorList(callback) {
                if (! callback) {
                    callback = () => { };
                }
                socket.emit("getMonitorList", callback);
            },
    
            /**
             * Get list of maintenances
             * @param {socketCB} callback Callback for socket response
             * @returns {void}
             */
            getMaintenanceList(callback) {
                if (! callback) {
                    callback = () => { };
                }
                socket.emit("getMaintenanceList", callback);
            },
    
            /**
             * Send list of API keys
             * @param {socketCB} callback Callback for socket response
             * @returns {void}
             */
            getAPIKeyList(callback) {
                if (!callback) {
                    callback = () => { };
                }
                socket.emit("getAPIKeyList", callback);
            },
    
            /**
             * Add a monitor
             * @param {object} monitor Object representing monitor to add
             * @param {socketCB} callback Callback for socket response
             * @returns {void}
             */
            add(monitor, callback) {
                socket.emit("add", monitor, callback);
            },
    
            /**
             * Adds a maintenance
             * @param {object} maintenance Maintenance to add
             * @param {socketCB} callback Callback for socket response
             * @returns {void}
             */
            addMaintenance(maintenance, callback) {
                socket.emit("addMaintenance", maintenance, callback);
            },
    
            /**
             * Add monitors to maintenance
             * @param {number} maintenanceID Maintenance to modify
             * @param {number[]} monitors IDs of monitors to add
             * @param {socketCB} callback Callback for socket response
             * @returns {void}
             */
            addMonitorMaintenance(maintenanceID, monitors, callback) {
                socket.emit("addMonitorMaintenance", maintenanceID, monitors, callback);
            },
    
            /**
             * Add status page to maintenance
             * @param {number} maintenanceID Maintenance to modify
             * @param {number} statusPages ID of status page to add
             * @param {socketCB} callback Callback for socket response
             * @returns {void}
             */
            addMaintenanceStatusPage(maintenanceID, statusPages, callback) {
                socket.emit("addMaintenanceStatusPage", maintenanceID, statusPages, callback);
            },
    
            /**
             * Get monitors affected by maintenance
             * @param {number} maintenanceID Maintenance to read
             * @param {socketCB} callback Callback for socket response
             * @returns {void}
             */
            getMonitorMaintenance(maintenanceID, callback) {
                socket.emit("getMonitorMaintenance", maintenanceID, callback);
            },
    
            /**
             * Get status pages where maintenance is shown
             * @param {number} maintenanceID Maintenance to read
             * @param {socketCB} callback Callback for socket response
             * @returns {void}
             */
            getMaintenanceStatusPage(maintenanceID, callback) {
                socket.emit("getMaintenanceStatusPage", maintenanceID, callback);
            },
    
            /**
             * Delete monitor by ID
             * @param {number} monitorID ID of monitor to delete
             * @param {socketCB} callback Callback for socket response
             * @returns {void}
             */
            deleteMonitor(monitorID, callback) {
                socket.emit("deleteMonitor", monitorID, callback);
            },
    
            /**
             * Delete specified maintenance
             * @param {number} maintenanceID Maintenance to delete
             * @param {socketCB} callback Callback for socket response
             * @returns {void}
             */
            deleteMaintenance(maintenanceID, callback) {
                socket.emit("deleteMaintenance", maintenanceID, callback);
            },
    
            /**
             * Add an API key
             * @param {object} key API key to add
             * @param {socketCB} callback Callback for socket response
             * @returns {void}
             */
            addAPIKey(key, callback) {
                socket.emit("addAPIKey", key, callback);
            },
    
            /**
             * Delete specified API key
             * @param {int} keyID ID of key to delete
             * @param {socketCB} callback Callback for socket response
             * @returns {void}
             */
            deleteAPIKey(keyID, callback) {
                socket.emit("deleteAPIKey", keyID, callback);
            },
    
            /**
             * Clear the hearbeat list
             * @returns {void}
             */
            clearData() {
                console.log("reset heartbeat list");
                this.heartbeatList = {};
            },
    
            /**
             * Upload the provided backup
             * @param {string} uploadedJSON JSON to upload
             * @param {string} importHandle Type of import. If set to
             * most data in database will be replaced
             * @param {socketCB} callback Callback for socket response
             * @returns {void}
             */
            uploadBackup(uploadedJSON, importHandle, callback) {
                socket.emit("uploadBackup", uploadedJSON, importHandle, callback);
            },
    
            /**
             * Clear events for a specified monitor
             * @param {number} monitorID ID of monitor to clear
             * @param {socketCB} callback Callback for socket response
             * @returns {void}
             */
            clearEvents(monitorID, callback) {
                socket.emit("clearEvents", monitorID, callback);
            },
    
            /**
             * Clear the heartbeats of a specified monitor
             * @param {number} monitorID Id of monitor to clear
             * @param {socketCB} callback Callback for socket response
             * @returns {void}
             */
            clearHeartbeats(monitorID, callback) {
                socket.emit("clearHeartbeats", monitorID, callback);
            },
    
            /**
             * Clear all statistics
             * @param {socketCB} callback Callback for socket response
             * @returns {void}
             */
            clearStatistics(callback) {
                socket.emit("clearStatistics", callback);
            },
    
            /**
             * Get monitor beats for a specific monitor in a time range
             * @param {number} monitorID ID of monitor to fetch
             * @param {number} period Time in hours from now
             * @param {socketCB} callback Callback for socket response
             * @returns {void}
             */
            getMonitorBeats(monitorID, period, callback) {
                socket.emit("getMonitorBeats", monitorID, period, callback);
            },
    
            /**
             * Retrieves monitor chart data.
             * @param {string} monitorID - The ID of the monitor.
             * @param {number} period - The time period for the chart data, in hours.
             * @param {socketCB} callback - The callback function to handle the chart data.
             * @returns {void}
             */
            getMonitorChartData(monitorID, period, callback) {
                socket.emit("getMonitorChartData", monitorID, period, callback);
            }
        },
    
        computed: {
    
            usernameFirstChar() {
                if (typeof this.username == "string" && this.username.length >= 1) {
                    return this.username.charAt(0).toUpperCase();
                } else {
                    return "🐻";
                }
            },
    
            lastHeartbeatList() {
                let result = {};
    
                for (let monitorID in this.heartbeatList) {
                    let index = this.heartbeatList[monitorID].length - 1;
                    result[monitorID] = this.heartbeatList[monitorID][index];
                }
    
                return result;
            },
    
            statusList() {
                let result = {};
    
                let unknown = {
                    text: this.$t("Unknown"),
                    color: "secondary",
                };
    
                for (let monitorID in this.lastHeartbeatList) {
                    let lastHeartBeat = this.lastHeartbeatList[monitorID];
    
                    if (! lastHeartBeat) {
                        result[monitorID] = unknown;
                    } else if (lastHeartBeat.status === UP) {
                        result[monitorID] = {
                            text: this.$t("Up"),
                            color: "primary",
                        };
                    } else if (lastHeartBeat.status === DOWN) {
                        result[monitorID] = {
                            text: this.$t("Down"),
                            color: "danger",
                        };
                    } else if (lastHeartBeat.status === PENDING) {
                        result[monitorID] = {
                            text: this.$t("Pending"),
                            color: "warning",
                        };
                    } else if (lastHeartBeat.status === MAINTENANCE) {
                        result[monitorID] = {
                            text: this.$t("statusMaintenance"),
                            color: "maintenance",
                        };
                    } else {
                        result[monitorID] = unknown;
                    }
                }
    
                return result;
            },
    
            stats() {
                let result = {
                    active: 0,
                    up: 0,
                    down: 0,
                    maintenance: 0,
                    pending: 0,
                    unknown: 0,
                    pause: 0,
                };
    
                for (let monitorID in this.$root.monitorList) {
                    let beat = this.$root.lastHeartbeatList[monitorID];
                    let monitor = this.$root.monitorList[monitorID];
    
                    if (monitor && ! monitor.active) {
                        result.pause++;
                    } else if (beat) {
                        result.active++;
                        if (beat.status === UP) {
                            result.up++;
                        } else if (beat.status === DOWN) {
                            result.down++;
                        } else if (beat.status === PENDING) {
                            result.pending++;
                        } else if (beat.status === MAINTENANCE) {
                            result.maintenance++;
                        } else {
                            result.unknown++;
                        }
                    } else {
                        result.unknown++;
                    }
                }
    
                return result;
            },
    
            /**
             *  Frontend Version
             *  It should be compiled to a static value while building the frontend.
             *  Please see ./config/vite.config.js, it is defined via vite.js
             * @returns {string} Current version
             */
            frontendVersion() {
                // eslint-disable-next-line no-undef
                return FRONTEND_VERSION;
            },
    
            /**
             * Are both frontend and backend in the same version?
             * @returns {boolean} The frontend and backend match?
             */
            isFrontendBackendVersionMatched() {
                if (!this.info.version) {
                    return true;
                }
                return this.info.version === this.frontendVersion;
            }
        },
    
        watch: {
    
            // Update Badge
            "stats.down"(to, from) {
                if (to !== from) {
                    if (this.faviconUpdateDebounce != null) {
                        clearTimeout(this.faviconUpdateDebounce);
                    }
                    this.faviconUpdateDebounce = setTimeout(() => {
                        favicon.badge(to);
                    }, 1000);
                }
            },
    
            // Reload the SPA if the server version is changed.
            "info.version"(to, from) {
                if (from && from !== to) {
                    window.location.reload();
                }
            },
    
            remember() {
                localStorage.remember = (this.remember) ? "1" : "0";
            },
    
            // Reconnect the socket io, if status-page to dashboard
            "$route.fullPath"(newValue, oldValue) {
    
                if (newValue) {
                    for (let page of noSocketIOPages) {
                        if (newValue.match(page)) {
                            return;
                        }
                    }
                }
    
                this.initSocketIO();
            },
    
        },
    
    };