diff --git a/.github/workflows/stale-bot.yml b/.github/workflows/stale-bot.yml index 6acd303f..60eca640 100644 --- a/.github/workflows/stale-bot.yml +++ b/.github/workflows/stale-bot.yml @@ -9,7 +9,7 @@ jobs: stale: runs-on: ubuntu-latest steps: - - uses: actions/stale@v8 + - uses: actions/stale@v9 with: stale-issue-message: |- We are clearing up our old `help`-issues and your issue has been open for 60 days with no activity. @@ -21,7 +21,7 @@ jobs: exempt-issue-labels: 'News,Medium,High,discussion,bug,doc,feature-request' exempt-issue-assignees: 'louislam' operations-per-run: 200 - - uses: actions/stale@v8 + - uses: actions/stale@v9 with: stale-issue-message: |- This issue was marked as `cannot-reproduce` by a maintainer. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 916a4b93..6b37b5d8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -127,7 +127,7 @@ Different guidelines exist for different types of pull requests (PRs): - `server/monitor-types/MONITORING_TYPE.js` is the core of each monitor. the `async check(...)`-function should: - throw an error for each fault that is detected with an actionable error message - - in the happy-path, you should set `heartbeat.msg` to a successfull message and set `heartbeat.status = UP` + - in the happy-path, you should set `heartbeat.msg` to a successful message and set `heartbeat.status = UP` - `server/uptime-kuma-server.js` is where the monitoring backend needs to be registered. *If you have an idea how we can skip this step, we would love to hear about it ^^* - `src/pages/EditMonitor.vue` is the shared frontend users interact with. diff --git a/config/playwright.config.js b/config/playwright.config.js index 94239d2d..0e186f3b 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", diff --git a/db/knex_migrations/2024-04-26-0000-snmp-monitor.js b/db/knex_migrations/2024-04-26-0000-snmp-monitor.js new file mode 100644 index 00000000..24752f2d --- /dev/null +++ b/db/knex_migrations/2024-04-26-0000-snmp-monitor.js @@ -0,0 +1,16 @@ +exports.up = function (knex) { + return knex.schema + .alterTable("monitor", function (table) { + table.string("snmp_oid").defaultTo(null); + table.enum("snmp_version", [ "1", "2c", "3" ]).defaultTo("2c"); + table.string("json_path_operator").defaultTo(null); + }); +}; + +exports.down = function (knex) { + return knex.schema.alterTable("monitor", function (table) { + table.dropColumn("snmp_oid"); + table.dropColumn("snmp_version"); + table.dropColumn("json_path_operator"); + }); +}; 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 00000000..3644377c --- /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 00000000..96352fdc --- /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 918c3dd0..e2f6fbd5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,7 +24,7 @@ "command-exists": "~1.2.9", "compare-versions": "~3.6.0", "compression": "~1.7.4", - "croner": "~6.0.5", + "croner": "~8.1.0", "dayjs": "~1.11.5", "dev-null": "^0.1.1", "dotenv": "~16.0.3", @@ -48,12 +48,14 @@ "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", "mssql": "~11.0.0", "mysql2": "~3.9.6", "nanoid": "~3.3.4", + "net-snmp": "^3.11.2", "node-cloudflared-tunnel": "~1.0.9", "node-radius-client": "~1.0.0", "nodemailer": "~6.9.13", @@ -112,7 +114,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", @@ -5525,6 +5526,11 @@ "safer-buffer": "~2.1.0" } }, + "node_modules/asn1-ber": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/asn1-ber/-/asn1-ber-1.2.2.tgz", + "integrity": "sha512-CbNem/7hxrjSiOAOOTX4iZxu+0m3jiLqlsERQwwPM1IDR/22M8IPpA1VVndCLw5KtjRYyRODbvAEIfuTogNDng==" + }, "node_modules/astral-regex": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", @@ -6738,12 +6744,11 @@ } }, "node_modules/croner": { - "version": "6.0.7", - "resolved": "https://registry.npmjs.org/croner/-/croner-6.0.7.tgz", - "integrity": "sha512-k3Xx3Rcclfr60Yx4TmvsF3Yscuiql8LSvYLaphTsaq5Hk8La4Z/udmUANMOTKpgGGroI2F6/XOr9cU9OFkYluQ==", - "license": "MIT", + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/croner/-/croner-8.1.0.tgz", + "integrity": "sha512-sz990XOUPR8dG/r5BRKMBd15MYDDUu8oeSaxFD5DqvNgHSZw8Psd1s689/IGET7ezxRMiNlCIyGeY1Gvxp/MLg==", "engines": { - "node": ">=6.0" + "node": ">=18.0" } }, "node_modules/cronstrue": { @@ -10640,16 +10645,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": { @@ -11285,6 +11289,15 @@ "node": ">= 0.6" } }, + "node_modules/net-snmp": { + "version": "3.11.2", + "resolved": "https://registry.npmjs.org/net-snmp/-/net-snmp-3.11.2.tgz", + "integrity": "sha512-QKy2JQHIBsSK344dUxYRZv7tU0ANk8f8fzKD/Mmq/cCxm/cPbtiT7009QEgxdViW/gGjqGIOiLHxkCc+JhZltg==", + "dependencies": { + "asn1-ber": "^1.2.1", + "smart-buffer": "^4.1.0" + } + }, "node_modules/node-cloudflared-tunnel": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/node-cloudflared-tunnel/-/node-cloudflared-tunnel-1.0.10.tgz", diff --git a/package.json b/package.json index 31a89a7c..6476b741 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", @@ -89,7 +87,7 @@ "command-exists": "~1.2.9", "compare-versions": "~3.6.0", "compression": "~1.7.4", - "croner": "~6.0.5", + "croner": "~8.1.0", "dayjs": "~1.11.5", "dev-null": "^0.1.1", "dotenv": "~16.0.3", @@ -113,12 +111,14 @@ "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", "mssql": "~11.0.0", "mysql2": "~3.9.6", "nanoid": "~3.3.4", + "net-snmp": "^3.11.2", "node-cloudflared-tunnel": "~1.0.9", "node-radius-client": "~1.0.0", "nodemailer": "~6.9.13", @@ -177,7 +177,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 58ed8f95..72f0a4e8 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/maintenance.js b/server/model/maintenance.js index 516c0377..7111a18c 100644 --- a/server/model/maintenance.js +++ b/server/model/maintenance.js @@ -239,19 +239,7 @@ class Maintenance extends BeanModel { this.beanMeta.status = "under-maintenance"; clearTimeout(this.beanMeta.durationTimeout); - // Check if duration is still in the window. If not, use the duration from the current time to the end of the window - let duration; - - if (customDuration > 0) { - duration = customDuration; - } else if (this.end_date) { - let d = dayjs(this.end_date).diff(dayjs(), "second"); - if (d < this.duration) { - duration = d * 1000; - } - } else { - duration = this.duration * 1000; - } + let duration = this.inferDuration(customDuration); UptimeKumaServer.getInstance().sendMaintenanceListByUserID(this.user_id); @@ -263,9 +251,21 @@ class Maintenance extends BeanModel { }; // Create Cron - this.beanMeta.job = new Cron(this.cron, { - timezone: await this.getTimezone(), - }, startEvent); + if (this.strategy === "recurring-interval") { + // For recurring-interval, Croner needs to have interval and startAt + const startDate = dayjs(this.startDate); + const [ hour, minute ] = this.startTime.split(":"); + const startDateTime = startDate.hour(hour).minute(minute); + this.beanMeta.job = new Cron(this.cron, { + timezone: await this.getTimezone(), + interval: this.interval_day * 24 * 60 * 60, + startAt: startDateTime.toISOString(), + }, startEvent); + } else { + this.beanMeta.job = new Cron(this.cron, { + timezone: await this.getTimezone(), + }, startEvent); + } // Continue if the maintenance is still in the window let runningTimeslot = this.getRunningTimeslot(); @@ -311,6 +311,24 @@ class Maintenance extends BeanModel { } } + /** + * Calculate the maintenance duration + * @param {number} customDuration - The custom duration in milliseconds. + * @returns {number} The inferred duration in milliseconds. + */ + inferDuration(customDuration) { + // Check if duration is still in the window. If not, use the duration from the current time to the end of the window + if (customDuration > 0) { + return customDuration; + } else if (this.end_date) { + let d = dayjs(this.end_date).diff(dayjs(), "second"); + if (d < this.duration) { + return d * 1000; + } + } + return this.duration * 1000; + } + /** * Stop the maintenance * @returns {void} @@ -395,10 +413,8 @@ class Maintenance extends BeanModel { } else if (!this.strategy.startsWith("recurring-")) { this.cron = ""; } else if (this.strategy === "recurring-interval") { - let array = this.start_time.split(":"); - let hour = parseInt(array[0]); - let minute = parseInt(array[1]); - this.cron = minute + " " + hour + " */" + this.interval_day + " * *"; + // For intervals, the pattern is calculated in the run function as the interval-option is set + this.cron = "* * * * *"; this.duration = this.calcDuration(); log.debug("maintenance", "Cron: " + this.cron); log.debug("maintenance", "Duration: " + this.duration); diff --git a/server/model/monitor.js b/server/model/monitor.js index 1b11c614..b55e9891 100644 --- a/server/model/monitor.js +++ b/server/model/monitor.js @@ -2,7 +2,7 @@ const dayjs = require("dayjs"); const axios = require("axios"); const { Prometheus } = require("../prometheus"); const { log, UP, DOWN, PENDING, MAINTENANCE, flipStatus, MAX_INTERVAL_SECOND, MIN_INTERVAL_SECOND, - SQL_DATETIME_FORMAT + SQL_DATETIME_FORMAT, evaluateJsonQuery } = require("../../src/util"); const { tcping, ping, checkCertificate, checkStatusCode, getTotalClientInRoom, setting, mssqlQuery, postgresQuery, mysqlQuery, setSetting, httpNtlm, radius, grpcQuery, redisPingAsync, kafkaProducerAsync, getOidcTokenClientCredentials, rootCertificatesFingerprints, axiosAbortSignal @@ -17,7 +17,6 @@ const apicache = require("../modules/apicache"); const { UptimeKumaServer } = require("../uptime-kuma-server"); const { DockerHost } = require("../docker"); const Gamedig = require("gamedig"); -const jsonata = require("jsonata"); const jwt = require("jsonwebtoken"); const crypto = require("crypto"); const { UptimeCalculator } = require("../uptime-calculator"); @@ -160,7 +159,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) { @@ -293,6 +297,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 @@ -334,7 +346,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 () => { @@ -498,6 +510,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); @@ -598,25 +618,15 @@ class Monitor extends BeanModel { } else if (this.type === "json-query") { let data = res.data; - // convert data to object - if (typeof data === "string" && res.headers["content-type"] !== "application/json") { - try { - data = JSON.parse(data); - } catch (_) { - // Failed to parse as JSON, just process it as a string - } - } + const { status, response } = await evaluateJsonQuery(data, this.jsonPath, this.jsonPathOperator, this.expectedValue); - let expression = jsonata(this.jsonPath); - - let result = await expression.evaluate(data); - - if (result.toString() === this.expectedValue) { - bean.msg += ", expected value is found"; + if (status) { bean.status = UP; + bean.msg = `JSON query passes (comparing ${response} ${this.jsonPathOperator} ${this.expectedValue})`; } else { - throw new Error(bean.msg + ", but value is not equal to expected value, value was: [" + result + "]"); + throw new Error(`JSON query does not pass (comparing ${response} ${this.jsonPathOperator} ${this.expectedValue})`); } + } } else if (this.type === "port") { @@ -988,7 +998,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 528d1dd4..e40b28f6 100644 --- a/server/model/status_page.js +++ b/server/model/status_page.js @@ -4,6 +4,7 @@ const cheerio = require("cheerio"); const { UptimeKumaServer } = require("../uptime-kuma-server"); const jsesc = require("jsesc"); const googleAnalytics = require("../google-analytics"); +const { marked } = require("marked"); class StatusPage extends BeanModel { @@ -46,7 +47,11 @@ class StatusPage extends BeanModel { */ static async renderHTML(indexHTML, statusPage) { const $ = cheerio.load(indexHTML); - const description155 = statusPage.description?.substring(0, 155) ?? ""; + + const description155 = marked(statusPage.description ?? "") + .replace(/<[^>]+>/gm, "") + .trim() + .substring(0, 155); $("title").text(statusPage.title); $("meta[name=description]").attr("content", description155); diff --git a/server/monitor-conditions/evaluator.js b/server/monitor-conditions/evaluator.js new file mode 100644 index 00000000..3860a332 --- /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 00000000..1e703695 --- /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 00000000..d900dff9 --- /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 00000000..af98d2f2 --- /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 d038b680..8b87932f 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 8290bdd7..8f3cbcac 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/monitor-types/snmp.js b/server/monitor-types/snmp.js new file mode 100644 index 00000000..a1760fa3 --- /dev/null +++ b/server/monitor-types/snmp.js @@ -0,0 +1,63 @@ +const { MonitorType } = require("./monitor-type"); +const { UP, log, evaluateJsonQuery } = require("../../src/util"); +const snmp = require("net-snmp"); + +class SNMPMonitorType extends MonitorType { + name = "snmp"; + + /** + * @inheritdoc + */ + async check(monitor, heartbeat, _server) { + let session; + try { + const sessionOptions = { + port: monitor.port || "161", + retries: monitor.maxretries, + timeout: monitor.timeout * 1000, + version: snmp.Version[monitor.snmpVersion], + }; + session = snmp.createSession(monitor.hostname, monitor.radiusPassword, sessionOptions); + + // Handle errors during session creation + session.on("error", (error) => { + throw new Error(`Error creating SNMP session: ${error.message}`); + }); + + const varbinds = await new Promise((resolve, reject) => { + session.get([ monitor.snmpOid ], (error, varbinds) => { + error ? reject(error) : resolve(varbinds); + }); + }); + log.debug("monitor", `SNMP: Received varbinds (Type: ${snmp.ObjectType[varbinds[0].type]} Value: ${varbinds[0].value})`); + + if (varbinds.length === 0) { + throw new Error(`No varbinds returned from SNMP session (OID: ${monitor.snmpOid})`); + } + + if (varbinds[0].type === snmp.ObjectType.NoSuchInstance) { + throw new Error(`The SNMP query returned that no instance exists for OID ${monitor.snmpOid}`); + } + + // We restrict querying to one OID per monitor, therefore `varbinds[0]` will always contain the value we're interested in. + const value = varbinds[0].value; + + const { status, response } = await evaluateJsonQuery(value, monitor.jsonPath, monitor.jsonPathOperator, monitor.expectedValue); + + if (status) { + heartbeat.status = UP; + heartbeat.msg = `JSON query passes (comparing ${response} ${monitor.jsonPathOperator} ${monitor.expectedValue})`; + } else { + throw new Error(`JSON query does not pass (comparing ${response} ${monitor.jsonPathOperator} ${monitor.expectedValue})`); + } + } finally { + if (session) { + session.close(); + } + } + } +} + +module.exports = { + SNMPMonitorType, +}; diff --git a/server/notification-providers/nostr.js b/server/notification-providers/nostr.js index 453b86d0..87847382 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/onesender.js b/server/notification-providers/onesender.js new file mode 100644 index 00000000..4a33931a --- /dev/null +++ b/server/notification-providers/onesender.js @@ -0,0 +1,47 @@ +const NotificationProvider = require("./notification-provider"); +const axios = require("axios"); + +class Onesender extends NotificationProvider { + name = "Onesender"; + + /** + * @inheritdoc + */ + async send(notification, msg, monitorJSON = null, heartbeatJSON = null) { + const okMsg = "Sent Successfully."; + + try { + let data = { + heartbeat: heartbeatJSON, + monitor: monitorJSON, + msg, + to: notification.onesenderReceiver, + type: "text", + recipient_type: "individual", + text: { + body: msg + } + }; + if (notification.onesenderTypeReceiver === "private") { + data.to = notification.onesenderReceiver + "@s.whatsapp.net"; + } else { + data.recipient_type = "group"; + data.to = notification.onesenderReceiver + "@g.us"; + } + let config = { + headers: { + "Authorization": "Bearer " + notification.onesenderToken, + } + }; + await axios.post(notification.onesenderURL, data, config); + return okMsg; + + } catch (error) { + this.throwGeneralAxiosError(error); + } + + } + +} + +module.exports = Onesender; diff --git a/server/notification-providers/pushover.js b/server/notification-providers/pushover.js index 304aa351..8422b64c 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 00000000..e48983f5 --- /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 00000000..db043f9c --- /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 28b0db75..60eae4d7 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"); @@ -64,6 +65,8 @@ const SevenIO = require("./notification-providers/sevenio"); 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 { @@ -111,6 +114,7 @@ class Notification { new Ntfy(), new Octopush(), new OneBot(), + new Onesender(), new Opsgenie(), new PagerDuty(), new FlashDuty(), @@ -124,6 +128,7 @@ class Notification { new ServerChan(), new SerwerSMS(), new Signal(), + new SIGNL4(), new SMSManager(), new SMSPartner(), new Slack(), @@ -145,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 f26125d2..05a02839 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/server.js b/server/server.js index 38158c54..1f27b3c4 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,11 +858,17 @@ 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; bean.gamedigGivenPortOnly = monitor.gamedigGivenPortOnly; bean.remote_browser = monitor.remote_browser; + bean.snmpVersion = monitor.snmpVersion; + bean.snmpOid = monitor.snmpOid; + bean.jsonPathOperator = monitor.jsonPathOperator; + bean.timeout = monitor.timeout; + bean.conditions = JSON.stringify(monitor.conditions); bean.validate(); @@ -1636,6 +1674,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 bcf34c90..ee2394bf 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 68e1f814..50dcd946 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/server/uptime-kuma-server.js b/server/uptime-kuma-server.js index 6ab5f6c2..573d791a 100644 --- a/server/uptime-kuma-server.js +++ b/server/uptime-kuma-server.js @@ -113,6 +113,7 @@ class UptimeKumaServer { UptimeKumaServer.monitorTypeList["tailscale-ping"] = new TailscalePing(); UptimeKumaServer.monitorTypeList["dns"] = new DnsMonitorType(); UptimeKumaServer.monitorTypeList["mqtt"] = new MqttMonitorType(); + UptimeKumaServer.monitorTypeList["snmp"] = new SNMPMonitorType(); UptimeKumaServer.monitorTypeList["mongodb"] = new MongodbMonitorType(); // Allow all CORS origins (polling) in development @@ -517,4 +518,5 @@ const { RealBrowserMonitorType } = require("./monitor-types/real-browser-monitor const { TailscalePing } = require("./monitor-types/tailscale-ping"); const { DnsMonitorType } = require("./monitor-types/dns"); const { MqttMonitorType } = require("./monitor-types/mqtt"); +const { SNMPMonitorType } = require("./monitor-types/snmp"); const { MongodbMonitorType } = require("./monitor-types/mongodb"); diff --git a/src/assets/app.scss b/src/assets/app.scss index c7e56ba7..28eeca87 100644 --- a/src/assets/app.scss +++ b/src/assets/app.scss @@ -576,6 +576,12 @@ optgroup { outline: none !important; } +.prism-editor__container { + .important { + font-weight: var(--bs-body-font-weight) !important; + } +} + h5.settings-subheading::after { content: ""; display: block; diff --git a/src/components/EditMonitorCondition.vue b/src/components/EditMonitorCondition.vue new file mode 100644 index 00000000..ac1b02dd --- /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 00000000..910b4150 --- /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 00000000..60f7c658 --- /dev/null +++ b/src/components/EditMonitorConditions.vue @@ -0,0 +1,149 @@ + + + + + diff --git a/src/components/HeartbeatBar.vue b/src/components/HeartbeatBar.vue index fc044fe5..96a62cf6 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/NotificationDialog.vue b/src/components/NotificationDialog.vue index 42736661..864cbf5f 100644 --- a/src/components/NotificationDialog.vue +++ b/src/components/NotificationDialog.vue @@ -135,6 +135,7 @@ export default { "ntfy": "Ntfy", "octopush": "Octopush", "OneBot": "OneBot", + "Onesender": "Onesender", "Opsgenie": "Opsgenie", "PagerDuty": "PagerDuty", "PagerTree": "PagerTree", @@ -144,6 +145,7 @@ export default { "pushy": "Pushy", "rocket.chat": "Rocket.Chat", "signal": "Signal", + "SIGNL4": "SIGNL4", "slack": "Slack", "squadcast": "SquadCast", "SMSEagle": "SMSEagle", @@ -178,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 d1c1f4c5..48379a7c 100644 --- a/src/components/PublicGroupList.vue +++ b/src/components/PublicGroupList.vue @@ -33,7 +33,7 @@ - -
+ +
- +
- -
+ +
+ +
+ + + + +
{{ $t('snmpCommunityStringHelptext') }}
+
+ +
+ + +
{{ $t('snmpOIDHelptext') }}
+
+ +
+ + +
+ + + +
+
+ + + jsonata.org + {{ $t('playground') }} + + +
+ +
+
+ + +
+
+ + + +
+
+
+ + + +
@@ -483,8 +548,8 @@
- -
+ +
@@ -516,6 +581,18 @@
+
+ + +
+ {{ $t("cacheBusterParamDescription") }} +
+
+
- +
@@ -912,7 +997,7 @@ - +
@@ -931,6 +1016,7 @@ import TagsManager from "../components/TagsManager.vue"; import { genSecret, isDev, MAX_INTERVAL_SECOND, MIN_INTERVAL_SECOND, sleep } from "../util.ts"; import { hostNameRegexPattern } from "../util-frontend"; import HiddenInput from "../components/HiddenInput.vue"; +import EditMonitorConditions from "../components/EditMonitorConditions.vue"; const toast = useToast; @@ -946,7 +1032,6 @@ const monitorDefaults = { retryInterval: 60, resendInterval: 0, maxretries: 0, - timeout: 48, notificationIDList: {}, ignoreTls: false, upsideDown: false, @@ -971,10 +1056,12 @@ const monitorDefaults = { kafkaProducerSaslOptions: { mechanism: "None", }, + cacheBust: false, kafkaProducerSsl: false, kafkaProducerAllowAutoTopicCreation: false, gamedigGivenPortOnly: true, - remote_browser: null + remote_browser: null, + conditions: [] }; export default { @@ -989,6 +1076,7 @@ export default { RemoteBrowserDialog, TagsManager, VueMultiselect, + EditMonitorConditions, }, data() { @@ -1158,8 +1246,8 @@ message HealthCheckResponse { // Only groups, not itself, not a decendant result = result.filter( monitor => monitor.type === "group" && - monitor.id !== this.monitor.id && - !this.monitor.childrenIDs?.includes(monitor.id) + monitor.id !== this.monitor.id && + !this.monitor.childrenIDs?.includes(monitor.id) ); // Filter result by active state, weight and alphabetical @@ -1243,7 +1331,15 @@ message HealthCheckResponse { value: null, }]; } - } + }, + + supportsConditions() { + return this.$root.monitorTypeList[this.monitor.type]?.supportsConditions || false; + }, + + conditionVariables() { + return this.$root.monitorTypeList[this.monitor.type]?.conditionVariables || []; + }, }, watch: { "$root.proxyList"() { @@ -1276,7 +1372,7 @@ message HealthCheckResponse { } }, - "monitor.type"() { + "monitor.type"(newType, oldType) { if (this.monitor.type === "push") { if (! this.monitor.pushToken) { // ideally this would require checking if the generated token is already used @@ -1291,11 +1387,35 @@ message HealthCheckResponse { this.monitor.port = "53"; } else if (this.monitor.type === "radius") { this.monitor.port = "1812"; + } else if (this.monitor.type === "snmp") { + this.monitor.port = "161"; } else { this.monitor.port = undefined; } } + if (this.monitor.type === "snmp") { + // snmp is not expected to be executed via the internet => we can choose a lower default timeout + this.monitor.timeout = 5; + } else { + this.monitor.timeout = 48; + } + + // Set default SNMP version + if (!this.monitor.snmpVersion) { + this.monitor.snmpVersion = "2c"; + } + + // Set default jsonPath + if (!this.monitor.jsonPath) { + this.monitor.jsonPath = "$"; + } + + // Set default condition for for jsonPathOperator + if (!this.monitor.jsonPathOperator) { + this.monitor.jsonPathOperator = "=="; + } + // Get the game list from server if (this.monitor.type === "gamedig") { this.$root.getSocket().emit("getGameList", (res) => { @@ -1324,6 +1444,10 @@ message HealthCheckResponse { } } + // Reset conditions since condition variables likely change: + if (oldType && newType !== oldType) { + this.monitor.conditions = []; + } }, currentGameObject(newGameObject, previousGameObject) { diff --git a/src/util.js b/src/util.js index d8d5ba0c..c88d0496 100644 --- a/src/util.js +++ b/src/util.js @@ -14,8 +14,11 @@ var __importDefault = (this && this.__importDefault) || function (mod) { var _a; Object.defineProperty(exports, "__esModule", { value: true }); exports.sleep = exports.flipStatus = exports.badgeConstants = exports.CONSOLE_STYLE_BgGray = exports.CONSOLE_STYLE_BgWhite = exports.CONSOLE_STYLE_BgCyan = exports.CONSOLE_STYLE_BgMagenta = exports.CONSOLE_STYLE_BgBlue = exports.CONSOLE_STYLE_BgYellow = exports.CONSOLE_STYLE_BgGreen = exports.CONSOLE_STYLE_BgRed = exports.CONSOLE_STYLE_BgBlack = exports.CONSOLE_STYLE_FgPink = exports.CONSOLE_STYLE_FgBrown = exports.CONSOLE_STYLE_FgViolet = exports.CONSOLE_STYLE_FgLightBlue = exports.CONSOLE_STYLE_FgLightGreen = exports.CONSOLE_STYLE_FgOrange = exports.CONSOLE_STYLE_FgGray = exports.CONSOLE_STYLE_FgWhite = exports.CONSOLE_STYLE_FgCyan = exports.CONSOLE_STYLE_FgMagenta = exports.CONSOLE_STYLE_FgBlue = exports.CONSOLE_STYLE_FgYellow = exports.CONSOLE_STYLE_FgGreen = exports.CONSOLE_STYLE_FgRed = exports.CONSOLE_STYLE_FgBlack = exports.CONSOLE_STYLE_Hidden = exports.CONSOLE_STYLE_Reverse = exports.CONSOLE_STYLE_Blink = exports.CONSOLE_STYLE_Underscore = exports.CONSOLE_STYLE_Dim = exports.CONSOLE_STYLE_Bright = exports.CONSOLE_STYLE_Reset = exports.MIN_INTERVAL_SECOND = exports.MAX_INTERVAL_SECOND = exports.SQL_DATETIME_FORMAT_WITHOUT_SECOND = exports.SQL_DATETIME_FORMAT = exports.SQL_DATE_FORMAT = exports.STATUS_PAGE_MAINTENANCE = exports.STATUS_PAGE_PARTIAL_DOWN = exports.STATUS_PAGE_ALL_UP = exports.STATUS_PAGE_ALL_DOWN = exports.MAINTENANCE = exports.PENDING = exports.UP = exports.DOWN = exports.appName = exports.isNode = exports.isDev = void 0; +exports.evaluateJsonQuery = exports.intHash = exports.localToUTC = exports.utcToLocal = exports.utcToISODateTime = exports.isoToUTCDateTime = exports.parseTimeFromTimeObject = exports.parseTimeObject = exports.getMaintenanceRelativeURL = exports.getMonitorRelativeURL = exports.genSecret = exports.getCryptoRandomInt = exports.getRandomInt = exports.getRandomArbitrary = exports.TimeLogger = exports.polyfill = exports.log = exports.debug = exports.ucfirst = void 0; exports.intHash = exports.localToUTC = exports.utcToLocal = exports.utcToISODateTime = exports.isoToUTCDateTime = exports.parseTimeFromTimeObject = exports.parseTimeObject = exports.getMaintenanceRelativeURL = exports.getMonitorRelativeURL = exports.genSecret = exports.getCryptoRandomInt = exports.getRandomInt = exports.getRandomArbitrary = exports.TimeLogger = exports.polyfill = exports.log = exports.debug = exports.ucfirst = void 0; const dayjs_1 = __importDefault(require("dayjs")); +const dayjs = require("dayjs"); +const jsonata = require("jsonata"); exports.isDev = process.env.NODE_ENV === "development"; exports.isNode = typeof process !== "undefined" && ((_a = process === null || process === void 0 ? void 0 : process.versions) === null || _a === void 0 ? void 0 : _a.node); exports.appName = "Uptime Kuma"; @@ -399,3 +402,59 @@ function intHash(str, length = 10) { return (hash % length + length) % length; } exports.intHash = intHash; +async function evaluateJsonQuery(data, jsonPath, jsonPathOperator, expectedValue) { + let response; + try { + response = JSON.parse(data); + } + catch (_a) { + response = (typeof data === "object" || typeof data === "number") && !Buffer.isBuffer(data) ? data : data.toString(); + } + try { + response = (jsonPath) ? await jsonata(jsonPath).evaluate(response) : response; + if (response === null || response === undefined) { + throw new Error("Empty or undefined response. Check query syntax and response structure"); + } + if (typeof response === "object" || response instanceof Date || typeof response === "function") { + throw new Error(`The post-JSON query evaluated response from the server is of type ${typeof response}, which cannot be directly compared to the expected value`); + } + let jsonQueryExpression; + switch (jsonPathOperator) { + case ">": + case ">=": + case "<": + case "<=": + jsonQueryExpression = `$number($.value) ${jsonPathOperator} $number($.expected)`; + break; + case "!=": + jsonQueryExpression = "$.value != $.expected"; + break; + case "==": + jsonQueryExpression = "$.value = $.expected"; + break; + case "contains": + jsonQueryExpression = "$contains($.value, $.expected)"; + break; + default: + throw new Error(`Invalid condition ${jsonPathOperator}`); + } + const expression = jsonata(jsonQueryExpression); + const status = await expression.evaluate({ + value: response.toString(), + expected: expectedValue.toString() + }); + if (status === undefined) { + throw new Error("Query evaluation returned undefined. Check query syntax and the structure of the response data"); + } + return { + status, + response + }; + } + catch (err) { + response = JSON.stringify(response); + response = (response && response.length > 50) ? `${response.substring(0, 100)}… (truncated)` : response; + throw new Error(`Error evaluating JSON query: ${err.message}. Response from server was: ${response}`); + } +} +exports.evaluateJsonQuery = evaluateJsonQuery; diff --git a/src/util.ts b/src/util.ts index 7b5734f8..efc8f3a8 100644 --- a/src/util.ts +++ b/src/util.ts @@ -17,6 +17,8 @@ import * as timezone from "dayjs/plugin/timezone"; // eslint-disable-next-line @typescript-eslint/no-unused-vars import * as utc from "dayjs/plugin/utc"; +import * as jsonata from "jsonata"; + export const isDev = process.env.NODE_ENV === "development"; export const isNode = typeof process !== "undefined" && process?.versions?.node; export const appName = "Uptime Kuma"; @@ -643,3 +645,76 @@ export function intHash(str : string, length = 10) : number { return (hash % length + length) % length; // Ensure the result is non-negative } +/** + * Evaluate a JSON query expression against the provided data. + * @param data The data to evaluate the JSON query against. + * @param jsonPath The JSON path or custom JSON query expression. + * @param jsonPathOperator The operator to use for comparison. + * @param expectedValue The expected value to compare against. + * @returns An object containing the status and the evaluation result. + * @throws Error if the evaluation returns undefined. + */ +export async function evaluateJsonQuery(data: any, jsonPath: string, jsonPathOperator: string, expectedValue: any): Promise<{ status: boolean; response: any }> { + // Attempt to parse data as JSON; if unsuccessful, handle based on data type. + let response: any; + try { + response = JSON.parse(data); + } catch { + response = (typeof data === "object" || typeof data === "number") && !Buffer.isBuffer(data) ? data : data.toString(); + } + + try { + // If a JSON path is provided, pre-evaluate the data using it. + response = (jsonPath) ? await jsonata(jsonPath).evaluate(response) : response; + + if (response === null || response === undefined) { + throw new Error("Empty or undefined response. Check query syntax and response structure"); + } + + if (typeof response === "object" || response instanceof Date || typeof response === "function") { + throw new Error(`The post-JSON query evaluated response from the server is of type ${typeof response}, which cannot be directly compared to the expected value`); + } + + // Perform the comparison logic using the chosen operator + let jsonQueryExpression; + switch (jsonPathOperator) { + case ">": + case ">=": + case "<": + case "<=": + jsonQueryExpression = `$number($.value) ${jsonPathOperator} $number($.expected)`; + break; + case "!=": + jsonQueryExpression = "$.value != $.expected"; + break; + case "==": + jsonQueryExpression = "$.value = $.expected"; + break; + case "contains": + jsonQueryExpression = "$contains($.value, $.expected)"; + break; + default: + throw new Error(`Invalid condition ${jsonPathOperator}`); + } + + // Evaluate the JSON Query Expression + const expression = jsonata(jsonQueryExpression); + const status = await expression.evaluate({ + value: response.toString(), + expected: expectedValue.toString() + }); + + if (status === undefined) { + throw new Error("Query evaluation returned undefined. Check query syntax and the structure of the response data"); + } + + return { + status, // The evaluation of the json query + response // The response from the server or result from initial json-query evaluation + }; + } catch (err: any) { + response = JSON.stringify(response); // Ensure the response is treated as a string for the console + response = (response && response.length > 50) ? `${response.substring(0, 100)}… (truncated)` : response;// Truncate long responses to the console + throw new Error(`Error evaluating JSON query: ${err.message}. Response from server was: ${response}`); + } +} diff --git a/test/backend-test-entry.js b/test/backend-test-entry.js deleted file mode 100644 index 7cc8d734..00000000 --- a/test/backend-test-entry.js +++ /dev/null @@ -1,20 +0,0 @@ -// Check Node.js version -const semver = require("semver"); -const childProcess = require("child_process"); - -const nodeVersion = process.versions.node; -console.log("Node.js version: " + nodeVersion); - - - -// Node.js version >= 18 -if (semver.satisfies(nodeVersion, ">= 18")) { - console.log("Use the native test runner: `node --test`"); - childProcess.execSync("npm run test-backend:18", { stdio: "inherit" }); -} else { - // 14 - 16 here - console.log("Use `test` package: `node--test`") - childProcess.execSync("npm run test-backend:14", { stdio: "inherit" }); -} - - diff --git a/test/backend-test/README.md b/test/backend-test/README.md index 5686fae7..775ffb7a 100644 --- a/test/backend-test/README.md +++ b/test/backend-test/README.md @@ -7,15 +7,7 @@ Create a test file in this directory with the name `*.js`. ## Template ```js -const semver = require("semver"); -let test; -const nodeVersion = process.versions.node; -if (semver.satisfies(nodeVersion, ">= 18")) { - test = require("node:test"); -} else { - test = require("test"); -} - +const test = require("node:test"); const assert = require("node:assert"); test("Test name", async (t) => { @@ -25,14 +17,6 @@ test("Test name", async (t) => { ## Run -Node.js >=18 - ```bash -npm run test-backend:18 -``` - -Node.js < 18 - -```bash -npm run test-backend:14 +npm run test-backend ``` diff --git a/test/backend-test/monitor-conditions/test-evaluator.js b/test/backend-test/monitor-conditions/test-evaluator.js new file mode 100644 index 00000000..da7c7fab --- /dev/null +++ b/test/backend-test/monitor-conditions/test-evaluator.js @@ -0,0 +1,46 @@ +const test = require("node:test"); +const assert = require("node:assert"); +const { ConditionExpressionGroup, ConditionExpression, LOGICAL } = require("../../../server/monitor-conditions/expression.js"); +const { evaluateExpressionGroup, evaluateExpression } = require("../../../server/monitor-conditions/evaluator.js"); + +test("Test evaluateExpression", async (t) => { + const expr = new ConditionExpression("record", "contains", "mx1.example.com"); + assert.strictEqual(true, evaluateExpression(expr, { record: "mx1.example.com" })); + assert.strictEqual(false, evaluateExpression(expr, { record: "mx2.example.com" })); +}); + +test("Test evaluateExpressionGroup with logical AND", async (t) => { + const group = new ConditionExpressionGroup([ + new ConditionExpression("record", "contains", "mx1."), + new ConditionExpression("record", "contains", "example.com", LOGICAL.AND), + ]); + assert.strictEqual(true, evaluateExpressionGroup(group, { record: "mx1.example.com" })); + assert.strictEqual(false, evaluateExpressionGroup(group, { record: "mx1." })); + assert.strictEqual(false, evaluateExpressionGroup(group, { record: "example.com" })); +}); + +test("Test evaluateExpressionGroup with logical OR", async (t) => { + const group = new ConditionExpressionGroup([ + new ConditionExpression("record", "contains", "example.com"), + new ConditionExpression("record", "contains", "example.org", LOGICAL.OR), + ]); + assert.strictEqual(true, evaluateExpressionGroup(group, { record: "example.com" })); + assert.strictEqual(true, evaluateExpressionGroup(group, { record: "example.org" })); + assert.strictEqual(false, evaluateExpressionGroup(group, { record: "example.net" })); +}); + +test("Test evaluateExpressionGroup with nested group", async (t) => { + const group = new ConditionExpressionGroup([ + new ConditionExpression("record", "contains", "mx1."), + new ConditionExpressionGroup([ + new ConditionExpression("record", "contains", "example.com"), + new ConditionExpression("record", "contains", "example.org", LOGICAL.OR), + ]), + ]); + assert.strictEqual(false, evaluateExpressionGroup(group, { record: "mx1." })); + assert.strictEqual(true, evaluateExpressionGroup(group, { record: "mx1.example.com" })); + assert.strictEqual(true, evaluateExpressionGroup(group, { record: "mx1.example.org" })); + assert.strictEqual(false, evaluateExpressionGroup(group, { record: "example.com" })); + assert.strictEqual(false, evaluateExpressionGroup(group, { record: "example.org" })); + assert.strictEqual(false, evaluateExpressionGroup(group, { record: "mx1.example.net" })); +}); diff --git a/test/backend-test/monitor-conditions/test-expressions.js b/test/backend-test/monitor-conditions/test-expressions.js new file mode 100644 index 00000000..fc723a24 --- /dev/null +++ b/test/backend-test/monitor-conditions/test-expressions.js @@ -0,0 +1,55 @@ +const test = require("node:test"); +const assert = require("node:assert"); +const { ConditionExpressionGroup, ConditionExpression } = require("../../../server/monitor-conditions/expression.js"); + +test("Test ConditionExpressionGroup.fromMonitor", async (t) => { + const monitor = { + conditions: JSON.stringify([ + { + "type": "expression", + "andOr": "and", + "operator": "contains", + "value": "foo", + "variable": "record" + }, + { + "type": "group", + "andOr": "and", + "children": [ + { + "type": "expression", + "andOr": "and", + "operator": "contains", + "value": "bar", + "variable": "record" + }, + { + "type": "group", + "andOr": "and", + "children": [ + { + "type": "expression", + "andOr": "and", + "operator": "contains", + "value": "car", + "variable": "record" + } + ] + }, + ] + }, + ]), + }; + const root = ConditionExpressionGroup.fromMonitor(monitor); + assert.strictEqual(true, root.children.length === 2); + assert.strictEqual(true, root.children[0] instanceof ConditionExpression); + assert.strictEqual(true, root.children[0].value === "foo"); + assert.strictEqual(true, root.children[1] instanceof ConditionExpressionGroup); + assert.strictEqual(true, root.children[1].children.length === 2); + assert.strictEqual(true, root.children[1].children[0] instanceof ConditionExpression); + assert.strictEqual(true, root.children[1].children[0].value === "bar"); + assert.strictEqual(true, root.children[1].children[1] instanceof ConditionExpressionGroup); + assert.strictEqual(true, root.children[1].children[1].children.length === 1); + assert.strictEqual(true, root.children[1].children[1].children[0] instanceof ConditionExpression); + assert.strictEqual(true, root.children[1].children[1].children[0].value === "car"); +}); diff --git a/test/backend-test/monitor-conditions/test-operators.js b/test/backend-test/monitor-conditions/test-operators.js new file mode 100644 index 00000000..e663c9a5 --- /dev/null +++ b/test/backend-test/monitor-conditions/test-operators.js @@ -0,0 +1,108 @@ +const test = require("node:test"); +const assert = require("node:assert"); +const { operatorMap, OP_CONTAINS, OP_NOT_CONTAINS, OP_LT, OP_GT, OP_LTE, OP_GTE, OP_STR_EQUALS, OP_STR_NOT_EQUALS, OP_NUM_EQUALS, OP_NUM_NOT_EQUALS, OP_STARTS_WITH, OP_ENDS_WITH, OP_NOT_STARTS_WITH, OP_NOT_ENDS_WITH } = require("../../../server/monitor-conditions/operators.js"); + +test("Test StringEqualsOperator", async (t) => { + const op = operatorMap.get(OP_STR_EQUALS); + assert.strictEqual(true, op.test("mx1.example.com", "mx1.example.com")); + assert.strictEqual(false, op.test("mx1.example.com", "mx1.example.org")); + assert.strictEqual(false, op.test("1", 1)); // strict equality +}); + +test("Test StringNotEqualsOperator", async (t) => { + const op = operatorMap.get(OP_STR_NOT_EQUALS); + assert.strictEqual(true, op.test("mx1.example.com", "mx1.example.org")); + assert.strictEqual(false, op.test("mx1.example.com", "mx1.example.com")); + assert.strictEqual(true, op.test(1, "1")); // variable is not typecasted (strict equality) +}); + +test("Test ContainsOperator with scalar", async (t) => { + const op = operatorMap.get(OP_CONTAINS); + assert.strictEqual(true, op.test("mx1.example.org", "example.org")); + assert.strictEqual(false, op.test("mx1.example.org", "example.com")); +}); + +test("Test ContainsOperator with array", async (t) => { + const op = operatorMap.get(OP_CONTAINS); + assert.strictEqual(true, op.test([ "example.org" ], "example.org")); + assert.strictEqual(false, op.test([ "example.org" ], "example.com")); +}); + +test("Test NotContainsOperator with scalar", async (t) => { + const op = operatorMap.get(OP_NOT_CONTAINS); + assert.strictEqual(true, op.test("example.org", ".com")); + assert.strictEqual(false, op.test("example.org", ".org")); +}); + +test("Test NotContainsOperator with array", async (t) => { + const op = operatorMap.get(OP_NOT_CONTAINS); + assert.strictEqual(true, op.test([ "example.org" ], "example.com")); + assert.strictEqual(false, op.test([ "example.org" ], "example.org")); +}); + +test("Test StartsWithOperator", async (t) => { + const op = operatorMap.get(OP_STARTS_WITH); + assert.strictEqual(true, op.test("mx1.example.com", "mx1")); + assert.strictEqual(false, op.test("mx1.example.com", "mx2")); +}); + +test("Test NotStartsWithOperator", async (t) => { + const op = operatorMap.get(OP_NOT_STARTS_WITH); + assert.strictEqual(true, op.test("mx1.example.com", "mx2")); + assert.strictEqual(false, op.test("mx1.example.com", "mx1")); +}); + +test("Test EndsWithOperator", async (t) => { + const op = operatorMap.get(OP_ENDS_WITH); + assert.strictEqual(true, op.test("mx1.example.com", "example.com")); + assert.strictEqual(false, op.test("mx1.example.com", "example.net")); +}); + +test("Test NotEndsWithOperator", async (t) => { + const op = operatorMap.get(OP_NOT_ENDS_WITH); + assert.strictEqual(true, op.test("mx1.example.com", "example.net")); + assert.strictEqual(false, op.test("mx1.example.com", "example.com")); +}); + +test("Test NumberEqualsOperator", async (t) => { + const op = operatorMap.get(OP_NUM_EQUALS); + assert.strictEqual(true, op.test(1, 1)); + assert.strictEqual(true, op.test(1, "1")); + assert.strictEqual(false, op.test(1, "2")); +}); + +test("Test NumberNotEqualsOperator", async (t) => { + const op = operatorMap.get(OP_NUM_NOT_EQUALS); + assert.strictEqual(true, op.test(1, "2")); + assert.strictEqual(false, op.test(1, "1")); +}); + +test("Test LessThanOperator", async (t) => { + const op = operatorMap.get(OP_LT); + assert.strictEqual(true, op.test(1, 2)); + assert.strictEqual(true, op.test(1, "2")); + assert.strictEqual(false, op.test(1, 1)); +}); + +test("Test GreaterThanOperator", async (t) => { + const op = operatorMap.get(OP_GT); + assert.strictEqual(true, op.test(2, 1)); + assert.strictEqual(true, op.test(2, "1")); + assert.strictEqual(false, op.test(1, 1)); +}); + +test("Test LessThanOrEqualToOperator", async (t) => { + const op = operatorMap.get(OP_LTE); + assert.strictEqual(true, op.test(1, 1)); + assert.strictEqual(true, op.test(1, 2)); + assert.strictEqual(true, op.test(1, "2")); + assert.strictEqual(false, op.test(1, 0)); +}); + +test("Test GreaterThanOrEqualToOperator", async (t) => { + const op = operatorMap.get(OP_GTE); + assert.strictEqual(true, op.test(1, 1)); + assert.strictEqual(true, op.test(2, 1)); + assert.strictEqual(true, op.test(2, "2")); + assert.strictEqual(false, op.test(2, 3)); +}); diff --git a/test/backend-test/test-uptime-calculator.js b/test/backend-test/test-uptime-calculator.js index 0c7a38d2..4f2f05ef 100644 --- a/test/backend-test/test-uptime-calculator.js +++ b/test/backend-test/test-uptime-calculator.js @@ -1,13 +1,4 @@ -const semver = require("semver"); -let test; -const nodeVersion = process.versions.node; -// Node.js version >= 18 -if (semver.satisfies(nodeVersion, ">= 18")) { - test = require("node:test"); -} else { - test = require("test"); -} - +const test = require("node:test"); const assert = require("node:assert"); const { UptimeCalculator } = require("../../server/uptime-calculator"); const dayjs = require("dayjs"); diff --git a/test/e2e/setup.spec.js b/test/e2e/setup.spec.js deleted file mode 100644 index 55dcb34e..00000000 --- a/test/e2e/setup.spec.js +++ /dev/null @@ -1,43 +0,0 @@ -import { test } from "@playwright/test"; -import { login, screenshot } from "./util-test"; - -/* - * Setup - */ - -test("setup sqlite", async ({ page }, testInfo) => { - await page.goto("./"); - await page.getByText("SQLite").click(); - await page.getByRole("button", { name: "Next" }).click(); - await screenshot(testInfo, page); -}); - -test("setup admin", async ({ page }, testInfo) => { - await page.goto("./"); - await page.getByPlaceholder("Username").click(); - await page.getByPlaceholder("Username").fill("admin"); - await page.getByPlaceholder("Username").press("Tab"); - await page.getByPlaceholder("Password", { exact: true }).fill("admin123"); - await page.getByPlaceholder("Password", { exact: true }).press("Tab"); - await page.getByPlaceholder("Repeat Password").fill("admin123"); - await page.getByRole("button", { name: "Create" }).click(); - await screenshot(testInfo, page); -}); - -/* - * All other tests should be run after setup - */ - -test("login", async ({ page }, testInfo) => { - await page.goto("./dashboard"); - await login(page); - await screenshot(testInfo, page); -}); - -test("logout", async ({ page }, testInfo) => { - await page.goto("./dashboard"); - await login(page); - await page.getByText("A", { exact: true }).click(); - await page.getByRole("button", { name: "Log out" }).click(); - await screenshot(testInfo, page); -}); diff --git a/test/e2e/specs/example.spec.js b/test/e2e/specs/example.spec.js new file mode 100644 index 00000000..27c605dd --- /dev/null +++ b/test/e2e/specs/example.spec.js @@ -0,0 +1,38 @@ +import { expect, test } from "@playwright/test"; +import { login, restoreSqliteSnapshot, screenshot } from "../util-test"; + +test.describe("Example Spec", () => { + + test.beforeEach(async ({ page }) => { + await restoreSqliteSnapshot(page); + }); + + test("dashboard", async ({ page }, testInfo) => { + await page.goto("./dashboard"); + await login(page); + await screenshot(testInfo, page); + }); + + test("change display timezone", async ({ page }, testInfo) => { + await page.goto("./settings/general"); + await login(page); + await page.getByLabel("Display Timezone").selectOption("Pacific/Fiji"); + await page.getByRole("button", { name: "Save" }).click(); + await screenshot(testInfo, page); + + await page.goto("./dashboard"); + await page.goto("./settings/general"); + await expect(page.getByLabel("Display Timezone")).toHaveValue("Pacific/Fiji"); + }); + + test("database is reset after previous test", async ({ page }, testInfo) => { + await page.goto("./settings/general"); + await login(page); + + const timezoneEl = page.getByLabel("Display Timezone"); + await expect(timezoneEl).toBeVisible(); + await expect(timezoneEl).toHaveValue("auto"); + await screenshot(testInfo, page); + }); + +}); diff --git a/test/e2e/specs/monitor-form.spec.js b/test/e2e/specs/monitor-form.spec.js new file mode 100644 index 00000000..7efc117c --- /dev/null +++ b/test/e2e/specs/monitor-form.spec.js @@ -0,0 +1,109 @@ +import { expect, test } from "@playwright/test"; +import { login, restoreSqliteSnapshot, screenshot } from "../util-test"; + +test.describe("Monitor Form", () => { + + test.beforeEach(async ({ page }) => { + await restoreSqliteSnapshot(page); + }); + + test("condition ui", async ({ page }, testInfo) => { + await page.goto("./add"); + await login(page); + await screenshot(testInfo, page); + + const monitorTypeSelect = page.getByTestId("monitor-type-select"); + await expect(monitorTypeSelect).toBeVisible(); + + await monitorTypeSelect.selectOption("dns"); + const selectedValue = await monitorTypeSelect.evaluate(select => select.value); + expect(selectedValue).toBe("dns"); + + // Add Conditions & verify: + await page.getByTestId("add-condition-button").click(); + expect(await page.getByTestId("condition").count()).toEqual(2); // 1 added by default + 1 explicitly added + + // Add a Condition Group & verify: + await page.getByTestId("add-group-button").click(); + expect(await page.getByTestId("condition-group").count()).toEqual(1); + expect(await page.getByTestId("condition").count()).toEqual(3); // 2 solo conditions + 1 condition in group + + await screenshot(testInfo, page); + + // Remove a condition & verify: + await page.getByTestId("remove-condition").first().click(); + expect(await page.getByTestId("condition").count()).toEqual(2); // 1 solo condition + 1 condition in group + + // Remove a condition group & verify: + await page.getByTestId("remove-condition-group").first().click(); + expect(await page.getByTestId("condition-group").count()).toEqual(0); + + await screenshot(testInfo, page); + }); + + test("successful condition", async ({ page }, testInfo) => { + await page.goto("./add"); + await login(page); + await screenshot(testInfo, page); + + const monitorTypeSelect = page.getByTestId("monitor-type-select"); + await expect(monitorTypeSelect).toBeVisible(); + + await monitorTypeSelect.selectOption("dns"); + const selectedValue = await monitorTypeSelect.evaluate(select => select.value); + expect(selectedValue).toBe("dns"); + + const friendlyName = "Example DNS NS"; + await page.getByTestId("friendly-name-input").fill(friendlyName); + await page.getByTestId("hostname-input").fill("example.com"); + + // Vue-Multiselect component + const resolveTypeSelect = page.getByTestId("resolve-type-select"); + await resolveTypeSelect.click(); + await resolveTypeSelect.getByRole("option", { name: "NS" }).click(); + + await page.getByTestId("add-condition-button").click(); + expect(await page.getByTestId("condition").count()).toEqual(2); // 1 added by default + 1 explicitly added + await page.getByTestId("condition-value").nth(0).fill("a.iana-servers.net"); + await page.getByTestId("condition-and-or").nth(0).selectOption("or"); + await page.getByTestId("condition-value").nth(1).fill("b.iana-servers.net"); + await screenshot(testInfo, page); + + await page.getByTestId("save-button").click(); + await page.waitForURL("/dashboard/*"); // wait for the monitor to be created + await expect(page.getByTestId("monitor-status")).toHaveText("up", { ignoreCase: true }); + await screenshot(testInfo, page); + }); + + test("failing condition", async ({ page }, testInfo) => { + await page.goto("./add"); + await login(page); + await screenshot(testInfo, page); + + const monitorTypeSelect = page.getByTestId("monitor-type-select"); + await expect(monitorTypeSelect).toBeVisible(); + + await monitorTypeSelect.selectOption("dns"); + const selectedValue = await monitorTypeSelect.evaluate(select => select.value); + expect(selectedValue).toBe("dns"); + + const friendlyName = "Example DNS NS"; + await page.getByTestId("friendly-name-input").fill(friendlyName); + await page.getByTestId("hostname-input").fill("example.com"); + + // Vue-Multiselect component + const resolveTypeSelect = page.getByTestId("resolve-type-select"); + await resolveTypeSelect.click(); + await resolveTypeSelect.getByRole("option", { name: "NS" }).click(); + + expect(await page.getByTestId("condition").count()).toEqual(1); // 1 added by default + await page.getByTestId("condition-value").nth(0).fill("definitely-not.net"); + await screenshot(testInfo, page); + + await page.getByTestId("save-button").click(); + await page.waitForURL("/dashboard/*"); // wait for the monitor to be created + await expect(page.getByTestId("monitor-status")).toHaveText("down", { ignoreCase: true }); + await screenshot(testInfo, page); + }); + +}); diff --git a/test/e2e/specs/setup-process.once.js b/test/e2e/specs/setup-process.once.js new file mode 100644 index 00000000..0dde0cdc --- /dev/null +++ b/test/e2e/specs/setup-process.once.js @@ -0,0 +1,56 @@ +import { test } from "@playwright/test"; +import { getSqliteDatabaseExists, login, screenshot, takeSqliteSnapshot } from "../util-test"; + +test.describe("Uptime Kuma Setup", () => { + + test.skip(() => getSqliteDatabaseExists(), "Must only run once per session"); + + /* + * Setup + */ + + test("setup sqlite", async ({ page }, testInfo) => { + await page.goto("./"); + await page.getByText("SQLite").click(); + await page.getByRole("button", { name: "Next" }).click(); + await screenshot(testInfo, page); + await page.waitForURL("/setup"); // ensures the server is ready to continue to the next test + await screenshot(testInfo, page); + }); + + test("setup admin", async ({ page }, testInfo) => { + await page.goto("./"); + await page.getByPlaceholder("Username").click(); + await page.getByPlaceholder("Username").fill("admin"); + await page.getByPlaceholder("Username").press("Tab"); + await page.getByPlaceholder("Password", { exact: true }).fill("admin123"); + await page.getByPlaceholder("Password", { exact: true }).press("Tab"); + await page.getByPlaceholder("Repeat Password").fill("admin123"); + await page.getByRole("button", { name: "Create" }).click(); + await screenshot(testInfo, page); + }); + + /* + * All other tests should be run after setup + */ + + test("login", async ({ page }, testInfo) => { + await page.goto("./dashboard"); + await login(page); + await screenshot(testInfo, page); + }); + + test("logout", async ({ page }, testInfo) => { + await page.goto("./dashboard"); + await login(page); + await page.getByText("A", { exact: true }).click(); + await page.getByRole("button", { name: "Log out" }).click(); + await screenshot(testInfo, page); + }); + + test("take sqlite snapshot", async ({ page }, testInfo) => { + await takeSqliteSnapshot(page); + await screenshot(testInfo, page); + }); + +}); diff --git a/test/e2e/util-test.js b/test/e2e/util-test.js index 285e7b71..f6af3cbd 100644 --- a/test/e2e/util-test.js +++ b/test/e2e/util-test.js @@ -1,3 +1,9 @@ +const fs = require("fs"); +const path = require("path"); +const serverUrl = require("../../config/playwright.config.js").url; + +const dbPath = "./../../data/playwright-test/kuma.db"; + /** * @param {TestInfo} testInfo Test info * @param {Page} page Page @@ -25,3 +31,32 @@ export async function login(page) { await page.getByRole("button", { name: "Log in" }).click(); await page.isVisible("text=Add New Monitor"); } + +/** + * Determines if the SQLite database has been created. This indicates setup has completed. + * @returns {boolean} True if exists + */ +export function getSqliteDatabaseExists() { + return fs.existsSync(path.resolve(__dirname, dbPath)); +} + +/** + * Makes a request to the server to take a snapshot of the SQLite database. + * @param {Page|null} page Page + * @returns {Promise} Promise of response from snapshot request. + */ +export async function takeSqliteSnapshot(page = null) { + if (page) { + return page.goto("./_e2e/take-sqlite-snapshot"); + } else { + return fetch(`${serverUrl}/_e2e/take-sqlite-snapshot`); + } +} + +/** + * Makes a request to the server to restore the snapshot of the SQLite database. + * @returns {Promise} Promise of response from restoration request. + */ +export async function restoreSqliteSnapshot() { + return fetch(`${serverUrl}/_e2e/restore-sqlite-snapshot`); +}