From 36f8be040dcfef2bcc16fca4c1914a53ac4935fe Mon Sep 17 00:00:00 2001 From: Shaun Date: Fri, 30 Aug 2024 15:48:13 -0400 Subject: [PATCH] Monitor Conditions (#5048) --- .../2024-08-24-0000-conditions.js | 12 + server/client.js | 27 ++ server/model/monitor.js | 1 + server/monitor-conditions/evaluator.js | 71 ++++ server/monitor-conditions/expression.js | 111 ++++++ server/monitor-conditions/operators.js | 318 ++++++++++++++++++ server/monitor-conditions/variables.js | 31 ++ server/monitor-types/dns.js | 78 +++-- server/monitor-types/monitor-type.js | 13 + server/server.js | 6 +- src/components/EditMonitorCondition.vue | 152 +++++++++ src/components/EditMonitorConditionGroup.vue | 189 +++++++++++ src/components/EditMonitorConditions.vue | 149 ++++++++ src/lang/en.json | 22 +- src/mixins/socket.js | 5 + src/pages/Details.vue | 2 +- src/pages/EditMonitor.vue | 56 ++- .../monitor-conditions/test-evaluator.js | 46 +++ .../monitor-conditions/test-expressions.js | 55 +++ .../monitor-conditions/test-operators.js | 108 ++++++ test/e2e/specs/monitor-form.spec.js | 109 ++++++ 21 files changed, 1526 insertions(+), 35 deletions(-) create mode 100644 db/knex_migrations/2024-08-24-0000-conditions.js create mode 100644 server/monitor-conditions/evaluator.js create mode 100644 server/monitor-conditions/expression.js create mode 100644 server/monitor-conditions/operators.js create mode 100644 server/monitor-conditions/variables.js create mode 100644 src/components/EditMonitorCondition.vue create mode 100644 src/components/EditMonitorConditionGroup.vue create mode 100644 src/components/EditMonitorConditions.vue create mode 100644 test/backend-test/monitor-conditions/test-evaluator.js create mode 100644 test/backend-test/monitor-conditions/test-expressions.js create mode 100644 test/backend-test/monitor-conditions/test-operators.js create mode 100644 test/e2e/specs/monitor-form.spec.js 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/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/monitor.js b/server/model/monitor.js index 73901e80..b55e9891 100644 --- a/server/model/monitor.js +++ b/server/model/monitor.js @@ -164,6 +164,7 @@ class Monitor extends BeanModel { snmpOid: this.snmpOid, jsonPathOperator: this.jsonPathOperator, snmpVersion: this.snmpVersion, + conditions: JSON.parse(this.conditions), }; if (includeSensitiveData) { 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/server.js b/server/server.js index 21491da9..1f27b3c4 100644 --- a/server/server.js +++ b/server/server.js @@ -132,7 +132,7 @@ 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 { remoteBrowserSocketHandler } = require("./socket-handlers/remote-browser-socket-handler"); @@ -716,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; @@ -866,6 +868,7 @@ let needSetup = false; bean.snmpOid = monitor.snmpOid; bean.jsonPathOperator = monitor.jsonPathOperator; bean.timeout = monitor.timeout; + bean.conditions = JSON.stringify(monitor.conditions); bean.validate(); @@ -1671,6 +1674,7 @@ async function afterLogin(socket, user) { sendDockerHostList(socket), sendAPIKeyList(socket), sendRemoteBrowserList(socket), + sendMonitorTypeList(socket), ]); await StatusPage.sendStatusPageList(io, socket); 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/lang/en.json b/src/lang/en.json index a09f0162..e8b74a9e 100644 --- a/src/lang/en.json +++ b/src/lang/en.json @@ -444,6 +444,7 @@ "backupOutdatedWarning": "Deprecated: Since a lot of features were added and this backup feature is a bit unmaintained, it cannot generate or restore a complete backup.", "backupRecommend": "Please backup the volume or the data folder (./data/) directly instead.", "Optional": "Optional", + "and": "and", "or": "or", "sameAsServerTimezone": "Same as Server Timezone", "startDateTime": "Start Date/Time", @@ -994,5 +995,24 @@ "Cannot connect to the socket server.": "Cannot connect to the socket server.", "SIGNL4": "SIGNL4", "SIGNL4 Webhook URL": "SIGNL4 Webhook URL", - "signl4Docs": "You can find more information about how to configure SIGNL4 and how to obtain the SIGNL4 webhook URL in the {0}." + "signl4Docs": "You can find more information about how to configure SIGNL4 and how to obtain the SIGNL4 webhook URL in the {0}.", + "Conditions": "Conditions", + "conditionAdd": "Add Condition", + "conditionDelete": "Delete Condition", + "conditionAddGroup": "Add Group", + "conditionDeleteGroup": "Delete Group", + "conditionValuePlaceholder": "Value", + "equals": "equals", + "not equals": "not equals", + "contains": "contains", + "not contains": "not contains", + "starts with": "starts with", + "not starts with": "not starts with", + "ends with": "ends with", + "not ends with": "not ends with", + "less than": "less than", + "greater than": "greater than", + "less than or equal to": "less than or equal to", + "greater than or equal to": "greater than or equal to", + "record": "record" } diff --git a/src/mixins/socket.js b/src/mixins/socket.js index f305cc7c..4541161c 100644 --- a/src/mixins/socket.js +++ b/src/mixins/socket.js @@ -38,6 +38,7 @@ export default { allowLoginDialog: false, // Allowed to show login dialog, but "loggedIn" have to be true too. This exists because prevent the login dialog show 0.1s in first before the socket server auth-ed. loggedIn: false, monitorList: { }, + monitorTypeList: {}, maintenanceList: {}, apiKeyList: {}, heartbeatList: { }, @@ -153,6 +154,10 @@ export default { this.monitorList = data; }); + socket.on("monitorTypeList", (data) => { + this.monitorTypeList = data; + }); + socket.on("maintenanceList", (data) => { this.maintenanceList = data; }); diff --git a/src/pages/Details.vue b/src/pages/Details.vue index 16e45dab..17d32365 100644 --- a/src/pages/Details.vue +++ b/src/pages/Details.vue @@ -79,7 +79,7 @@ {{ $t("checkEverySecond", [ monitor.interval ]) }}
- {{ status.text }} + {{ status.text }}
diff --git a/src/pages/EditMonitor.vue b/src/pages/EditMonitor.vue index 8318bb7a..eca386e0 100644 --- a/src/pages/EditMonitor.vue +++ b/src/pages/EditMonitor.vue @@ -10,7 +10,7 @@
-
@@ -972,7 +997,7 @@ - + @@ -991,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; @@ -1034,7 +1060,8 @@ const monitorDefaults = { kafkaProducerSsl: false, kafkaProducerAllowAutoTopicCreation: false, gamedigGivenPortOnly: true, - remote_browser: null + remote_browser: null, + conditions: [] }; export default { @@ -1049,6 +1076,7 @@ export default { RemoteBrowserDialog, TagsManager, VueMultiselect, + EditMonitorConditions, }, data() { @@ -1303,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"() { @@ -1336,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 @@ -1408,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/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/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); + }); + +});