Monitor Conditions (#5048)

This commit is contained in:
Shaun 2024-08-30 15:48:13 -04:00 committed by GitHub
parent 032ac161f7
commit 36f8be040d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 1526 additions and 35 deletions

View 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");
});
};

View File

@ -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,
}; };

View File

@ -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) {

View 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,
};

View 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,
};

View 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,
};

View 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,
};

View File

@ -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;
} }
} }

View File

@ -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

View File

@ -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);

View 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>

View 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>

View 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>

View File

@ -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"
} }

View File

@ -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;
}); });

View File

@ -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>

View File

@ -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) {

View 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" }));
});

View 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");
});

View 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));
});

View 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);
});
});