diff --git a/.devcontainer/README.md b/.devcontainer/README.md deleted file mode 100644 index 81b8b0fa0..000000000 --- a/.devcontainer/README.md +++ /dev/null @@ -1,28 +0,0 @@ -# Codespaces - -You can modifiy Uptime Kuma in your browser without setting up a local development. - -![image](https://github.com/louislam/uptime-kuma/assets/1336778/31d9f06d-dd0b-4405-8e0d-a96586ee4595) - -1. Click `Code` -> `Create codespace on master` -2. Wait a few minutes until you see there are two exposed ports -3. Go to the `3000` url, see if it is working - -![image](https://github.com/louislam/uptime-kuma/assets/1336778/909b2eb4-4c5e-44e4-ac26-6d20ed856e7f) - -## Frontend - -Since the frontend is using [Vite.js](https://vitejs.dev/), all changes in this area will be hot-reloaded. -You don't need to restart the frontend, unless you try to add a new frontend dependency. - -## Backend - -The backend does not automatically hot-reload. -You will need to restart the backend after changing something using these steps: - -1. Click `Terminal` -2. Click `Codespaces: server-dev` in the right panel -3. Press `Ctrl + C` to stop the server -4. Press `Up` to run `npm run start-server-dev` - -![image](https://github.com/louislam/uptime-kuma/assets/1336778/e0c0a350-fe46-4588-9f37-e053c85834d1) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json deleted file mode 100644 index 6e3282dc8..000000000 --- a/.devcontainer/devcontainer.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "image": "mcr.microsoft.com/devcontainers/javascript-node:dev-18-bookworm", - "features": { - "ghcr.io/devcontainers/features/github-cli:1": {} - }, - "updateContentCommand": "npm ci", - "postCreateCommand": "", - "postAttachCommand": { - "frontend-dev": "npm run start-frontend-devcontainer", - "server-dev": "npm run start-server-dev", - "open-port": "gh codespace ports visibility 3001:public -c $CODESPACE_NAME" - }, - "customizations": { - "vscode": { - "extensions": [ - "streetsidesoftware.code-spell-checker", - "dbaeumer.vscode-eslint", - "GitHub.copilot-chat" - ] - } - }, - "forwardPorts": [3000, 3001] -} diff --git a/.dockerignore b/.dockerignore index 4ef25f5eb..5db08b7bf 100644 --- a/.dockerignore +++ b/.dockerignore @@ -17,7 +17,6 @@ README.md .vscode .eslint* .stylelint* -/.devcontainer /.github yarn.lock app.json diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6b37b5d85..69f98c0e4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -236,12 +236,6 @@ The goal is to make the Uptime Kuma installation as easy as installing a mobile - IDE that supports [`ESLint`](https://eslint.org/) and EditorConfig (I am using [`IntelliJ IDEA`](https://www.jetbrains.com/idea/)) - A SQLite GUI tool (f.ex. [`SQLite Expert Personal`](https://www.sqliteexpert.com/download.html) or [`DBeaver Community`](https://dbeaver.io/download/)) -### GitHub Codespaces - -If you don't want to setup an local environment, you can now develop on GitHub Codespaces, read more: - -https://github.com/louislam/uptime-kuma/tree/master/.devcontainer - ## Git Branches - `master`: 2.X.X development. If you want to add a new feature, your pull request should base on this. diff --git a/config/playwright.config.js b/config/playwright.config.js index 94239d2dd..5c574eecc 100644 --- a/config/playwright.config.js +++ b/config/playwright.config.js @@ -1,11 +1,11 @@ import { defineConfig, devices } from "@playwright/test"; const port = 30001; -const url = `http://localhost:${port}`; +export const url = `http://localhost:${port}`; export default defineConfig({ // Look for test files in the "tests" directory, relative to this configuration file. - testDir: "../test/e2e", + testDir: "../test/e2e/specs", outputDir: "../private/playwright-test-results", fullyParallel: false, locale: "en-US", @@ -40,9 +40,15 @@ export default defineConfig({ // Configure projects for major browsers. projects: [ { - name: "chromium", + name: "run-once setup", + testMatch: /setup-process\.once\.js/, use: { ...devices["Desktop Chrome"] }, }, + { + name: "specs", + use: { ...devices["Desktop Chrome"] }, + dependencies: [ "run-once setup" ], + }, /* { name: "firefox", @@ -52,7 +58,7 @@ export default defineConfig({ // Run your local dev server before starting the tests. webServer: { - command: `node extra/remove-playwright-test-data.js && node server/server.js --port=${port} --data-dir=./data/playwright-test`, + command: `node extra/remove-playwright-test-data.js && cross-env NODE_ENV=development node server/server.js --port=${port} --data-dir=./data/playwright-test`, url, reuseExistingServer: false, cwd: "../", diff --git a/db/knex_migrations/2024-08-24-000-add-cache-bust.js b/db/knex_migrations/2024-08-24-000-add-cache-bust.js new file mode 100644 index 000000000..3644377c4 --- /dev/null +++ b/db/knex_migrations/2024-08-24-000-add-cache-bust.js @@ -0,0 +1,13 @@ +exports.up = function (knex) { + return knex.schema + .alterTable("monitor", function (table) { + table.boolean("cache_bust").notNullable().defaultTo(false); + }); +}; + +exports.down = function (knex) { + return knex.schema + .alterTable("monitor", function (table) { + table.dropColumn("cache_bust"); + }); +}; diff --git a/db/knex_migrations/2024-08-24-0000-conditions.js b/db/knex_migrations/2024-08-24-0000-conditions.js new file mode 100644 index 000000000..96352fdc4 --- /dev/null +++ b/db/knex_migrations/2024-08-24-0000-conditions.js @@ -0,0 +1,12 @@ +exports.up = function (knex) { + return knex.schema + .alterTable("monitor", function (table) { + table.text("conditions").notNullable().defaultTo("[]"); + }); +}; + +exports.down = function (knex) { + return knex.schema.alterTable("monitor", function (table) { + table.dropColumn("conditions"); + }); +}; diff --git a/package-lock.json b/package-lock.json index 98d351db4..c5b2f54c4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,6 +31,7 @@ "express": "~4.19.2", "express-basic-auth": "~1.2.1", "express-static-gzip": "~2.1.7", + "feed": "^4.2.2", "form-data": "~4.0.0", "gamedig": "^4.2.0", "html-escaper": "^3.0.3", @@ -48,6 +49,7 @@ "knex": "^2.4.2", "limiter": "~2.1.0", "liquidjs": "^10.7.0", + "marked": "^14.0.0", "mitt": "~3.0.1", "mongodb": "~4.17.1", "mqtt": "~4.3.7", @@ -113,7 +115,6 @@ "eslint-plugin-vue": "~8.7.1", "favico.js": "~0.3.10", "get-port-please": "^3.1.1", - "marked": "~4.2.5", "node-ssh": "~13.1.0", "postcss-html": "~1.5.0", "postcss-rtlcss": "~3.7.2", @@ -8315,6 +8316,18 @@ "dev": true, "license": "MIT" }, + "node_modules/feed": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/feed/-/feed-4.2.2.tgz", + "integrity": "sha512-u5/sxGfiMfZNtJ3OvQpXcvotFpYkL0n9u9mM2vkui2nGo8b4wvDkJ8gAkYqbA8QpGyFCv3RK0Z+Iv+9veCS9bQ==", + "license": "MIT", + "dependencies": { + "xml-js": "^1.6.11" + }, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", @@ -10645,16 +10658,15 @@ } }, "node_modules/marked": { - "version": "4.2.12", - "resolved": "https://registry.npmjs.org/marked/-/marked-4.2.12.tgz", - "integrity": "sha512-yr8hSKa3Fv4D3jdZmtMMPghgVt6TWbk86WQaWhDloQjRSQhMMYCAro7jP7VDJrjjdV8pxVxMssXS8B8Y5DZ5aw==", - "dev": true, + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", + "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", "license": "MIT", "bin": { "marked": "bin/marked.js" }, "engines": { - "node": ">= 12" + "node": ">= 18" } }, "node_modules/mathml-tag-names": { @@ -11299,22 +11311,6 @@ "smart-buffer": "^4.1.0" } }, - "node_modules/node-abi": { - "version": "3.62.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.62.0.tgz", - "integrity": "sha512-CPMcGa+y33xuL1E0TcNIu4YyaZCxnnvkVaEXrsosR3FxN+fV8xvb7Mzpb7IgKler10qeMkE6+Dp8qJhpzdq35g==", - "dependencies": { - "semver": "^7.3.5" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/node-abort-controller": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", - "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==" - }, "node_modules/node-cloudflared-tunnel": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/node-cloudflared-tunnel/-/node-cloudflared-tunnel-1.0.10.tgz", @@ -15942,6 +15938,18 @@ } } }, + "node_modules/xml-js": { + "version": "1.6.11", + "resolved": "https://registry.npmjs.org/xml-js/-/xml-js-1.6.11.tgz", + "integrity": "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==", + "license": "MIT", + "dependencies": { + "sax": "^1.2.4" + }, + "bin": { + "xml-js": "bin/cli.js" + } + }, "node_modules/xmlbuilder": { "version": "8.2.2", "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-8.2.2.tgz", diff --git a/package.json b/package.json index a74e4677e..52ce4cac1 100644 --- a/package.json +++ b/package.json @@ -27,9 +27,7 @@ "build": "vite build --config ./config/vite.config.js", "test": "npm run test-backend && npm run test-e2e", "test-with-build": "npm run build && npm test", - "test-backend": "node test/backend-test-entry.js", - "test-backend:14": "cross-env TEST_BACKEND=1 NODE_OPTIONS=\"--experimental-abortcontroller --no-warnings\" node--test test/backend-test", - "test-backend:18": "cross-env TEST_BACKEND=1 node --test test/backend-test", + "test-backend": "cross-env TEST_BACKEND=1 node --test test/backend-test", "test-e2e": "playwright test --config ./config/playwright.config.js", "test-e2e-ui": "playwright test --config ./config/playwright.config.js --ui --ui-port=51063", "playwright-codegen": "playwright codegen localhost:3000 --save-storage=./private/e2e-auth.json", @@ -96,6 +94,7 @@ "express": "~4.19.2", "express-basic-auth": "~1.2.1", "express-static-gzip": "~2.1.7", + "feed": "^4.2.2", "form-data": "~4.0.0", "gamedig": "^4.2.0", "html-escaper": "^3.0.3", @@ -113,6 +112,7 @@ "knex": "^2.4.2", "limiter": "~2.1.0", "liquidjs": "^10.7.0", + "marked": "^14.0.0", "mitt": "~3.0.1", "mongodb": "~4.17.1", "mqtt": "~4.3.7", @@ -178,7 +178,6 @@ "eslint-plugin-vue": "~8.7.1", "favico.js": "~0.3.10", "get-port-please": "^3.1.1", - "marked": "~4.2.5", "node-ssh": "~13.1.0", "postcss-html": "~1.5.0", "postcss-rtlcss": "~3.7.2", diff --git a/server/client.js b/server/client.js index 58ed8f956..72f0a4e8e 100644 --- a/server/client.js +++ b/server/client.js @@ -213,6 +213,32 @@ async function sendRemoteBrowserList(socket) { return list; } +/** + * Send list of monitor types to client + * @param {Socket} socket Socket.io socket instance + * @returns {Promise} + */ +async function sendMonitorTypeList(socket) { + const result = Object.entries(UptimeKumaServer.monitorTypeList).map(([ key, type ]) => { + return [ key, { + supportsConditions: type.supportsConditions, + conditionVariables: type.conditionVariables.map(v => { + return { + id: v.id, + operators: v.operators.map(o => { + return { + id: o.id, + caption: o.caption, + }; + }), + }; + }), + }]; + }); + + io.to(socket.userID).emit("monitorTypeList", Object.fromEntries(result)); +} + module.exports = { sendNotificationList, sendImportantHeartbeatList, @@ -222,4 +248,5 @@ module.exports = { sendInfo, sendDockerHostList, sendRemoteBrowserList, + sendMonitorTypeList, }; diff --git a/server/model/monitor.js b/server/model/monitor.js index 79b383540..ac1093558 100644 --- a/server/model/monitor.js +++ b/server/model/monitor.js @@ -151,10 +151,12 @@ class Monitor extends BeanModel { kafkaProducerAllowAutoTopicCreation: this.getKafkaProducerAllowAutoTopicCreation(), kafkaProducerMessage: this.kafkaProducerMessage, screenshot, + cacheBust: this.getCacheBust(), remote_browser: this.remote_browser, snmpOid: this.snmpOid, jsonPathOperator: this.jsonPathOperator, snmpVersion: this.snmpVersion, + conditions: JSON.parse(this.conditions), }; if (includeSensitiveData) { @@ -277,6 +279,14 @@ class Monitor extends BeanModel { return Boolean(this.grpcEnableTls); } + /** + * Parse to boolean + * @returns {boolean} if cachebusting is enabled + */ + getCacheBust() { + return Boolean(this.cacheBust); + } + /** * Get accepted status codes * @returns {object} Accepted status codes @@ -318,7 +328,7 @@ class Monitor extends BeanModel { let previousBeat = null; let retries = 0; - this.prometheus = new Prometheus(this); + this.prometheus = await Prometheus.createAndInitMetrics(this); const beat = async () => { @@ -482,6 +492,14 @@ class Monitor extends BeanModel { options.data = bodyValue; } + if (this.cacheBust) { + const randomFloatString = Math.random().toString(36); + const cacheBust = randomFloatString.substring(2); + options.params = { + uptime_kuma_cachebuster: cacheBust, + }; + } + if (this.proxy_id) { const proxy = await R.load("proxy", this.proxy_id); @@ -962,7 +980,7 @@ class Monitor extends BeanModel { await R.store(bean); log.debug("monitor", `[${this.name}] prometheus.update`); - this.prometheus?.update(bean, tlsInfo); + await this.prometheus?.update(bean, tlsInfo); previousBeat = bean; diff --git a/server/model/status_page.js b/server/model/status_page.js index e40b28f6f..38f548ebb 100644 --- a/server/model/status_page.js +++ b/server/model/status_page.js @@ -5,6 +5,10 @@ const { UptimeKumaServer } = require("../uptime-kuma-server"); const jsesc = require("jsesc"); const googleAnalytics = require("../google-analytics"); const { marked } = require("marked"); +const { Feed } = require("feed"); +const config = require("../config"); + +const { STATUS_PAGE_ALL_DOWN, STATUS_PAGE_ALL_UP, STATUS_PAGE_MAINTENANCE, STATUS_PAGE_PARTIAL_DOWN, UP, MAINTENANCE, DOWN } = require("../../src/util"); class StatusPage extends BeanModel { @@ -14,6 +18,24 @@ class StatusPage extends BeanModel { */ static domainMappingList = { }; + /** + * Handle responses to RSS pages + * @param {Response} response Response object + * @param {string} slug Status page slug + * @returns {Promise} + */ + static async handleStatusPageRSSResponse(response, slug) { + let statusPage = await R.findOne("status_page", " slug = ? ", [ + slug + ]); + + if (statusPage) { + response.send(await StatusPage.renderRSS(statusPage, slug)); + } else { + response.status(404).send(UptimeKumaServer.getInstance().indexHTML); + } + } + /** * Handle responses to status page * @param {Response} response Response object @@ -39,6 +61,38 @@ class StatusPage extends BeanModel { } } + /** + * SSR for RSS feed + * @param {statusPage} statusPage object + * @param {slug} slug from router + * @returns {Promise} the rendered html + */ + static async renderRSS(statusPage, slug) { + const { heartbeats, statusDescription } = await StatusPage.getRSSPageData(statusPage); + + let proto = config.isSSL ? "https" : "http"; + let host = `${proto}://${config.hostname || "localhost"}:${config.port}/status/${slug}`; + + const feed = new Feed({ + title: "uptime kuma rss feed", + description: `current status: ${statusDescription}`, + link: host, + language: "en", // optional, used only in RSS 2.0, possible values: http://www.w3.org/TR/REC-html40/struct/dirlang.html#langcodes + updated: new Date(), // optional, default = today + }); + + heartbeats.forEach(heartbeat => { + feed.addItem({ + title: `${heartbeat.name} is down`, + description: `${heartbeat.name} has been down since ${heartbeat.time}`, + id: heartbeat.monitorID, + date: new Date(heartbeat.time), + }); + }); + + return feed.rss2(); + } + /** * SSR for status pages * @param {string} indexHTML HTML page to render @@ -98,6 +152,109 @@ class StatusPage extends BeanModel { return $.root().html(); } + /** + * @param {heartbeats} heartbeats from getRSSPageData + * @returns {number} status_page constant from util.ts + */ + static overallStatus(heartbeats) { + if (heartbeats.length === 0) { + return -1; + } + + let status = STATUS_PAGE_ALL_UP; + let hasUp = false; + + for (let beat of heartbeats) { + if (beat.status === MAINTENANCE) { + return STATUS_PAGE_MAINTENANCE; + } else if (beat.status === UP) { + hasUp = true; + } else { + status = STATUS_PAGE_PARTIAL_DOWN; + } + } + + if (! hasUp) { + status = STATUS_PAGE_ALL_DOWN; + } + + return status; + } + + /** + * @param {number} status from overallStatus + * @returns {string} description + */ + static getStatusDescription(status) { + if (status === -1) { + return "No Services"; + } + + if (status === STATUS_PAGE_ALL_UP) { + return "All Systems Operational"; + } + + if (status === STATUS_PAGE_PARTIAL_DOWN) { + return "Partially Degraded Service"; + } + + if (status === STATUS_PAGE_ALL_DOWN) { + return "Degraded Service"; + } + + // TODO: show the real maintenance information: title, description, time + if (status === MAINTENANCE) { + return "Under maintenance"; + } + + return "?"; + } + + /** + * Get all data required for RSS + * @param {StatusPage} statusPage Status page to get data for + * @returns {object} Status page data + */ + static async getRSSPageData(statusPage) { + // get all heartbeats that correspond to this statusPage + const config = await statusPage.toPublicJSON(); + + // Public Group List + const showTags = !!statusPage.show_tags; + + const list = await R.find("group", " public = 1 AND status_page_id = ? ORDER BY weight ", [ + statusPage.id + ]); + + let heartbeats = []; + + for (let groupBean of list) { + let monitorGroup = await groupBean.toPublicJSON(showTags, config?.showCertificateExpiry); + for (const monitor of monitorGroup.monitorList) { + const heartbeat = await R.findOne("heartbeat", "monitor_id = ? ORDER BY time DESC", [ monitor.id ]); + if (heartbeat) { + heartbeats.push({ + ...monitor, + status: heartbeat.status, + time: heartbeat.time + }); + } + } + } + + // calculate RSS feed description + let status = StatusPage.overallStatus(heartbeats); + let statusDescription = StatusPage.getStatusDescription(status); + + // keep only DOWN heartbeats in the RSS feed + heartbeats = heartbeats.filter(heartbeat => heartbeat.status === DOWN); + + return { + heartbeats, + statusDescription + }; + } + /** * Get all status page data in one call * @param {StatusPage} statusPage Status page to get data for diff --git a/server/monitor-conditions/evaluator.js b/server/monitor-conditions/evaluator.js new file mode 100644 index 000000000..3860a3325 --- /dev/null +++ b/server/monitor-conditions/evaluator.js @@ -0,0 +1,71 @@ +const { ConditionExpressionGroup, ConditionExpression, LOGICAL } = require("./expression"); +const { operatorMap } = require("./operators"); + +/** + * @param {ConditionExpression} expression Expression to evaluate + * @param {object} context Context to evaluate against; These are values for variables in the expression + * @returns {boolean} Whether the expression evaluates true or false + * @throws {Error} + */ +function evaluateExpression(expression, context) { + /** + * @type {import("./operators").ConditionOperator|null} + */ + const operator = operatorMap.get(expression.operator) || null; + if (operator === null) { + throw new Error("Unexpected expression operator ID '" + expression.operator + "'. Expected one of [" + operatorMap.keys().join(",") + "]"); + } + + if (!Object.prototype.hasOwnProperty.call(context, expression.variable)) { + throw new Error("Variable missing in context: " + expression.variable); + } + + return operator.test(context[expression.variable], expression.value); +} + +/** + * @param {ConditionExpressionGroup} group Group of expressions to evaluate + * @param {object} context Context to evaluate against; These are values for variables in the expression + * @returns {boolean} Whether the group evaluates true or false + * @throws {Error} + */ +function evaluateExpressionGroup(group, context) { + if (!group.children.length) { + throw new Error("ConditionExpressionGroup must contain at least one child."); + } + + let result = null; + + for (const child of group.children) { + let childResult; + + if (child instanceof ConditionExpression) { + childResult = evaluateExpression(child, context); + } else if (child instanceof ConditionExpressionGroup) { + childResult = evaluateExpressionGroup(child, context); + } else { + throw new Error("Invalid child type in ConditionExpressionGroup. Expected ConditionExpression or ConditionExpressionGroup"); + } + + if (result === null) { + result = childResult; // Initialize result with the first child's result + } else if (child.andOr === LOGICAL.OR) { + result = result || childResult; + } else if (child.andOr === LOGICAL.AND) { + result = result && childResult; + } else { + throw new Error("Invalid logical operator in child of ConditionExpressionGroup. Expected 'and' or 'or'. Got '" + group.andOr + "'"); + } + } + + if (result === null) { + throw new Error("ConditionExpressionGroup did not result in a boolean."); + } + + return result; +} + +module.exports = { + evaluateExpression, + evaluateExpressionGroup, +}; diff --git a/server/monitor-conditions/expression.js b/server/monitor-conditions/expression.js new file mode 100644 index 000000000..1e7036959 --- /dev/null +++ b/server/monitor-conditions/expression.js @@ -0,0 +1,111 @@ +/** + * @readonly + * @enum {string} + */ +const LOGICAL = { + AND: "and", + OR: "or", +}; + +/** + * Recursively processes an array of raw condition objects and populates the given parent group with + * corresponding ConditionExpression or ConditionExpressionGroup instances. + * @param {Array} conditions Array of raw condition objects, where each object represents either a group or an expression. + * @param {ConditionExpressionGroup} parentGroup The parent group to which the instantiated ConditionExpression or ConditionExpressionGroup objects will be added. + * @returns {void} + */ +function processMonitorConditions(conditions, parentGroup) { + conditions.forEach(condition => { + const andOr = condition.andOr === LOGICAL.OR ? LOGICAL.OR : LOGICAL.AND; + + if (condition.type === "group") { + const group = new ConditionExpressionGroup([], andOr); + + // Recursively process the group's children + processMonitorConditions(condition.children, group); + + parentGroup.children.push(group); + } else if (condition.type === "expression") { + const expression = new ConditionExpression(condition.variable, condition.operator, condition.value, andOr); + parentGroup.children.push(expression); + } + }); +} + +class ConditionExpressionGroup { + /** + * @type {ConditionExpressionGroup[]|ConditionExpression[]} Groups and/or expressions to test + */ + children = []; + + /** + * @type {LOGICAL} Connects group result with previous group/expression results + */ + andOr; + + /** + * @param {ConditionExpressionGroup[]|ConditionExpression[]} children Groups and/or expressions to test + * @param {LOGICAL} andOr Connects group result with previous group/expression results + */ + constructor(children = [], andOr = LOGICAL.AND) { + this.children = children; + this.andOr = andOr; + } + + /** + * @param {Monitor} monitor Monitor instance + * @returns {ConditionExpressionGroup|null} A ConditionExpressionGroup with the Monitor's conditions + */ + static fromMonitor(monitor) { + const conditions = JSON.parse(monitor.conditions); + if (conditions.length === 0) { + return null; + } + + const root = new ConditionExpressionGroup(); + processMonitorConditions(conditions, root); + + return root; + } +} + +class ConditionExpression { + /** + * @type {string} ID of variable + */ + variable; + + /** + * @type {string} ID of operator + */ + operator; + + /** + * @type {string} Value to test with the operator + */ + value; + + /** + * @type {LOGICAL} Connects expression result with previous group/expression results + */ + andOr; + + /** + * @param {string} variable ID of variable to test against + * @param {string} operator ID of operator to test the variable with + * @param {string} value Value to test with the operator + * @param {LOGICAL} andOr Connects expression result with previous group/expression results + */ + constructor(variable, operator, value, andOr = LOGICAL.AND) { + this.variable = variable; + this.operator = operator; + this.value = value; + this.andOr = andOr; + } +} + +module.exports = { + LOGICAL, + ConditionExpressionGroup, + ConditionExpression, +}; diff --git a/server/monitor-conditions/operators.js b/server/monitor-conditions/operators.js new file mode 100644 index 000000000..d900dff9d --- /dev/null +++ b/server/monitor-conditions/operators.js @@ -0,0 +1,318 @@ +class ConditionOperator { + id = undefined; + caption = undefined; + + /** + * @type {mixed} variable + * @type {mixed} value + */ + test(variable, value) { + throw new Error("You need to override test()"); + } +} + +const OP_STR_EQUALS = "equals"; + +const OP_STR_NOT_EQUALS = "not_equals"; + +const OP_CONTAINS = "contains"; + +const OP_NOT_CONTAINS = "not_contains"; + +const OP_STARTS_WITH = "starts_with"; + +const OP_NOT_STARTS_WITH = "not_starts_with"; + +const OP_ENDS_WITH = "ends_with"; + +const OP_NOT_ENDS_WITH = "not_ends_with"; + +const OP_NUM_EQUALS = "num_equals"; + +const OP_NUM_NOT_EQUALS = "num_not_equals"; + +const OP_LT = "lt"; + +const OP_GT = "gt"; + +const OP_LTE = "lte"; + +const OP_GTE = "gte"; + +/** + * Asserts a variable is equal to a value. + */ +class StringEqualsOperator extends ConditionOperator { + id = OP_STR_EQUALS; + caption = "equals"; + + /** + * @inheritdoc + */ + test(variable, value) { + return variable === value; + } +} + +/** + * Asserts a variable is not equal to a value. + */ +class StringNotEqualsOperator extends ConditionOperator { + id = OP_STR_NOT_EQUALS; + caption = "not equals"; + + /** + * @inheritdoc + */ + test(variable, value) { + return variable !== value; + } +} + +/** + * Asserts a variable contains a value. + * Handles both Array and String variable types. + */ +class ContainsOperator extends ConditionOperator { + id = OP_CONTAINS; + caption = "contains"; + + /** + * @inheritdoc + */ + test(variable, value) { + if (Array.isArray(variable)) { + return variable.includes(value); + } + + return variable.indexOf(value) !== -1; + } +} + +/** + * Asserts a variable does not contain a value. + * Handles both Array and String variable types. + */ +class NotContainsOperator extends ConditionOperator { + id = OP_NOT_CONTAINS; + caption = "not contains"; + + /** + * @inheritdoc + */ + test(variable, value) { + if (Array.isArray(variable)) { + return !variable.includes(value); + } + + return variable.indexOf(value) === -1; + } +} + +/** + * Asserts a variable starts with a value. + */ +class StartsWithOperator extends ConditionOperator { + id = OP_STARTS_WITH; + caption = "starts with"; + + /** + * @inheritdoc + */ + test(variable, value) { + return variable.startsWith(value); + } +} + +/** + * Asserts a variable does not start with a value. + */ +class NotStartsWithOperator extends ConditionOperator { + id = OP_NOT_STARTS_WITH; + caption = "not starts with"; + + /** + * @inheritdoc + */ + test(variable, value) { + return !variable.startsWith(value); + } +} + +/** + * Asserts a variable ends with a value. + */ +class EndsWithOperator extends ConditionOperator { + id = OP_ENDS_WITH; + caption = "ends with"; + + /** + * @inheritdoc + */ + test(variable, value) { + return variable.endsWith(value); + } +} + +/** + * Asserts a variable does not end with a value. + */ +class NotEndsWithOperator extends ConditionOperator { + id = OP_NOT_ENDS_WITH; + caption = "not ends with"; + + /** + * @inheritdoc + */ + test(variable, value) { + return !variable.endsWith(value); + } +} + +/** + * Asserts a numeric variable is equal to a value. + */ +class NumberEqualsOperator extends ConditionOperator { + id = OP_NUM_EQUALS; + caption = "equals"; + + /** + * @inheritdoc + */ + test(variable, value) { + return variable === Number(value); + } +} + +/** + * Asserts a numeric variable is not equal to a value. + */ +class NumberNotEqualsOperator extends ConditionOperator { + id = OP_NUM_NOT_EQUALS; + caption = "not equals"; + + /** + * @inheritdoc + */ + test(variable, value) { + return variable !== Number(value); + } +} + +/** + * Asserts a variable is less than a value. + */ +class LessThanOperator extends ConditionOperator { + id = OP_LT; + caption = "less than"; + + /** + * @inheritdoc + */ + test(variable, value) { + return variable < Number(value); + } +} + +/** + * Asserts a variable is greater than a value. + */ +class GreaterThanOperator extends ConditionOperator { + id = OP_GT; + caption = "greater than"; + + /** + * @inheritdoc + */ + test(variable, value) { + return variable > Number(value); + } +} + +/** + * Asserts a variable is less than or equal to a value. + */ +class LessThanOrEqualToOperator extends ConditionOperator { + id = OP_LTE; + caption = "less than or equal to"; + + /** + * @inheritdoc + */ + test(variable, value) { + return variable <= Number(value); + } +} + +/** + * Asserts a variable is greater than or equal to a value. + */ +class GreaterThanOrEqualToOperator extends ConditionOperator { + id = OP_GTE; + caption = "greater than or equal to"; + + /** + * @inheritdoc + */ + test(variable, value) { + return variable >= Number(value); + } +} + +const operatorMap = new Map([ + [ OP_STR_EQUALS, new StringEqualsOperator ], + [ OP_STR_NOT_EQUALS, new StringNotEqualsOperator ], + [ OP_CONTAINS, new ContainsOperator ], + [ OP_NOT_CONTAINS, new NotContainsOperator ], + [ OP_STARTS_WITH, new StartsWithOperator ], + [ OP_NOT_STARTS_WITH, new NotStartsWithOperator ], + [ OP_ENDS_WITH, new EndsWithOperator ], + [ OP_NOT_ENDS_WITH, new NotEndsWithOperator ], + [ OP_NUM_EQUALS, new NumberEqualsOperator ], + [ OP_NUM_NOT_EQUALS, new NumberNotEqualsOperator ], + [ OP_LT, new LessThanOperator ], + [ OP_GT, new GreaterThanOperator ], + [ OP_LTE, new LessThanOrEqualToOperator ], + [ OP_GTE, new GreaterThanOrEqualToOperator ], +]); + +const defaultStringOperators = [ + operatorMap.get(OP_STR_EQUALS), + operatorMap.get(OP_STR_NOT_EQUALS), + operatorMap.get(OP_CONTAINS), + operatorMap.get(OP_NOT_CONTAINS), + operatorMap.get(OP_STARTS_WITH), + operatorMap.get(OP_NOT_STARTS_WITH), + operatorMap.get(OP_ENDS_WITH), + operatorMap.get(OP_NOT_ENDS_WITH) +]; + +const defaultNumberOperators = [ + operatorMap.get(OP_NUM_EQUALS), + operatorMap.get(OP_NUM_NOT_EQUALS), + operatorMap.get(OP_LT), + operatorMap.get(OP_GT), + operatorMap.get(OP_LTE), + operatorMap.get(OP_GTE) +]; + +module.exports = { + OP_STR_EQUALS, + OP_STR_NOT_EQUALS, + OP_CONTAINS, + OP_NOT_CONTAINS, + OP_STARTS_WITH, + OP_NOT_STARTS_WITH, + OP_ENDS_WITH, + OP_NOT_ENDS_WITH, + OP_NUM_EQUALS, + OP_NUM_NOT_EQUALS, + OP_LT, + OP_GT, + OP_LTE, + OP_GTE, + operatorMap, + defaultStringOperators, + defaultNumberOperators, + ConditionOperator, +}; diff --git a/server/monitor-conditions/variables.js b/server/monitor-conditions/variables.js new file mode 100644 index 000000000..af98d2f29 --- /dev/null +++ b/server/monitor-conditions/variables.js @@ -0,0 +1,31 @@ +/** + * Represents a variable used in a condition and the set of operators that can be applied to this variable. + * + * A `ConditionVariable` holds the ID of the variable and a list of operators that define how this variable can be evaluated + * in conditions. For example, if the variable is a request body or a specific field in a request, the operators can include + * operations such as equality checks, comparisons, or other custom evaluations. + */ +class ConditionVariable { + /** + * @type {string} + */ + id; + + /** + * @type {import("./operators").ConditionOperator[]} + */ + operators = {}; + + /** + * @param {string} id ID of variable + * @param {import("./operators").ConditionOperator[]} operators Operators the condition supports + */ + constructor(id, operators = []) { + this.id = id; + this.operators = operators; + } +} + +module.exports = { + ConditionVariable, +}; diff --git a/server/monitor-types/dns.js b/server/monitor-types/dns.js index d038b6805..8b87932fe 100644 --- a/server/monitor-types/dns.js +++ b/server/monitor-types/dns.js @@ -1,12 +1,22 @@ const { MonitorType } = require("./monitor-type"); -const { UP } = require("../../src/util"); +const { UP, DOWN } = require("../../src/util"); const dayjs = require("dayjs"); const { dnsResolve } = require("../util-server"); const { R } = require("redbean-node"); +const { ConditionVariable } = require("../monitor-conditions/variables"); +const { defaultStringOperators } = require("../monitor-conditions/operators"); +const { ConditionExpressionGroup } = require("../monitor-conditions/expression"); +const { evaluateExpressionGroup } = require("../monitor-conditions/evaluator"); class DnsMonitorType extends MonitorType { name = "dns"; + supportsConditions = true; + + conditionVariables = [ + new ConditionVariable("record", defaultStringOperators ), + ]; + /** * @inheritdoc */ @@ -17,28 +27,48 @@ class DnsMonitorType extends MonitorType { let dnsRes = await dnsResolve(monitor.hostname, monitor.dns_resolve_server, monitor.port, monitor.dns_resolve_type); heartbeat.ping = dayjs().valueOf() - startTime; - if (monitor.dns_resolve_type === "A" || monitor.dns_resolve_type === "AAAA" || monitor.dns_resolve_type === "TXT" || monitor.dns_resolve_type === "PTR") { - dnsMessage += "Records: "; - dnsMessage += dnsRes.join(" | "); - } else if (monitor.dns_resolve_type === "CNAME" || monitor.dns_resolve_type === "PTR") { - dnsMessage += dnsRes[0]; - } else if (monitor.dns_resolve_type === "CAA") { - dnsMessage += dnsRes[0].issue; - } else if (monitor.dns_resolve_type === "MX") { - dnsRes.forEach(record => { - dnsMessage += `Hostname: ${record.exchange} - Priority: ${record.priority} | `; - }); - dnsMessage = dnsMessage.slice(0, -2); - } else if (monitor.dns_resolve_type === "NS") { - dnsMessage += "Servers: "; - dnsMessage += dnsRes.join(" | "); - } else if (monitor.dns_resolve_type === "SOA") { - dnsMessage += `NS-Name: ${dnsRes.nsname} | Hostmaster: ${dnsRes.hostmaster} | Serial: ${dnsRes.serial} | Refresh: ${dnsRes.refresh} | Retry: ${dnsRes.retry} | Expire: ${dnsRes.expire} | MinTTL: ${dnsRes.minttl}`; - } else if (monitor.dns_resolve_type === "SRV") { - dnsRes.forEach(record => { - dnsMessage += `Name: ${record.name} | Port: ${record.port} | Priority: ${record.priority} | Weight: ${record.weight} | `; - }); - dnsMessage = dnsMessage.slice(0, -2); + const conditions = ConditionExpressionGroup.fromMonitor(monitor); + let conditionsResult = true; + const handleConditions = (data) => conditions ? evaluateExpressionGroup(conditions, data) : true; + + switch (monitor.dns_resolve_type) { + case "A": + case "AAAA": + case "TXT": + case "PTR": + dnsMessage = `Records: ${dnsRes.join(" | ")}`; + conditionsResult = dnsRes.some(record => handleConditions({ record })); + break; + + case "CNAME": + dnsMessage = dnsRes[0]; + conditionsResult = handleConditions({ record: dnsRes[0] }); + break; + + case "CAA": + dnsMessage = dnsRes[0].issue; + conditionsResult = handleConditions({ record: dnsRes[0].issue }); + break; + + case "MX": + dnsMessage = dnsRes.map(record => `Hostname: ${record.exchange} - Priority: ${record.priority}`).join(" | "); + conditionsResult = dnsRes.some(record => handleConditions({ record: record.exchange })); + break; + + case "NS": + dnsMessage = `Servers: ${dnsRes.join(" | ")}`; + conditionsResult = dnsRes.some(record => handleConditions({ record })); + break; + + case "SOA": + dnsMessage = `NS-Name: ${dnsRes.nsname} | Hostmaster: ${dnsRes.hostmaster} | Serial: ${dnsRes.serial} | Refresh: ${dnsRes.refresh} | Retry: ${dnsRes.retry} | Expire: ${dnsRes.expire} | MinTTL: ${dnsRes.minttl}`; + conditionsResult = handleConditions({ record: dnsRes.nsname }); + break; + + case "SRV": + dnsMessage = dnsRes.map(record => `Name: ${record.name} | Port: ${record.port} | Priority: ${record.priority} | Weight: ${record.weight}`).join(" | "); + conditionsResult = dnsRes.some(record => handleConditions({ record: record.name })); + break; } if (monitor.dns_last_result !== dnsMessage && dnsMessage !== undefined) { @@ -46,7 +76,7 @@ class DnsMonitorType extends MonitorType { } heartbeat.msg = dnsMessage; - heartbeat.status = UP; + heartbeat.status = conditionsResult ? UP : DOWN; } } diff --git a/server/monitor-types/monitor-type.js b/server/monitor-types/monitor-type.js index 8290bdd76..8f3cbcac4 100644 --- a/server/monitor-types/monitor-type.js +++ b/server/monitor-types/monitor-type.js @@ -1,6 +1,19 @@ class MonitorType { name = undefined; + /** + * Whether or not this type supports monitor conditions. Controls UI visibility in monitor form. + * @type {boolean} + */ + supportsConditions = false; + + /** + * Variables supported by this type. e.g. an HTTP type could have a "response_code" variable to test against. + * This property controls the choices displayed in the monitor edit form. + * @type {import("../monitor-conditions/variables").ConditionVariable[]} + */ + conditionVariables = []; + /** * Run the monitoring check on the given monitor * @param {Monitor} monitor Monitor to check diff --git a/server/notification-providers/nostr.js b/server/notification-providers/nostr.js index 453b86d0a..87847382e 100644 --- a/server/notification-providers/nostr.js +++ b/server/notification-providers/nostr.js @@ -1,4 +1,3 @@ -const { log } = require("../../src/util"); const NotificationProvider = require("./notification-provider"); const { relayInit, @@ -12,16 +11,7 @@ const { // polyfills for node versions const semver = require("semver"); const nodeVersion = process.version; -if (semver.lt(nodeVersion, "16.0.0")) { - log.warn("monitor", "Node <= 16 is unsupported for nostr, sorry :("); -} else if (semver.lt(nodeVersion, "18.0.0")) { - // polyfills for node 16 - global.crypto = require("crypto"); - global.WebSocket = require("isomorphic-ws"); - if (typeof crypto !== "undefined" && !crypto.subtle && crypto.webcrypto) { - crypto.subtle = crypto.webcrypto.subtle; - } -} else if (semver.lt(nodeVersion, "20.0.0")) { +if (semver.lt(nodeVersion, "20.0.0")) { // polyfills for node 18 global.crypto = require("crypto"); global.WebSocket = require("isomorphic-ws"); diff --git a/server/notification-providers/pushover.js b/server/notification-providers/pushover.js index 304aa3519..8422b64c2 100644 --- a/server/notification-providers/pushover.js +++ b/server/notification-providers/pushover.js @@ -1,3 +1,6 @@ +const { getMonitorRelativeURL } = require("../../src/util"); +const { setting } = require("../util-server"); + const NotificationProvider = require("./notification-provider"); const axios = require("axios"); @@ -23,6 +26,12 @@ class Pushover extends NotificationProvider { "html": 1, }; + const baseURL = await setting("primaryBaseURL"); + if (baseURL && monitorJSON) { + data["url"] = baseURL + getMonitorRelativeURL(monitorJSON.id); + data["url_title"] = "Link to Monitor"; + } + if (notification.pushoverdevice) { data.device = notification.pushoverdevice; } diff --git a/server/notification-providers/signl4.js b/server/notification-providers/signl4.js new file mode 100644 index 000000000..e48983f59 --- /dev/null +++ b/server/notification-providers/signl4.js @@ -0,0 +1,52 @@ +const NotificationProvider = require("./notification-provider"); +const axios = require("axios"); +const { UP, DOWN } = require("../../src/util"); + +class SIGNL4 extends NotificationProvider { + name = "SIGNL4"; + + /** + * @inheritdoc + */ + async send(notification, msg, monitorJSON = null, heartbeatJSON = null) { + const okMsg = "Sent Successfully."; + + try { + let data = { + heartbeat: heartbeatJSON, + monitor: monitorJSON, + msg, + // Source system + "X-S4-SourceSystem": "UptimeKuma", + monitorUrl: this.extractAdress(monitorJSON), + }; + + const config = { + headers: { + "Content-Type": "application/json" + } + }; + + if (heartbeatJSON == null) { + // Test alert + data.title = "Uptime Kuma Alert"; + data.message = msg; + } else if (heartbeatJSON.status === UP) { + data.title = "Uptime Kuma Monitor ✅ Up"; + data["X-S4-ExternalID"] = "UptimeKuma-" + monitorJSON.monitorID; + data["X-S4-Status"] = "resolved"; + } else if (heartbeatJSON.status === DOWN) { + data.title = "Uptime Kuma Monitor 🔴 Down"; + data["X-S4-ExternalID"] = "UptimeKuma-" + monitorJSON.monitorID; + data["X-S4-Status"] = "new"; + } + + await axios.post(notification.webhookURL, data, config); + return okMsg; + } catch (error) { + this.throwGeneralAxiosError(error); + } + } +} + +module.exports = SIGNL4; diff --git a/server/notification-providers/wpush.js b/server/notification-providers/wpush.js new file mode 100644 index 000000000..db043f9c5 --- /dev/null +++ b/server/notification-providers/wpush.js @@ -0,0 +1,51 @@ +const NotificationProvider = require("./notification-provider"); +const axios = require("axios"); +const { DOWN, UP } = require("../../src/util"); + +class WPush extends NotificationProvider { + name = "WPush"; + + /** + * @inheritdoc + */ + async send(notification, msg, monitorJSON = null, heartbeatJSON = null) { + const okMsg = "Sent Successfully."; + + try { + const context = { + "title": this.checkStatus(heartbeatJSON, monitorJSON), + "content": msg, + "apikey": notification.wpushAPIkey, + "channel": notification.wpushChannel + }; + const result = await axios.post("https://api.wpush.cn/api/v1/send", context); + if (result.data.code !== 0) { + throw result.data.message; + } + + return okMsg; + + } catch (error) { + this.throwGeneralAxiosError(error); + } + } + + /** + * Get the formatted title for message + * @param {?object} heartbeatJSON Heartbeat details (For Up/Down only) + * @param {?object} monitorJSON Monitor details (For Up/Down only) + * @returns {string} Formatted title + */ + checkStatus(heartbeatJSON, monitorJSON) { + let title = "UptimeKuma Message"; + if (heartbeatJSON != null && heartbeatJSON["status"] === UP) { + title = "UptimeKuma Monitor Up " + monitorJSON["name"]; + } + if (heartbeatJSON != null && heartbeatJSON["status"] === DOWN) { + title = "UptimeKuma Monitor Down " + monitorJSON["name"]; + } + return title; + } +} + +module.exports = WPush; diff --git a/server/notification.js b/server/notification.js index e6a2e6d5e..60eae4d73 100644 --- a/server/notification.js +++ b/server/notification.js @@ -42,6 +42,7 @@ const Pushy = require("./notification-providers/pushy"); const RocketChat = require("./notification-providers/rocket-chat"); const SerwerSMS = require("./notification-providers/serwersms"); const Signal = require("./notification-providers/signal"); +const SIGNL4 = require("./notification-providers/signl4"); const Slack = require("./notification-providers/slack"); const SMSPartner = require("./notification-providers/smspartner"); const SMSEagle = require("./notification-providers/smseagle"); @@ -65,6 +66,7 @@ const Whapi = require("./notification-providers/whapi"); const GtxMessaging = require("./notification-providers/gtx-messaging"); const Cellsynt = require("./notification-providers/cellsynt"); const Onesender = require("./notification-providers/onesender"); +const Wpush = require("./notification-providers/wpush"); class Notification { @@ -126,6 +128,7 @@ class Notification { new ServerChan(), new SerwerSMS(), new Signal(), + new SIGNL4(), new SMSManager(), new SMSPartner(), new Slack(), @@ -147,6 +150,7 @@ class Notification { new Whapi(), new GtxMessaging(), new Cellsynt(), + new Wpush(), ]; for (let item of list) { if (! item.name) { diff --git a/server/prometheus.js b/server/prometheus.js index f26125d2c..05a028397 100644 --- a/server/prometheus.js +++ b/server/prometheus.js @@ -1,3 +1,4 @@ +const { R } = require("redbean-node"); const PrometheusClient = require("prom-client"); const { log } = require("../src/util"); @@ -9,36 +10,102 @@ const commonLabels = [ "monitor_port", ]; -const monitorCertDaysRemaining = new PrometheusClient.Gauge({ - name: "monitor_cert_days_remaining", - help: "The number of days remaining until the certificate expires", - labelNames: commonLabels -}); - -const monitorCertIsValid = new PrometheusClient.Gauge({ - name: "monitor_cert_is_valid", - help: "Is the certificate still valid? (1 = Yes, 0= No)", - labelNames: commonLabels -}); -const monitorResponseTime = new PrometheusClient.Gauge({ - name: "monitor_response_time", - help: "Monitor Response Time (ms)", - labelNames: commonLabels -}); - -const monitorStatus = new PrometheusClient.Gauge({ - name: "monitor_status", - help: "Monitor Status (1 = UP, 0= DOWN, 2= PENDING, 3= MAINTENANCE)", - labelNames: commonLabels -}); - class Prometheus { - monitorLabelValues = {}; /** - * @param {object} monitor Monitor object to monitor + * Metric: monitor_cert_days_remaining + * @type {PrometheusClient.Gauge | null} */ - constructor(monitor) { + static monitorCertDaysRemaining = null; + + /** + * Metric: monitor_cert_is_valid + * @type {PrometheusClient.Gauge | null} + */ + static monitorCertIsValid = null; + + /** + * Metric: monitor_response_time + * @type {PrometheusClient.Gauge | null} + */ + static monitorResponseTime = null; + + /** + * Metric: monitor_status + * @type {PrometheusClient.Gauge | null} + */ + static monitorStatus = null; + + /** + * All registered metric labels. + * @type {string[] | null} + */ + static monitorLabelNames = null; + + /** + * Monitor labels/values combination. + * @type {{}} + */ + monitorLabelValues; + + /** + * Initialize metrics and get all label names the first time called. + * @returns {void} + */ + static async initMetrics() { + if (!this.monitorLabelNames) { + let labelNames = await R.getCol("SELECT name FROM tag"); + this.monitorLabelNames = [ ...commonLabels, ...labelNames ]; + } + if (!this.monitorCertDaysRemaining) { + this.monitorCertDaysRemaining = new PrometheusClient.Gauge({ + name: "monitor_cert_days_remaining", + help: "The number of days remaining until the certificate expires", + labelNames: this.monitorLabelNames + }); + } + if (!this.monitorCertIsValid) { + this.monitorCertIsValid = new PrometheusClient.Gauge({ + name: "monitor_cert_is_valid", + help: "Is the certificate still valid? (1 = Yes, 0 = No)", + labelNames: this.monitorLabelNames + }); + } + if (!this.monitorResponseTime) { + this.monitorResponseTime = new PrometheusClient.Gauge({ + name: "monitor_response_time", + help: "Monitor Response Time (ms)", + labelNames: this.monitorLabelNames + }); + } + if (!this.monitorStatus) { + this.monitorStatus = new PrometheusClient.Gauge({ + name: "monitor_status", + help: "Monitor Status (1 = UP, 0 = DOWN, 2 = PENDING, 3 = MAINTENANCE)", + labelNames: this.monitorLabelNames + }); + } + } + + /** + * Wrapper to create a `Prometheus` instance and ensure metrics are initialized. + * @param {Monitor} monitor Monitor object to monitor + * @returns {Promise} `Prometheus` instance + */ + static async createAndInitMetrics(monitor) { + await Prometheus.initMetrics(); + let tags = await monitor.getTags(); + return new Prometheus(monitor, tags); + } + + /** + * Creates a prometheus metric instance. + * + * Note: Make sure to call `Prometheus.initMetrics()` once prior creating Prometheus instances. + * @param {Monitor} monitor Monitor object to monitor + * @param {Promise[]>} tags Tags of the monitor + */ + constructor(monitor, tags) { this.monitorLabelValues = { monitor_name: monitor.name, monitor_type: monitor.type, @@ -46,6 +113,12 @@ class Prometheus { monitor_hostname: monitor.hostname, monitor_port: monitor.port }; + Object.values(tags) + // only label names that were known at first metric creation. + .filter(tag => Prometheus.monitorLabelNames.includes(tag.name)) + .forEach(tag => { + this.monitorLabelValues[tag.name] = tag.value; + }); } /** @@ -55,7 +128,6 @@ class Prometheus { * @returns {void} */ update(heartbeat, tlsInfo) { - if (typeof tlsInfo !== "undefined") { try { let isValid; @@ -64,7 +136,7 @@ class Prometheus { } else { isValid = 0; } - monitorCertIsValid.set(this.monitorLabelValues, isValid); + Prometheus.monitorCertIsValid.set(this.monitorLabelValues, isValid); } catch (e) { log.error("prometheus", "Caught error"); log.error("prometheus", e); @@ -72,7 +144,7 @@ class Prometheus { try { if (tlsInfo.certInfo != null) { - monitorCertDaysRemaining.set(this.monitorLabelValues, tlsInfo.certInfo.daysRemaining); + Prometheus.monitorCertDaysRemaining.set(this.monitorLabelValues, tlsInfo.certInfo.daysRemaining); } } catch (e) { log.error("prometheus", "Caught error"); @@ -82,7 +154,7 @@ class Prometheus { if (heartbeat) { try { - monitorStatus.set(this.monitorLabelValues, heartbeat.status); + Prometheus.monitorStatus.set(this.monitorLabelValues, heartbeat.status); } catch (e) { log.error("prometheus", "Caught error"); log.error("prometheus", e); @@ -90,10 +162,10 @@ class Prometheus { try { if (typeof heartbeat.ping === "number") { - monitorResponseTime.set(this.monitorLabelValues, heartbeat.ping); + Prometheus.monitorResponseTime.set(this.monitorLabelValues, heartbeat.ping); } else { // Is it good? - monitorResponseTime.set(this.monitorLabelValues, -1); + Prometheus.monitorResponseTime.set(this.monitorLabelValues, -1); } } catch (e) { log.error("prometheus", "Caught error"); @@ -108,10 +180,10 @@ class Prometheus { */ remove() { try { - monitorCertDaysRemaining.remove(this.monitorLabelValues); - monitorCertIsValid.remove(this.monitorLabelValues); - monitorResponseTime.remove(this.monitorLabelValues); - monitorStatus.remove(this.monitorLabelValues); + Prometheus.monitorCertDaysRemaining?.remove(this.monitorLabelValues); + Prometheus.monitorCertIsValid?.remove(this.monitorLabelValues); + Prometheus.monitorResponseTime?.remove(this.monitorLabelValues); + Prometheus.monitorStatus?.remove(this.monitorLabelValues); } catch (e) { console.error(e); } diff --git a/server/routers/status-page-router.js b/server/routers/status-page-router.js index 42cccc942..b209d33d1 100644 --- a/server/routers/status-page-router.js +++ b/server/routers/status-page-router.js @@ -18,6 +18,11 @@ router.get("/status/:slug", cache("5 minutes"), async (request, response) => { await StatusPage.handleStatusPageResponse(response, server.indexHTML, slug); }); +router.get("/status/:slug/rss", cache("5 minutes"), async (request, response) => { + let slug = request.params.slug; + await StatusPage.handleStatusPageRSSResponse(response, slug); +}); + router.get("/status", cache("5 minutes"), async (request, response) => { let slug = "default"; await StatusPage.handleStatusPageResponse(response, server.indexHTML, slug); diff --git a/server/server.js b/server/server.js index 18a60de7a..db58ae829 100644 --- a/server/server.js +++ b/server/server.js @@ -19,7 +19,7 @@ const nodeVersion = process.versions.node; // Get the required Node.js version from package.json const requiredNodeVersions = require("../package.json").engines.node; -const bannedNodeVersions = " < 14 || 20.0.* || 20.1.* || 20.2.* || 20.3.* "; +const bannedNodeVersions = " < 18 || 20.0.* || 20.1.* || 20.2.* || 20.3.* "; console.log(`Your Node.js version: ${nodeVersion}`); const semver = require("semver"); @@ -132,9 +132,9 @@ const twoFAVerifyOptions = { const testMode = !!args["test"] || false; // Must be after io instantiation -const { sendNotificationList, sendHeartbeatList, sendInfo, sendProxyList, sendDockerHostList, sendAPIKeyList, sendRemoteBrowserList } = require("./client"); +const { sendNotificationList, sendHeartbeatList, sendInfo, sendProxyList, sendDockerHostList, sendAPIKeyList, sendRemoteBrowserList, sendMonitorTypeList } = require("./client"); const { statusPageSocketHandler } = require("./socket-handlers/status-page-socket-handler"); -const databaseSocketHandler = require("./socket-handlers/database-socket-handler"); +const { databaseSocketHandler } = require("./socket-handlers/database-socket-handler"); const { remoteBrowserSocketHandler } = require("./socket-handlers/remote-browser-socket-handler"); const TwoFA = require("./2fa"); const StatusPage = require("./model/status_page"); @@ -246,6 +246,36 @@ let needSetup = false; log.debug("test", request.body); response.send("OK"); }); + + const fs = require("fs"); + + app.get("/_e2e/take-sqlite-snapshot", async (request, response) => { + await Database.close(); + try { + fs.cpSync(Database.sqlitePath, `${Database.sqlitePath}.e2e-snapshot`); + } catch (err) { + throw new Error("Unable to copy SQLite DB."); + } + await Database.connect(); + + response.send("Snapshot taken."); + }); + + app.get("/_e2e/restore-sqlite-snapshot", async (request, response) => { + if (!fs.existsSync(`${Database.sqlitePath}.e2e-snapshot`)) { + throw new Error("Snapshot doesn't exist."); + } + + await Database.close(); + try { + fs.cpSync(`${Database.sqlitePath}.e2e-snapshot`, Database.sqlitePath); + } catch (err) { + throw new Error("Unable to copy snapshot file."); + } + await Database.connect(); + + response.send("Snapshot restored."); + }); } // Robots.txt @@ -686,6 +716,8 @@ let needSetup = false; monitor.kafkaProducerBrokers = JSON.stringify(monitor.kafkaProducerBrokers); monitor.kafkaProducerSaslOptions = JSON.stringify(monitor.kafkaProducerSaslOptions); + monitor.conditions = JSON.stringify(monitor.conditions); + bean.import(monitor); bean.user_id = socket.userID; @@ -701,7 +733,7 @@ let needSetup = false; await startMonitor(socket.userID, bean.id); } - log.info("monitor", `Added Monitor: ${monitor.id} User ID: ${socket.userID}`); + log.info("monitor", `Added Monitor: ${bean.id} User ID: ${socket.userID}`); callback({ ok: true, @@ -826,6 +858,7 @@ let needSetup = false; bean.kafkaProducerAllowAutoTopicCreation = monitor.kafkaProducerAllowAutoTopicCreation; bean.kafkaProducerSaslOptions = JSON.stringify(monitor.kafkaProducerSaslOptions); bean.kafkaProducerMessage = monitor.kafkaProducerMessage; + bean.cacheBust = monitor.cacheBust; bean.kafkaProducerSsl = monitor.kafkaProducerSsl; bean.kafkaProducerAllowAutoTopicCreation = monitor.kafkaProducerAllowAutoTopicCreation; @@ -835,6 +868,7 @@ let needSetup = false; bean.snmpOid = monitor.snmpOid; bean.jsonPathOperator = monitor.jsonPathOperator; bean.timeout = monitor.timeout; + bean.conditions = JSON.stringify(monitor.conditions); bean.validate(); @@ -1642,6 +1676,7 @@ async function afterLogin(socket, user) { sendDockerHostList(socket), sendAPIKeyList(socket), sendRemoteBrowserList(socket), + sendMonitorTypeList(socket), ]); await StatusPage.sendStatusPageList(io, socket); diff --git a/server/socket-handlers/database-socket-handler.js b/server/socket-handlers/database-socket-handler.js index bcf34c906..ee2394bf6 100644 --- a/server/socket-handlers/database-socket-handler.js +++ b/server/socket-handlers/database-socket-handler.js @@ -6,7 +6,7 @@ const Database = require("../database"); * @param {Socket} socket Socket.io instance * @returns {void} */ -module.exports = (socket) => { +module.exports.databaseSocketHandler = (socket) => { // Post or edit incident socket.on("getDatabaseSize", async (callback) => { diff --git a/server/socket-handlers/general-socket-handler.js b/server/socket-handlers/general-socket-handler.js index 68e1f814c..50dcd946e 100644 --- a/server/socket-handlers/general-socket-handler.js +++ b/server/socket-handlers/general-socket-handler.js @@ -29,8 +29,13 @@ function getGameList() { return gameList; } +/** + * Handler for general events + * @param {Socket} socket Socket.io instance + * @param {UptimeKumaServer} server Uptime Kuma server + * @returns {void} + */ module.exports.generalSocketHandler = (socket, server) => { - socket.on("initServerTimezone", async (timezone) => { try { checkLogin(socket); diff --git a/src/components/EditMonitorCondition.vue b/src/components/EditMonitorCondition.vue new file mode 100644 index 000000000..ac1b02dd2 --- /dev/null +++ b/src/components/EditMonitorCondition.vue @@ -0,0 +1,152 @@ + + + + + diff --git a/src/components/EditMonitorConditionGroup.vue b/src/components/EditMonitorConditionGroup.vue new file mode 100644 index 000000000..910b41508 --- /dev/null +++ b/src/components/EditMonitorConditionGroup.vue @@ -0,0 +1,189 @@ + + + + + diff --git a/src/components/EditMonitorConditions.vue b/src/components/EditMonitorConditions.vue new file mode 100644 index 000000000..60f7c6589 --- /dev/null +++ b/src/components/EditMonitorConditions.vue @@ -0,0 +1,149 @@ + + + + + diff --git a/src/components/HeartbeatBar.vue b/src/components/HeartbeatBar.vue index fc044fe54..96a62cf61 100644 --- a/src/components/HeartbeatBar.vue +++ b/src/components/HeartbeatBar.vue @@ -14,7 +14,7 @@ v-if="!$root.isMobile && size !== 'small' && beatList.length > 4 && $root.styleElapsedTime !== 'none'" class="d-flex justify-content-between align-items-center word" :style="timeStyle" > -
{{ timeSinceFirstBeat }} ago
+
{{ timeSinceFirstBeat }}
{{ timeSinceLastBeat }}
@@ -184,11 +184,11 @@ export default { } if (seconds < tolerance) { - return "now"; + return this.$t("now"); } else if (seconds < 60 * 60) { - return (seconds / 60).toFixed(0) + "m ago"; + return this.$t("time ago", [ (seconds / 60).toFixed(0) + "m" ]); } else { - return (seconds / 60 / 60).toFixed(0) + "h ago"; + return this.$t("time ago", [ (seconds / 60 / 60).toFixed(0) + "h" ]); } } }, diff --git a/src/components/MonitorList.vue b/src/components/MonitorList.vue index b9d42048b..a579316b3 100644 --- a/src/components/MonitorList.vue +++ b/src/components/MonitorList.vue @@ -45,7 +45,7 @@ -
+
{{ $t("No Monitors, please") }} {{ $t("add one") }}
diff --git a/src/components/MonitorListItem.vue b/src/components/MonitorListItem.vue index 9b45ae9f2..74ba4835c 100644 --- a/src/components/MonitorListItem.vue +++ b/src/components/MonitorListItem.vue @@ -43,12 +43,15 @@
diff --git a/src/components/NotificationDialog.vue b/src/components/NotificationDialog.vue index 288b00559..864cbf5f4 100644 --- a/src/components/NotificationDialog.vue +++ b/src/components/NotificationDialog.vue @@ -145,6 +145,7 @@ export default { "pushy": "Pushy", "rocket.chat": "Rocket.Chat", "signal": "Signal", + "SIGNL4": "SIGNL4", "slack": "Slack", "squadcast": "SquadCast", "SMSEagle": "SMSEagle", @@ -179,6 +180,7 @@ export default { "WeCom": "WeCom (企业微信群机器人)", "ServerChan": "ServerChan (Server酱)", "smsc": "SMSC", + "WPush": "WPush(wpush.cn)", }; // Sort by notification name diff --git a/src/components/PublicGroupList.vue b/src/components/PublicGroupList.vue index d1c1f4c52..c5d7d4500 100644 --- a/src/components/PublicGroupList.vue +++ b/src/components/PublicGroupList.vue @@ -7,12 +7,12 @@ :animation="100" >