Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Monitor Conditions #5048

Merged
merged 28 commits into from
Aug 30, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
94ba013
Foundation for monitor conditions
simshaun Aug 25, 2024
80702bc
Preliminary support for conditions on DNS type
simshaun Aug 25, 2024
68aca7e
Eliminate annoying console warning about missing addedRemoteBrowser p…
simshaun Aug 25, 2024
a15a2b5
Clean up DnsMonitorType.check
simshaun Aug 25, 2024
6b342cc
Eliminate unused imports warnings for JSDoc-only imports
simshaun Aug 26, 2024
5101bb4
Use translator for and/or
simshaun Aug 26, 2024
11d710d
Use translator for operators
simshaun Aug 26, 2024
8720706
Use translator for variable ID
simshaun Aug 26, 2024
78ac510
Simplify test import since v2 is Node 18+
simshaun Aug 26, 2024
b2c3c14
Move translator directly to button caption
simshaun Aug 26, 2024
0b00688
Improve localization
simshaun Aug 26, 2024
52ac18c
Reduce container width required to inline fields
simshaun Aug 26, 2024
e7baebb
Make condition value required
simshaun Aug 26, 2024
1e0e46c
Fix style linters
simshaun Aug 26, 2024
d7d5e10
Fix and improve E2E test
simshaun Aug 29, 2024
b4ba229
Fix conditions not stored as JSON on new monitor
simshaun Aug 29, 2024
325bc8a
Remove unused import
simshaun Aug 29, 2024
53e691b
Remove unused 'Delete Group' translation key in lieu of 'conditionDel…
simshaun Aug 29, 2024
920c9c2
Clean up the tests
simshaun Aug 29, 2024
b7fd5a3
Remove monitor.getConditions()
simshaun Aug 30, 2024
2162380
Make conditions column not nullable
simshaun Aug 30, 2024
be1b90d
Add comment explaining reason for loose equality comparison
simshaun Aug 30, 2024
ed8433a
Split loose-comparison equality operator into strict-comparison Strin…
simshaun Aug 30, 2024
8a01db7
Add "starts with" and "ends with" operators
simshaun Aug 30, 2024
4bf42ce
More realistic cases
simshaun Aug 30, 2024
ce9cac9
Organize & improve buttons
simshaun Aug 30, 2024
139ac4d
Add a condition by default on new monitor
simshaun Aug 30, 2024
6671a97
Default conditionsResult to true in order to remove unnecessary null …
simshaun Aug 30, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions db/knex_migrations/2024-08-24-0000-conditions.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
exports.up = function (knex) {
return knex.schema
.alterTable("monitor", function (table) {
table.text("conditions").notNullable().defaultTo("[]");
louislam marked this conversation as resolved.
Show resolved Hide resolved
});
};

exports.down = function (knex) {
return knex.schema.alterTable("monitor", function (table) {
table.dropColumn("conditions");
});
};
27 changes: 27 additions & 0 deletions server/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,32 @@ async function sendRemoteBrowserList(socket) {
return list;
}

/**
* Send list of monitor types to client
* @param {Socket} socket Socket.io socket instance
* @returns {Promise<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 = {
sendNotificationList,
sendImportantHeartbeatList,
Expand All @@ -222,4 +248,5 @@ module.exports = {
sendInfo,
sendDockerHostList,
sendRemoteBrowserList,
sendMonitorTypeList,
};
1 change: 1 addition & 0 deletions server/model/monitor.js
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,7 @@ class Monitor extends BeanModel {
snmpOid: this.snmpOid,
jsonPathOperator: this.jsonPathOperator,
snmpVersion: this.snmpVersion,
conditions: JSON.parse(this.conditions),
};

if (includeSensitiveData) {
Expand Down
71 changes: 71 additions & 0 deletions server/monitor-conditions/evaluator.js
Original file line number Diff line number Diff line change
@@ -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 changes: 111 additions & 0 deletions server/monitor-conditions/expression.js
Original file line number Diff line number Diff line change
@@ -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,
};
Loading
Loading