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(); }, }, };