diff --git a/server/database.js b/server/database.js index 5ca88cac..06b81153 100644 --- a/server/database.js +++ b/server/database.js @@ -4,6 +4,7 @@ const { setSetting, setting } = require("./util-server"); const { log, sleep } = require("../src/util"); const dayjs = require("dayjs"); const knex = require("knex"); +const { PluginsManager } = require("./plugins-manager"); /** * Database & App Data Folder @@ -85,6 +86,13 @@ class Database { static init(args) { // Data Directory (must be end with "/") Database.dataDir = process.env.DATA_DIR || args["data-dir"] || "./data/"; + + // Plugin feature is working only if the dataDir = "./data"; + if (Database.dataDir !== "./data/") { + log.warn("PLUGIN", "Warning: In order to enable plugin feature, you need to use the default data directory: ./data/"); + PluginsManager.disable = true; + } + Database.path = Database.dataDir + "kuma.db"; if (! fs.existsSync(Database.dataDir)) { fs.mkdirSync(Database.dataDir, { recursive: true }); diff --git a/server/git.js b/server/git.js new file mode 100644 index 00000000..77a12d93 --- /dev/null +++ b/server/git.js @@ -0,0 +1,24 @@ +const childProcess = require("child_process"); + +class Git { + + static clone(repoURL, cwd, targetDir = ".") { + let result = childProcess.spawnSync("git", [ + "clone", + repoURL, + targetDir, + ], { + cwd: cwd, + }); + + if (result.status !== 0) { + throw new Error(result.stderr.toString("utf-8")); + } else { + return result.stdout.toString("utf-8") + result.stderr.toString("utf-8"); + } + } +} + +module.exports = { + Git, +}; diff --git a/server/model/monitor.js b/server/model/monitor.js index e06c15e2..c3e91f93 100644 --- a/server/model/monitor.js +++ b/server/model/monitor.js @@ -655,9 +655,17 @@ class Monitor extends BeanModel { bean.msg = await redisPingAsync(this.databaseConnectionString); bean.status = UP; bean.ping = dayjs().valueOf() - startTime; + + } else if (this.type in UptimeKumaServer.monitorTypeList) { + let startTime = dayjs().valueOf(); + const monitorType = UptimeKumaServer.monitorTypeList[this.type]; + await monitorType.check(this, bean); + if (!bean.ping) { + bean.ping = dayjs().valueOf() - startTime; + } + } else { - bean.msg = "Unknown Monitor Type"; - bean.status = PENDING; + throw new Error("Unknown Monitor Type"); } if (this.isUpsideDown()) { diff --git a/server/monitor-types/monitor-type.js b/server/monitor-types/monitor-type.js new file mode 100644 index 00000000..f2c7cbee --- /dev/null +++ b/server/monitor-types/monitor-type.js @@ -0,0 +1,19 @@ +class MonitorType { + + name = undefined; + + /** + * + * @param {Monitor} monitor + * @param {Heartbeat} heartbeat + * @returns {Promise} + */ + async check(monitor, heartbeat) { + throw new Error("You need to override check()"); + } + +} + +module.exports = { + MonitorType, +}; diff --git a/server/notification-providers/clicksendsms.js b/server/notification-providers/clicksendsms.js index e66b982c..1df05309 100644 --- a/server/notification-providers/clicksendsms.js +++ b/server/notification-providers/clicksendsms.js @@ -8,7 +8,6 @@ class ClickSendSMS extends NotificationProvider { async send(notification, msg, monitorJSON = null, heartbeatJSON = null) { let okMsg = "Sent Successfully."; try { - console.log({ notification }); let config = { headers: { "Content-Type": "application/json", diff --git a/server/plugin.js b/server/plugin.js new file mode 100644 index 00000000..8b687ad6 --- /dev/null +++ b/server/plugin.js @@ -0,0 +1,13 @@ +class Plugin { + async load() { + + } + + async unload() { + + } +} + +module.exports = { + Plugin, +}; diff --git a/server/plugins-manager.js b/server/plugins-manager.js new file mode 100644 index 00000000..e48c53c8 --- /dev/null +++ b/server/plugins-manager.js @@ -0,0 +1,235 @@ +const fs = require("fs"); +const { log } = require("../src/util"); +const path = require("path"); +const axios = require("axios"); +const { Git } = require("./git"); +const childProcess = require("child_process"); + +class PluginsManager { + + static disable = false; + + /** + * Plugin List + * @type {PluginWrapper[]} + */ + pluginList = []; + + /** + * Plugins Dir + */ + pluginsDir; + + server; + + /** + * + * @param {UptimeKumaServer} server + */ + constructor(server) { + this.server = server; + + if (!PluginsManager.disable) { + this.pluginsDir = "./data/plugins/"; + + if (! fs.existsSync(this.pluginsDir)) { + fs.mkdirSync(this.pluginsDir, { recursive: true }); + } + + log.debug("plugin", "Scanning plugin directory"); + let list = fs.readdirSync(this.pluginsDir); + + this.pluginList = []; + for (let item of list) { + this.loadPlugin(item); + } + + } else { + log.warn("PLUGIN", "Skip scanning plugin directory"); + } + + } + + /** + * Install a Plugin + */ + async loadPlugin(name) { + log.info("plugin", "Load " + name); + let plugin = new PluginWrapper(this.server, this.pluginsDir + name); + + try { + await plugin.load(); + this.pluginList.push(plugin); + } catch (e) { + log.error("plugin", "Failed to load plugin: " + this.pluginsDir + name); + log.error("plugin", "Reason: " + e.message); + } + } + + /** + * Download a Plugin + * @param {string} repoURL Git repo url + * @param {string} name Directory name, also known as plugin unique name + */ + downloadPlugin(repoURL, name) { + log.info("plugin", "Installing plugin: " + name + " " + repoURL); + let result = Git.clone(repoURL, this.pluginsDir, name); + log.info("plugin", "Install result: " + result); + } + + /** + * Remove a plugin + * @param {string} name + */ + async removePlugin(name) { + log.info("plugin", "Removing plugin: " + name); + for (let plugin of this.pluginList) { + if (plugin.info.name === name) { + await plugin.unload(); + + // Delete the plugin directory + fs.rmSync(this.pluginsDir + name, { + recursive: true + }); + + this.pluginList.splice(this.pluginList.indexOf(plugin), 1); + return; + } + } + log.warn("plugin", "Plugin not found: " + name); + throw new Error("Plugin not found: " + name); + } + + /** + * TODO: Update a plugin + * Only available for plugins which were downloaded from the official list + * @param pluginID + */ + updatePlugin(pluginID) { + + } + + /** + * Get the plugin list from server + local installed plugin list + * Item will be merged if the `name` is the same. + * @returns {Promise<[]>} + */ + async fetchPluginList() { + const res = await axios.get("https://uptime.kuma.pet/c/plugins.json"); + const list = res.data.pluginList; + + for (let plugin of this.pluginList) { + let find = false; + // Try to merge + for (let remotePlugin of list) { + if (remotePlugin.name === plugin.info.name) { + find = true; + remotePlugin.installed = true; + remotePlugin.name = plugin.info.name; + remotePlugin.fullName = plugin.info.fullName; + remotePlugin.description = plugin.info.description; + remotePlugin.version = plugin.info.version; + break; + } + } + + // Local plugin + if (!find) { + plugin.info.local = true; + list.push(plugin.info); + } + } + + // Sort Installed first, then sort by name + return list.sort((a, b) => { + if (a.installed === b.installed) { + if ( a.fullName < b.fullName ) { + return -1; + } + if ( a.fullName > b.fullName ) { + return 1; + } + return 0; + } else if (a.installed) { + return -1; + } else { + return 1; + } + }); + } +} + +class PluginWrapper { + + server = undefined; + pluginDir = undefined; + + /** + * Must be an `new-able` class. + * @type {function} + */ + pluginClass = undefined; + + /** + * + * @type {Plugin} + */ + object = undefined; + info = {}; + + /** + * + * @param {UptimeKumaServer} server + * @param {string} pluginDir + */ + constructor(server, pluginDir) { + this.server = server; + this.pluginDir = pluginDir; + } + + async load() { + let indexFile = this.pluginDir + "/index.js"; + let packageJSON = this.pluginDir + "/package.json"; + + if (fs.existsSync(indexFile)) { + // Install dependencies + childProcess.execSync("npm install", { + cwd: this.pluginDir, + env: { + PLAYWRIGHT_BROWSERS_PATH: "../../browsers", // Special handling for read-browser-monitor + } + }); + + this.pluginClass = require(path.join(process.cwd(), indexFile)); + + let pluginClassType = typeof this.pluginClass; + + if (pluginClassType === "function") { + this.object = new this.pluginClass(this.server); + await this.object.load(); + } else { + throw new Error("Invalid plugin, it does not export a class"); + } + + if (fs.existsSync(packageJSON)) { + this.info = require(path.join(process.cwd(), packageJSON)); + } else { + this.info.fullName = this.pluginDir; + this.info.name = "[unknown]"; + this.info.version = "[unknown-version]"; + } + + this.info.installed = true; + log.info("plugin", `${this.info.fullName} v${this.info.version} loaded`); + } + } + + async unload() { + await this.object.unload(); + } +} + +module.exports = { + PluginsManager, + PluginWrapper +}; diff --git a/server/server.js b/server/server.js index 9c18fcd1..5fbc8e55 100644 --- a/server/server.js +++ b/server/server.js @@ -138,6 +138,7 @@ const { maintenanceSocketHandler } = require("./socket-handlers/maintenance-sock const { generalSocketHandler } = require("./socket-handlers/general-socket-handler"); const { Settings } = require("./settings"); const { CacheableDnsHttpAgent } = require("./cacheable-dns-http-agent"); +const { pluginsHandler } = require("./socket-handlers/plugins-handler"); app.use(express.json()); @@ -166,7 +167,7 @@ let needSetup = false; Database.init(args); await initDatabase(testMode); await server.initAfterDatabaseReady(); - + server.loadPlugins(); server.entryPage = await Settings.get("entryPage"); await StatusPage.loadDomainMappingList(); @@ -574,7 +575,6 @@ let needSetup = false; }); } } catch (error) { - console.log(error); callback({ ok: false, msg: error.message, @@ -1501,6 +1501,7 @@ let needSetup = false; dockerSocketHandler(socket); maintenanceSocketHandler(socket); generalSocketHandler(socket, server); + pluginsHandler(socket, server); log.debug("server", "added all socket handlers"); diff --git a/server/socket-handlers/plugins-handler.js b/server/socket-handlers/plugins-handler.js new file mode 100644 index 00000000..4ee712c7 --- /dev/null +++ b/server/socket-handlers/plugins-handler.js @@ -0,0 +1,65 @@ +const { checkLogin } = require("../util-server"); +const { PluginManager } = require("../plugins-manager"); + +/** + * Handlers for plugins + * @param {Socket} socket Socket.io instance + * @param {UptimeKumaServer} server + */ +module.exports.pluginsHandler = (socket, server) => { + + const pluginManager = server.getPluginManager(); + + // Get Plugin List + socket.on("getPluginList", async (callback) => { + try { + checkLogin(socket); + + if (PluginManager.disable) { + throw new Error("Plugin Disabled: In order to enable plugin feature, you need to use the default data directory: ./data/"); + } + + let pluginList = await pluginManager.fetchPluginList(); + callback({ + ok: true, + pluginList, + }); + } catch (error) { + callback({ + ok: false, + msg: error.message, + }); + } + }); + + socket.on("installPlugin", async (repoURL, name, callback) => { + try { + checkLogin(socket); + pluginManager.downloadPlugin(repoURL, name); + await pluginManager.loadPlugin(name); + callback({ + ok: true, + }); + } catch (error) { + callback({ + ok: false, + msg: error.message, + }); + } + }); + + socket.on("uninstallPlugin", async (name, callback) => { + try { + checkLogin(socket); + await pluginManager.removePlugin(name); + callback({ + ok: true, + }); + } catch (error) { + callback({ + ok: false, + msg: error.message, + }); + } + }); +}; diff --git a/server/uptime-kuma-server.js b/server/uptime-kuma-server.js index faffb98b..0573f0d8 100644 --- a/server/uptime-kuma-server.js +++ b/server/uptime-kuma-server.js @@ -10,6 +10,7 @@ const util = require("util"); const { CacheableDnsHttpAgent } = require("./cacheable-dns-http-agent"); const { Settings } = require("./settings"); const dayjs = require("dayjs"); +const { PluginsManager } = require("./plugins-manager"); // DO NOT IMPORT HERE IF THE MODULES USED `UptimeKumaServer.getInstance()` /** @@ -48,6 +49,20 @@ class UptimeKumaServer { generateMaintenanceTimeslotsInterval = undefined; + /** + * Plugins Manager + * @type {PluginsManager} + */ + pluginsManager = null; + + /** + * + * @type {{}} + */ + static monitorTypeList = { + + }; + static getInstance(args) { if (UptimeKumaServer.instance == null) { UptimeKumaServer.instance = new UptimeKumaServer(args); @@ -272,6 +287,46 @@ class UptimeKumaServer { async stop() { clearTimeout(this.generateMaintenanceTimeslotsInterval); } + + loadPlugins() { + this.pluginsManager = new PluginsManager(this); + } + + /** + * + * @returns {PluginsManager} + */ + getPluginManager() { + return this.pluginsManager; + } + + /** + * + * @param {MonitorType} monitorType + */ + addMonitorType(monitorType) { + if (monitorType instanceof MonitorType && monitorType.name) { + if (monitorType.name in UptimeKumaServer.monitorTypeList) { + log.error("", "Conflict Monitor Type name"); + } + UptimeKumaServer.monitorTypeList[monitorType.name] = monitorType; + } else { + log.error("", "Invalid Monitor Type: " + monitorType.name); + } + } + + /** + * + * @param {MonitorType} monitorType + */ + removeMonitorType(monitorType) { + if (UptimeKumaServer.monitorTypeList[monitorType.name] === monitorType) { + delete UptimeKumaServer.monitorTypeList[monitorType.name]; + } else { + log.error("", "Remove MonitorType failed: " + monitorType.name); + } + } + } module.exports = { @@ -280,3 +335,4 @@ module.exports = { // Must be at the end const MaintenanceTimeslot = require("./model/maintenance_timeslot"); +const { MonitorType } = require("./monitor-types/monitor-type"); diff --git a/src/components/PluginItem.vue b/src/components/PluginItem.vue new file mode 100644 index 00000000..4925bc9c --- /dev/null +++ b/src/components/PluginItem.vue @@ -0,0 +1,102 @@ + + + + + diff --git a/src/components/settings/Plugins.vue b/src/components/settings/Plugins.vue new file mode 100644 index 00000000..ca39e7ad --- /dev/null +++ b/src/components/settings/Plugins.vue @@ -0,0 +1,57 @@ + + + diff --git a/src/lang/en.json b/src/lang/en.json index f2f16693..cab3dcea 100644 --- a/src/lang/en.json +++ b/src/lang/en.json @@ -428,6 +428,13 @@ "Schedule Maintenance": "Schedule Maintenance", "Date and Time": "Date and Time", "DateTime Range": "DateTime Range", + "loadingError": "Cannot fetch the data, please try again later.", + "plugin": "Plugin | Plugins", + "install": "Install", + "installing": "Installing", + "uninstall": "Uninstall", + "uninstalling": "Uninstalling", + "confirmUninstallPlugin": "Are you sure want to uninstall this plugin?", "smtp": "Email (SMTP)", "secureOptionNone": "None / STARTTLS (25, 587)", "secureOptionTLS": "TLS (465)", diff --git a/src/pages/EditMonitor.vue b/src/pages/EditMonitor.vue index 50e97bab..0f7fc4a5 100644 --- a/src/pages/EditMonitor.vue +++ b/src/pages/EditMonitor.vue @@ -70,6 +70,12 @@ Redis + + + + @@ -80,7 +86,7 @@ -
+
@@ -102,7 +108,7 @@
-
+
diff --git a/src/pages/Settings.vue b/src/pages/Settings.vue index f5d32e0e..2b08d04e 100644 --- a/src/pages/Settings.vue +++ b/src/pages/Settings.vue @@ -113,6 +113,9 @@ export default { backup: { title: this.$t("Backup"), }, + plugins: { + title: this.$tc("plugin", 2), + }, about: { title: this.$t("About"), }, diff --git a/src/router.js b/src/router.js index b5b46c30..7bb474ee 100644 --- a/src/router.js +++ b/src/router.js @@ -18,6 +18,7 @@ import NotFound from "./pages/NotFound.vue"; import DockerHosts from "./components/settings/Docker.vue"; import MaintenanceDetails from "./pages/MaintenanceDetails.vue"; import ManageMaintenance from "./pages/ManageMaintenance.vue"; +import Plugins from "./components/settings/Plugins.vue"; // Settings - Sub Pages import Appearance from "./components/settings/Appearance.vue"; @@ -31,6 +32,7 @@ import Proxies from "./components/settings/Proxies.vue"; import Backup from "./components/settings/Backup.vue"; import About from "./components/settings/About.vue"; + const routes = [ { path: "/", @@ -120,6 +122,10 @@ const routes = [ path: "backup", component: Backup, }, + { + path: "plugins", + component: Plugins, + }, { path: "about", component: About,