mirror of
https://github.com/louislam/uptime-kuma.git
synced 2024-11-27 16:54:04 +00:00
Monitor Conditions (#5048)
This commit is contained in:
parent
032ac161f7
commit
36f8be040d
21 changed files with 1526 additions and 35 deletions
12
db/knex_migrations/2024-08-24-0000-conditions.js
Normal file
12
db/knex_migrations/2024-08-24-0000-conditions.js
Normal file
|
@ -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");
|
||||||
|
});
|
||||||
|
};
|
|
@ -213,6 +213,32 @@ async function sendRemoteBrowserList(socket) {
|
||||||
return list;
|
return list;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send list of monitor types to client
|
||||||
|
* @param {Socket} socket Socket.io socket instance
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
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 = {
|
module.exports = {
|
||||||
sendNotificationList,
|
sendNotificationList,
|
||||||
sendImportantHeartbeatList,
|
sendImportantHeartbeatList,
|
||||||
|
@ -222,4 +248,5 @@ module.exports = {
|
||||||
sendInfo,
|
sendInfo,
|
||||||
sendDockerHostList,
|
sendDockerHostList,
|
||||||
sendRemoteBrowserList,
|
sendRemoteBrowserList,
|
||||||
|
sendMonitorTypeList,
|
||||||
};
|
};
|
||||||
|
|
|
@ -164,6 +164,7 @@ class Monitor extends BeanModel {
|
||||||
snmpOid: this.snmpOid,
|
snmpOid: this.snmpOid,
|
||||||
jsonPathOperator: this.jsonPathOperator,
|
jsonPathOperator: this.jsonPathOperator,
|
||||||
snmpVersion: this.snmpVersion,
|
snmpVersion: this.snmpVersion,
|
||||||
|
conditions: JSON.parse(this.conditions),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (includeSensitiveData) {
|
if (includeSensitiveData) {
|
||||||
|
|
71
server/monitor-conditions/evaluator.js
Normal file
71
server/monitor-conditions/evaluator.js
Normal file
|
@ -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,
|
||||||
|
};
|
111
server/monitor-conditions/expression.js
Normal file
111
server/monitor-conditions/expression.js
Normal file
|
@ -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,
|
||||||
|
};
|
318
server/monitor-conditions/operators.js
Normal file
318
server/monitor-conditions/operators.js
Normal file
|
@ -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,
|
||||||
|
};
|
31
server/monitor-conditions/variables.js
Normal file
31
server/monitor-conditions/variables.js
Normal file
|
@ -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,
|
||||||
|
};
|
|
@ -1,12 +1,22 @@
|
||||||
const { MonitorType } = require("./monitor-type");
|
const { MonitorType } = require("./monitor-type");
|
||||||
const { UP } = require("../../src/util");
|
const { UP, DOWN } = require("../../src/util");
|
||||||
const dayjs = require("dayjs");
|
const dayjs = require("dayjs");
|
||||||
const { dnsResolve } = require("../util-server");
|
const { dnsResolve } = require("../util-server");
|
||||||
const { R } = require("redbean-node");
|
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 {
|
class DnsMonitorType extends MonitorType {
|
||||||
name = "dns";
|
name = "dns";
|
||||||
|
|
||||||
|
supportsConditions = true;
|
||||||
|
|
||||||
|
conditionVariables = [
|
||||||
|
new ConditionVariable("record", defaultStringOperators ),
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @inheritdoc
|
* @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);
|
let dnsRes = await dnsResolve(monitor.hostname, monitor.dns_resolve_server, monitor.port, monitor.dns_resolve_type);
|
||||||
heartbeat.ping = dayjs().valueOf() - startTime;
|
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") {
|
const conditions = ConditionExpressionGroup.fromMonitor(monitor);
|
||||||
dnsMessage += "Records: ";
|
let conditionsResult = true;
|
||||||
dnsMessage += dnsRes.join(" | ");
|
const handleConditions = (data) => conditions ? evaluateExpressionGroup(conditions, data) : true;
|
||||||
} else if (monitor.dns_resolve_type === "CNAME" || monitor.dns_resolve_type === "PTR") {
|
|
||||||
dnsMessage += dnsRes[0];
|
switch (monitor.dns_resolve_type) {
|
||||||
} else if (monitor.dns_resolve_type === "CAA") {
|
case "A":
|
||||||
dnsMessage += dnsRes[0].issue;
|
case "AAAA":
|
||||||
} else if (monitor.dns_resolve_type === "MX") {
|
case "TXT":
|
||||||
dnsRes.forEach(record => {
|
case "PTR":
|
||||||
dnsMessage += `Hostname: ${record.exchange} - Priority: ${record.priority} | `;
|
dnsMessage = `Records: ${dnsRes.join(" | ")}`;
|
||||||
});
|
conditionsResult = dnsRes.some(record => handleConditions({ record }));
|
||||||
dnsMessage = dnsMessage.slice(0, -2);
|
break;
|
||||||
} else if (monitor.dns_resolve_type === "NS") {
|
|
||||||
dnsMessage += "Servers: ";
|
case "CNAME":
|
||||||
dnsMessage += dnsRes.join(" | ");
|
dnsMessage = dnsRes[0];
|
||||||
} else if (monitor.dns_resolve_type === "SOA") {
|
conditionsResult = handleConditions({ record: dnsRes[0] });
|
||||||
dnsMessage += `NS-Name: ${dnsRes.nsname} | Hostmaster: ${dnsRes.hostmaster} | Serial: ${dnsRes.serial} | Refresh: ${dnsRes.refresh} | Retry: ${dnsRes.retry} | Expire: ${dnsRes.expire} | MinTTL: ${dnsRes.minttl}`;
|
break;
|
||||||
} else if (monitor.dns_resolve_type === "SRV") {
|
|
||||||
dnsRes.forEach(record => {
|
case "CAA":
|
||||||
dnsMessage += `Name: ${record.name} | Port: ${record.port} | Priority: ${record.priority} | Weight: ${record.weight} | `;
|
dnsMessage = dnsRes[0].issue;
|
||||||
});
|
conditionsResult = handleConditions({ record: dnsRes[0].issue });
|
||||||
dnsMessage = dnsMessage.slice(0, -2);
|
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) {
|
if (monitor.dns_last_result !== dnsMessage && dnsMessage !== undefined) {
|
||||||
|
@ -46,7 +76,7 @@ class DnsMonitorType extends MonitorType {
|
||||||
}
|
}
|
||||||
|
|
||||||
heartbeat.msg = dnsMessage;
|
heartbeat.msg = dnsMessage;
|
||||||
heartbeat.status = UP;
|
heartbeat.status = conditionsResult ? UP : DOWN;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,19 @@
|
||||||
class MonitorType {
|
class MonitorType {
|
||||||
name = undefined;
|
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
|
* Run the monitoring check on the given monitor
|
||||||
* @param {Monitor} monitor Monitor to check
|
* @param {Monitor} monitor Monitor to check
|
||||||
|
|
|
@ -132,7 +132,7 @@ const twoFAVerifyOptions = {
|
||||||
const testMode = !!args["test"] || false;
|
const testMode = !!args["test"] || false;
|
||||||
|
|
||||||
// Must be after io instantiation
|
// 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 { 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 { remoteBrowserSocketHandler } = require("./socket-handlers/remote-browser-socket-handler");
|
||||||
|
@ -716,6 +716,8 @@ let needSetup = false;
|
||||||
monitor.kafkaProducerBrokers = JSON.stringify(monitor.kafkaProducerBrokers);
|
monitor.kafkaProducerBrokers = JSON.stringify(monitor.kafkaProducerBrokers);
|
||||||
monitor.kafkaProducerSaslOptions = JSON.stringify(monitor.kafkaProducerSaslOptions);
|
monitor.kafkaProducerSaslOptions = JSON.stringify(monitor.kafkaProducerSaslOptions);
|
||||||
|
|
||||||
|
monitor.conditions = JSON.stringify(monitor.conditions);
|
||||||
|
|
||||||
bean.import(monitor);
|
bean.import(monitor);
|
||||||
bean.user_id = socket.userID;
|
bean.user_id = socket.userID;
|
||||||
|
|
||||||
|
@ -866,6 +868,7 @@ let needSetup = false;
|
||||||
bean.snmpOid = monitor.snmpOid;
|
bean.snmpOid = monitor.snmpOid;
|
||||||
bean.jsonPathOperator = monitor.jsonPathOperator;
|
bean.jsonPathOperator = monitor.jsonPathOperator;
|
||||||
bean.timeout = monitor.timeout;
|
bean.timeout = monitor.timeout;
|
||||||
|
bean.conditions = JSON.stringify(monitor.conditions);
|
||||||
|
|
||||||
bean.validate();
|
bean.validate();
|
||||||
|
|
||||||
|
@ -1671,6 +1674,7 @@ async function afterLogin(socket, user) {
|
||||||
sendDockerHostList(socket),
|
sendDockerHostList(socket),
|
||||||
sendAPIKeyList(socket),
|
sendAPIKeyList(socket),
|
||||||
sendRemoteBrowserList(socket),
|
sendRemoteBrowserList(socket),
|
||||||
|
sendMonitorTypeList(socket),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
await StatusPage.sendStatusPageList(io, socket);
|
await StatusPage.sendStatusPageList(io, socket);
|
||||||
|
|
152
src/components/EditMonitorCondition.vue
Normal file
152
src/components/EditMonitorCondition.vue
Normal file
|
@ -0,0 +1,152 @@
|
||||||
|
<template>
|
||||||
|
<div class="monitor-condition mb-3" data-testid="condition">
|
||||||
|
<button
|
||||||
|
v-if="!isInGroup || !isFirst || !isLast"
|
||||||
|
class="btn btn-outline-danger remove-button"
|
||||||
|
type="button"
|
||||||
|
:aria-label="$t('conditionDelete')"
|
||||||
|
data-testid="remove-condition"
|
||||||
|
@click="remove"
|
||||||
|
>
|
||||||
|
<font-awesome-icon icon="trash" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<select v-if="!isFirst" v-model="model.andOr" class="form-select and-or-select" data-testid="condition-and-or">
|
||||||
|
<option value="and">{{ $t("and") }}</option>
|
||||||
|
<option value="or">{{ $t("or") }}</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select v-model="model.variable" class="form-select" data-testid="condition-variable">
|
||||||
|
<option
|
||||||
|
v-for="variable in conditionVariables"
|
||||||
|
:key="variable.id"
|
||||||
|
:value="variable.id"
|
||||||
|
>
|
||||||
|
{{ $t(variable.id) }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select v-model="model.operator" class="form-select" data-testid="condition-operator">
|
||||||
|
<option
|
||||||
|
v-for="operator in getVariableOperators(model.variable)"
|
||||||
|
:key="operator.id"
|
||||||
|
:value="operator.id"
|
||||||
|
>
|
||||||
|
{{ $t(operator.caption) }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<input
|
||||||
|
v-model="model.value"
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
:aria-label="$t('conditionValuePlaceholder')"
|
||||||
|
data-testid="condition-value"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: "EditMonitorCondition",
|
||||||
|
|
||||||
|
props: {
|
||||||
|
/**
|
||||||
|
* The monitor condition
|
||||||
|
*/
|
||||||
|
modelValue: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this is the first condition
|
||||||
|
*/
|
||||||
|
isFirst: {
|
||||||
|
type: Boolean,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this is the last condition
|
||||||
|
*/
|
||||||
|
isLast: {
|
||||||
|
type: Boolean,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this condition is in a group
|
||||||
|
*/
|
||||||
|
isInGroup: {
|
||||||
|
type: Boolean,
|
||||||
|
required: false,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Variable choices
|
||||||
|
*/
|
||||||
|
conditionVariables: {
|
||||||
|
type: Array,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
emits: [ "update:modelValue", "remove" ],
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
model: {
|
||||||
|
get() {
|
||||||
|
return this.modelValue;
|
||||||
|
},
|
||||||
|
set(value) {
|
||||||
|
this.$emit("update:modelValue", value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
remove() {
|
||||||
|
this.$emit("remove", this.model);
|
||||||
|
},
|
||||||
|
|
||||||
|
getVariableOperators(variableId) {
|
||||||
|
return this.conditionVariables.find(v => v.id === variableId)?.operators ?? [];
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import "../assets/vars.scss";
|
||||||
|
|
||||||
|
.monitor-condition {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-button {
|
||||||
|
justify-self: flex-end;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@container (min-width: 500px) {
|
||||||
|
.monitor-condition {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-button {
|
||||||
|
margin-bottom: 0;
|
||||||
|
margin-left: 10px;
|
||||||
|
order: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.and-or-select {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
189
src/components/EditMonitorConditionGroup.vue
Normal file
189
src/components/EditMonitorConditionGroup.vue
Normal file
|
@ -0,0 +1,189 @@
|
||||||
|
<template>
|
||||||
|
<div class="condition-group mb-3" data-testid="condition-group">
|
||||||
|
<div class="d-flex">
|
||||||
|
<select v-if="!isFirst" v-model="model.andOr" class="form-select" style="width: auto;" data-testid="condition-group-and-or">
|
||||||
|
<option value="and">{{ $t("and") }}</option>
|
||||||
|
<option value="or">{{ $t("or") }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="condition-group-inner mt-2 pa-2">
|
||||||
|
<div class="condition-group-conditions">
|
||||||
|
<template v-for="(child, childIndex) in model.children" :key="childIndex">
|
||||||
|
<EditMonitorConditionGroup
|
||||||
|
v-if="child.type === 'group'"
|
||||||
|
v-model="model.children[childIndex]"
|
||||||
|
:is-first="childIndex === 0"
|
||||||
|
:get-new-group="getNewGroup"
|
||||||
|
:get-new-condition="getNewCondition"
|
||||||
|
:condition-variables="conditionVariables"
|
||||||
|
@remove="removeChild"
|
||||||
|
/>
|
||||||
|
<EditMonitorCondition
|
||||||
|
v-else
|
||||||
|
v-model="model.children[childIndex]"
|
||||||
|
:is-first="childIndex === 0"
|
||||||
|
:is-last="childIndex === model.children.length - 1"
|
||||||
|
:is-in-group="true"
|
||||||
|
:condition-variables="conditionVariables"
|
||||||
|
@remove="removeChild"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="condition-group-actions mt-3">
|
||||||
|
<button class="btn btn-outline-secondary me-2" type="button" data-testid="add-condition-button" @click="addCondition">
|
||||||
|
{{ $t("conditionAdd") }}
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-outline-secondary me-2" type="button" data-testid="add-group-button" @click="addGroup">
|
||||||
|
{{ $t("conditionAddGroup") }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-outline-danger"
|
||||||
|
type="button"
|
||||||
|
:aria-label="$t('conditionDeleteGroup')"
|
||||||
|
data-testid="remove-condition-group"
|
||||||
|
@click="remove"
|
||||||
|
>
|
||||||
|
<font-awesome-icon icon="trash" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import EditMonitorCondition from "./EditMonitorCondition.vue";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: "EditMonitorConditionGroup",
|
||||||
|
|
||||||
|
components: {
|
||||||
|
EditMonitorCondition,
|
||||||
|
},
|
||||||
|
|
||||||
|
props: {
|
||||||
|
/**
|
||||||
|
* The condition group
|
||||||
|
*/
|
||||||
|
modelValue: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this is the first condition
|
||||||
|
*/
|
||||||
|
isFirst: {
|
||||||
|
type: Boolean,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Function to generate a new group model
|
||||||
|
*/
|
||||||
|
getNewGroup: {
|
||||||
|
type: Function,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Function to generate a new condition model
|
||||||
|
*/
|
||||||
|
getNewCondition: {
|
||||||
|
type: Function,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Variable choices
|
||||||
|
*/
|
||||||
|
conditionVariables: {
|
||||||
|
type: Array,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
emits: [ "update:modelValue", "remove" ],
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
model: {
|
||||||
|
get() {
|
||||||
|
return this.modelValue;
|
||||||
|
},
|
||||||
|
set(value) {
|
||||||
|
this.$emit("update:modelValue", value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
addGroup() {
|
||||||
|
const conditions = [ ...this.model.children ];
|
||||||
|
conditions.push(this.getNewGroup());
|
||||||
|
this.model.children = conditions;
|
||||||
|
},
|
||||||
|
|
||||||
|
addCondition() {
|
||||||
|
const conditions = [ ...this.model.children ];
|
||||||
|
conditions.push(this.getNewCondition());
|
||||||
|
this.model.children = conditions;
|
||||||
|
},
|
||||||
|
|
||||||
|
remove() {
|
||||||
|
this.$emit("remove", this.model);
|
||||||
|
},
|
||||||
|
|
||||||
|
removeChild(child) {
|
||||||
|
const idx = this.model.children.indexOf(child);
|
||||||
|
if (idx !== -1) {
|
||||||
|
this.model.children.splice(idx, 1);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import "../assets/vars.scss";
|
||||||
|
|
||||||
|
.condition-group-inner {
|
||||||
|
background: rgba(0, 0, 0, 0.05);
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .condition-group-inner {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.condition-group-conditions {
|
||||||
|
container-type: inline-size;
|
||||||
|
}
|
||||||
|
|
||||||
|
.condition-group-actions {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete button
|
||||||
|
.condition-group-actions > :last-child {
|
||||||
|
margin-left: auto;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@container (min-width: 400px) {
|
||||||
|
.condition-group-actions {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete button
|
||||||
|
.condition-group-actions > :last-child {
|
||||||
|
margin-left: auto;
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-delete-group {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
149
src/components/EditMonitorConditions.vue
Normal file
149
src/components/EditMonitorConditions.vue
Normal file
|
@ -0,0 +1,149 @@
|
||||||
|
<template>
|
||||||
|
<div class="monitor-conditions">
|
||||||
|
<label class="form-label">{{ $t("Conditions") }}</label>
|
||||||
|
<div class="monitor-conditions-conditions">
|
||||||
|
<template v-for="(condition, conditionIndex) in model" :key="conditionIndex">
|
||||||
|
<EditMonitorConditionGroup
|
||||||
|
v-if="condition.type === 'group'"
|
||||||
|
v-model="model[conditionIndex]"
|
||||||
|
:is-first="conditionIndex === 0"
|
||||||
|
:get-new-group="getNewGroup"
|
||||||
|
:get-new-condition="getNewCondition"
|
||||||
|
:condition-variables="conditionVariables"
|
||||||
|
@remove="removeCondition"
|
||||||
|
/>
|
||||||
|
<EditMonitorCondition
|
||||||
|
v-else
|
||||||
|
v-model="model[conditionIndex]"
|
||||||
|
:is-first="conditionIndex === 0"
|
||||||
|
:is-last="conditionIndex === model.length - 1"
|
||||||
|
:condition-variables="conditionVariables"
|
||||||
|
@remove="removeCondition"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<div class="monitor-conditions-buttons">
|
||||||
|
<button class="btn btn-outline-secondary me-2" type="button" data-testid="add-condition-button" @click="addCondition">
|
||||||
|
{{ $t("conditionAdd") }}
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-outline-secondary me-2" type="button" data-testid="add-group-button" @click="addGroup">
|
||||||
|
{{ $t("conditionAddGroup") }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import EditMonitorConditionGroup from "./EditMonitorConditionGroup.vue";
|
||||||
|
import EditMonitorCondition from "./EditMonitorCondition.vue";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: "EditMonitorConditions",
|
||||||
|
|
||||||
|
components: {
|
||||||
|
EditMonitorConditionGroup,
|
||||||
|
EditMonitorCondition,
|
||||||
|
},
|
||||||
|
|
||||||
|
props: {
|
||||||
|
/**
|
||||||
|
* The monitor conditions
|
||||||
|
*/
|
||||||
|
modelValue: {
|
||||||
|
type: Array,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
conditionVariables: {
|
||||||
|
type: Array,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
emits: [ "update:modelValue" ],
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
model: {
|
||||||
|
get() {
|
||||||
|
return this.modelValue;
|
||||||
|
},
|
||||||
|
set(value) {
|
||||||
|
this.$emit("update:modelValue", value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
created() {
|
||||||
|
if (this.model.length === 0) {
|
||||||
|
this.addCondition();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
getNewGroup() {
|
||||||
|
return {
|
||||||
|
type: "group",
|
||||||
|
children: [ this.getNewCondition() ],
|
||||||
|
andOr: "and",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
getNewCondition() {
|
||||||
|
const firstVariable = this.conditionVariables[0]?.id || null;
|
||||||
|
const firstOperator = this.getVariableOperators(firstVariable)[0] || null;
|
||||||
|
return {
|
||||||
|
type: "expression",
|
||||||
|
variable: firstVariable,
|
||||||
|
operator: firstOperator?.id || null,
|
||||||
|
value: "",
|
||||||
|
andOr: "and",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
addGroup() {
|
||||||
|
const conditions = [ ...this.model ];
|
||||||
|
conditions.push(this.getNewGroup());
|
||||||
|
this.$emit("update:modelValue", conditions);
|
||||||
|
},
|
||||||
|
|
||||||
|
addCondition() {
|
||||||
|
const conditions = [ ...this.model ];
|
||||||
|
conditions.push(this.getNewCondition());
|
||||||
|
this.$emit("update:modelValue", conditions);
|
||||||
|
},
|
||||||
|
|
||||||
|
removeCondition(condition) {
|
||||||
|
const conditions = [ ...this.model ];
|
||||||
|
const idx = conditions.indexOf(condition);
|
||||||
|
if (idx !== -1) {
|
||||||
|
conditions.splice(idx, 1);
|
||||||
|
this.$emit("update:modelValue", conditions);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
getVariableOperators(variableId) {
|
||||||
|
return this.conditionVariables.find(v => v.id === variableId)?.operators ?? [];
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import "../assets/vars.scss";
|
||||||
|
|
||||||
|
.monitor-conditions,
|
||||||
|
.monitor-conditions-conditions {
|
||||||
|
container-type: inline-size;
|
||||||
|
}
|
||||||
|
|
||||||
|
.monitor-conditions-buttons {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@container (min-width: 400px) {
|
||||||
|
.monitor-conditions-buttons {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -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.",
|
"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.",
|
"backupRecommend": "Please backup the volume or the data folder (./data/) directly instead.",
|
||||||
"Optional": "Optional",
|
"Optional": "Optional",
|
||||||
|
"and": "and",
|
||||||
"or": "or",
|
"or": "or",
|
||||||
"sameAsServerTimezone": "Same as Server Timezone",
|
"sameAsServerTimezone": "Same as Server Timezone",
|
||||||
"startDateTime": "Start Date/Time",
|
"startDateTime": "Start Date/Time",
|
||||||
|
@ -994,5 +995,24 @@
|
||||||
"Cannot connect to the socket server.": "Cannot connect to the socket server.",
|
"Cannot connect to the socket server.": "Cannot connect to the socket server.",
|
||||||
"SIGNL4": "SIGNL4",
|
"SIGNL4": "SIGNL4",
|
||||||
"SIGNL4 Webhook URL": "SIGNL4 Webhook URL",
|
"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"
|
||||||
}
|
}
|
||||||
|
|
|
@ -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.
|
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,
|
loggedIn: false,
|
||||||
monitorList: { },
|
monitorList: { },
|
||||||
|
monitorTypeList: {},
|
||||||
maintenanceList: {},
|
maintenanceList: {},
|
||||||
apiKeyList: {},
|
apiKeyList: {},
|
||||||
heartbeatList: { },
|
heartbeatList: { },
|
||||||
|
@ -153,6 +154,10 @@ export default {
|
||||||
this.monitorList = data;
|
this.monitorList = data;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
socket.on("monitorTypeList", (data) => {
|
||||||
|
this.monitorTypeList = data;
|
||||||
|
});
|
||||||
|
|
||||||
socket.on("maintenanceList", (data) => {
|
socket.on("maintenanceList", (data) => {
|
||||||
this.maintenanceList = data;
|
this.maintenanceList = data;
|
||||||
});
|
});
|
||||||
|
|
|
@ -79,7 +79,7 @@
|
||||||
<span class="word">{{ $t("checkEverySecond", [ monitor.interval ]) }}</span>
|
<span class="word">{{ $t("checkEverySecond", [ monitor.interval ]) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4 text-center">
|
<div class="col-md-4 text-center">
|
||||||
<span class="badge rounded-pill" :class=" 'bg-' + status.color " style="font-size: 30px;">{{ status.text }}</span>
|
<span class="badge rounded-pill" :class=" 'bg-' + status.color " style="font-size: 30px;" data-testid="monitor-status">{{ status.text }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -10,7 +10,7 @@
|
||||||
|
|
||||||
<div class="my-3">
|
<div class="my-3">
|
||||||
<label for="type" class="form-label">{{ $t("Monitor Type") }}</label>
|
<label for="type" class="form-label">{{ $t("Monitor Type") }}</label>
|
||||||
<select id="type" v-model="monitor.type" class="form-select">
|
<select id="type" v-model="monitor.type" class="form-select" data-testid="monitor-type-select">
|
||||||
<optgroup :label="$t('General Monitor Type')">
|
<optgroup :label="$t('General Monitor Type')">
|
||||||
<option value="group">
|
<option value="group">
|
||||||
{{ $t("Group") }}
|
{{ $t("Group") }}
|
||||||
|
@ -99,7 +99,7 @@
|
||||||
<!-- Friendly Name -->
|
<!-- Friendly Name -->
|
||||||
<div class="my-3">
|
<div class="my-3">
|
||||||
<label for="name" class="form-label">{{ $t("Friendly Name") }}</label>
|
<label for="name" class="form-label">{{ $t("Friendly Name") }}</label>
|
||||||
<input id="name" v-model="monitor.name" type="text" class="form-control" required>
|
<input id="name" v-model="monitor.name" type="text" class="form-control" required data-testid="friendly-name-input">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- URL -->
|
<!-- URL -->
|
||||||
|
@ -237,7 +237,15 @@
|
||||||
<!-- TCP Port / Ping / DNS / Steam / MQTT / Radius / Tailscale Ping / SNMP only -->
|
<!-- TCP Port / Ping / DNS / Steam / MQTT / Radius / Tailscale Ping / SNMP only -->
|
||||||
<div v-if="monitor.type === 'port' || monitor.type === 'ping' || monitor.type === 'dns' || monitor.type === 'steam' || monitor.type === 'gamedig' || monitor.type === 'mqtt' || monitor.type === 'radius' || monitor.type === 'tailscale-ping' || monitor.type === 'snmp'" class="my-3">
|
<div v-if="monitor.type === 'port' || monitor.type === 'ping' || monitor.type === 'dns' || monitor.type === 'steam' || monitor.type === 'gamedig' || monitor.type === 'mqtt' || monitor.type === 'radius' || monitor.type === 'tailscale-ping' || monitor.type === 'snmp'" class="my-3">
|
||||||
<label for="hostname" class="form-label">{{ $t("Hostname") }}</label>
|
<label for="hostname" class="form-label">{{ $t("Hostname") }}</label>
|
||||||
<input id="hostname" v-model="monitor.hostname" type="text" class="form-control" :pattern="`${monitor.type === 'mqtt' ? mqttIpOrHostnameRegexPattern : ipOrHostnameRegexPattern}`" required>
|
<input
|
||||||
|
id="hostname"
|
||||||
|
v-model="monitor.hostname"
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
:pattern="`${monitor.type === 'mqtt' ? mqttIpOrHostnameRegexPattern : ipOrHostnameRegexPattern}`"
|
||||||
|
required
|
||||||
|
data-testid="hostname-input"
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Port -->
|
<!-- Port -->
|
||||||
|
@ -343,6 +351,7 @@
|
||||||
:preselect-first="false"
|
:preselect-first="false"
|
||||||
:max-height="500"
|
:max-height="500"
|
||||||
:taggable="false"
|
:taggable="false"
|
||||||
|
data-testid="resolve-type-select"
|
||||||
></VueMultiselect>
|
></VueMultiselect>
|
||||||
|
|
||||||
<div class="form-text">
|
<div class="form-text">
|
||||||
|
@ -509,6 +518,14 @@
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<!-- Conditions -->
|
||||||
|
<EditMonitorConditions
|
||||||
|
v-if="supportsConditions && conditionVariables.length > 0"
|
||||||
|
v-model="monitor.conditions"
|
||||||
|
:condition-variables="conditionVariables"
|
||||||
|
class="my-3"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- Interval -->
|
<!-- Interval -->
|
||||||
<div class="my-3">
|
<div class="my-3">
|
||||||
<label for="interval" class="form-label">{{ $t("Heartbeat Interval") }} ({{ $t("checkEverySecond", [ monitor.interval ]) }})</label>
|
<label for="interval" class="form-label">{{ $t("Heartbeat Interval") }} ({{ $t("checkEverySecond", [ monitor.interval ]) }})</label>
|
||||||
|
@ -963,7 +980,15 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="fixed-bottom-bar p-3">
|
<div class="fixed-bottom-bar p-3">
|
||||||
<button id="monitor-submit-btn" class="btn btn-primary" type="submit" :disabled="processing">{{ $t("Save") }}</button>
|
<button
|
||||||
|
id="monitor-submit-btn"
|
||||||
|
class="btn btn-primary"
|
||||||
|
type="submit"
|
||||||
|
:disabled="processing"
|
||||||
|
data-testid="save-button"
|
||||||
|
>
|
||||||
|
{{ $t("Save") }}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
@ -972,7 +997,7 @@
|
||||||
<DockerHostDialog ref="dockerHostDialog" @added="addedDockerHost" />
|
<DockerHostDialog ref="dockerHostDialog" @added="addedDockerHost" />
|
||||||
<ProxyDialog ref="proxyDialog" @added="addedProxy" />
|
<ProxyDialog ref="proxyDialog" @added="addedProxy" />
|
||||||
<CreateGroupDialog ref="createGroupDialog" @added="addedDraftGroup" />
|
<CreateGroupDialog ref="createGroupDialog" @added="addedDraftGroup" />
|
||||||
<RemoteBrowserDialog ref="remoteBrowserDialog" @added="addedRemoteBrowser" />
|
<RemoteBrowserDialog ref="remoteBrowserDialog" />
|
||||||
</div>
|
</div>
|
||||||
</transition>
|
</transition>
|
||||||
</template>
|
</template>
|
||||||
|
@ -991,6 +1016,7 @@ import TagsManager from "../components/TagsManager.vue";
|
||||||
import { genSecret, isDev, MAX_INTERVAL_SECOND, MIN_INTERVAL_SECOND, sleep } from "../util.ts";
|
import { genSecret, isDev, MAX_INTERVAL_SECOND, MIN_INTERVAL_SECOND, sleep } from "../util.ts";
|
||||||
import { hostNameRegexPattern } from "../util-frontend";
|
import { hostNameRegexPattern } from "../util-frontend";
|
||||||
import HiddenInput from "../components/HiddenInput.vue";
|
import HiddenInput from "../components/HiddenInput.vue";
|
||||||
|
import EditMonitorConditions from "../components/EditMonitorConditions.vue";
|
||||||
|
|
||||||
const toast = useToast;
|
const toast = useToast;
|
||||||
|
|
||||||
|
@ -1034,7 +1060,8 @@ const monitorDefaults = {
|
||||||
kafkaProducerSsl: false,
|
kafkaProducerSsl: false,
|
||||||
kafkaProducerAllowAutoTopicCreation: false,
|
kafkaProducerAllowAutoTopicCreation: false,
|
||||||
gamedigGivenPortOnly: true,
|
gamedigGivenPortOnly: true,
|
||||||
remote_browser: null
|
remote_browser: null,
|
||||||
|
conditions: []
|
||||||
};
|
};
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
@ -1049,6 +1076,7 @@ export default {
|
||||||
RemoteBrowserDialog,
|
RemoteBrowserDialog,
|
||||||
TagsManager,
|
TagsManager,
|
||||||
VueMultiselect,
|
VueMultiselect,
|
||||||
|
EditMonitorConditions,
|
||||||
},
|
},
|
||||||
|
|
||||||
data() {
|
data() {
|
||||||
|
@ -1303,7 +1331,15 @@ message HealthCheckResponse {
|
||||||
value: null,
|
value: null,
|
||||||
}];
|
}];
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
|
||||||
|
supportsConditions() {
|
||||||
|
return this.$root.monitorTypeList[this.monitor.type]?.supportsConditions || false;
|
||||||
|
},
|
||||||
|
|
||||||
|
conditionVariables() {
|
||||||
|
return this.$root.monitorTypeList[this.monitor.type]?.conditionVariables || [];
|
||||||
|
},
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
"$root.proxyList"() {
|
"$root.proxyList"() {
|
||||||
|
@ -1336,7 +1372,7 @@ message HealthCheckResponse {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
"monitor.type"() {
|
"monitor.type"(newType, oldType) {
|
||||||
if (this.monitor.type === "push") {
|
if (this.monitor.type === "push") {
|
||||||
if (! this.monitor.pushToken) {
|
if (! this.monitor.pushToken) {
|
||||||
// ideally this would require checking if the generated token is already used
|
// 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) {
|
currentGameObject(newGameObject, previousGameObject) {
|
||||||
|
|
46
test/backend-test/monitor-conditions/test-evaluator.js
Normal file
46
test/backend-test/monitor-conditions/test-evaluator.js
Normal file
|
@ -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" }));
|
||||||
|
});
|
55
test/backend-test/monitor-conditions/test-expressions.js
Normal file
55
test/backend-test/monitor-conditions/test-expressions.js
Normal file
|
@ -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");
|
||||||
|
});
|
108
test/backend-test/monitor-conditions/test-operators.js
Normal file
108
test/backend-test/monitor-conditions/test-operators.js
Normal file
|
@ -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));
|
||||||
|
});
|
109
test/e2e/specs/monitor-form.spec.js
Normal file
109
test/e2e/specs/monitor-form.spec.js
Normal file
|
@ -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);
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
Loading…
Reference in a new issue