From d82535241056864200e404e6720c8a77faec8d55 Mon Sep 17 00:00:00 2001 From: leonace924 Date: Tue, 6 Jan 2026 00:43:17 -0500 Subject: [PATCH 01/12] feat: extract MySQL/MariaDB monitor to its own monitor-type and enable conditions support --- package.json | 1 + server/model/monitor.js | 12 +- server/monitor-types/mysql.js | 118 +++++++++++++++++ server/uptime-kuma-server.js | 2 + test/backend-test/monitors/test-mysql.js | 156 +++++++++++++++++++++++ 5 files changed, 278 insertions(+), 11 deletions(-) create mode 100644 server/monitor-types/mysql.js create mode 100644 test/backend-test/monitors/test-mysql.js diff --git a/package.json b/package.json index 1fff55628..e463b19c7 100644 --- a/package.json +++ b/package.json @@ -162,6 +162,7 @@ "@playwright/test": "~1.39.0", "@popperjs/core": "~2.10.2", "@testcontainers/hivemq": "^10.13.1", + "@testcontainers/mariadb": "^10.13.0", "@testcontainers/mssqlserver": "^10.28.0", "@testcontainers/postgresql": "^11.9.0", "@testcontainers/rabbitmq": "^10.13.2", diff --git a/server/model/monitor.js b/server/model/monitor.js index ed4bb219c..dac165072 100644 --- a/server/model/monitor.js +++ b/server/model/monitor.js @@ -8,7 +8,7 @@ const { log, UP, DOWN, PENDING, MAINTENANCE, flipStatus, MAX_INTERVAL_SECOND, MI PING_COUNT_MIN, PING_COUNT_MAX, PING_COUNT_DEFAULT, PING_PER_REQUEST_TIMEOUT_MIN, PING_PER_REQUEST_TIMEOUT_MAX, PING_PER_REQUEST_TIMEOUT_DEFAULT } = require("../../src/util"); -const { ping, checkCertificate, checkStatusCode, getTotalClientInRoom, setting, mysqlQuery, setSetting, httpNtlm, radius, +const { ping, checkCertificate, checkStatusCode, getTotalClientInRoom, setting, setSetting, httpNtlm, radius, kafkaProducerAsync, getOidcTokenClientCredentials, rootCertificatesFingerprints, axiosAbortSignal, checkCertificateHostname } = require("../util-server"); const { R } = require("redbean-node"); @@ -781,16 +781,6 @@ class Monitor extends BeanModel { bean.status = UP; bean.msg = `Container has not reported health and is currently ${res.data.State.Status}. As it is running, it is considered UP. Consider adding a health check for better service visibility`; } - } else if (this.type === "mysql") { - let startTime = dayjs().valueOf(); - - // Use `radius_password` as `password` field, since there are too many unnecessary fields - // TODO: rename `radius_password` to `password` later for general use - let mysqlPassword = this.radiusPassword; - - bean.msg = await mysqlQuery(this.databaseConnectionString, this.databaseQuery || "SELECT 1", mysqlPassword); - bean.status = UP; - bean.ping = dayjs().valueOf() - startTime; } else if (this.type === "radius") { let startTime = dayjs().valueOf(); diff --git a/server/monitor-types/mysql.js b/server/monitor-types/mysql.js new file mode 100644 index 000000000..5b2c2c050 --- /dev/null +++ b/server/monitor-types/mysql.js @@ -0,0 +1,118 @@ +const { MonitorType } = require("./monitor-type"); +const { log, UP } = require("../../src/util"); +const dayjs = require("dayjs"); +const mysql = require("mysql2"); +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 MysqlMonitorType extends MonitorType { + name = "mysql"; + + supportsConditions = true; + conditionVariables = [ + new ConditionVariable("result", defaultStringOperators), + ]; + + /** + * @inheritdoc + */ + async check(monitor, heartbeat, _server) { + let startTime = dayjs().valueOf(); + + let query = monitor.databaseQuery; + if (!query || (typeof query === "string" && query.trim() === "")) { + query = "SELECT 1"; + } + + // Use `radius_password` as `password` field for backwards compatibility + // TODO: rename `radius_password` to `password` later for general use + const password = monitor.radiusPassword; + + let result; + try { + result = await this.mysqlQuery(monitor.databaseConnectionString, query, password); + } catch (error) { + log.error("mysql", "Database query failed:", error.message); + throw new Error(`Database connection/query failed: ${error.message}`); + } finally { + heartbeat.ping = dayjs().valueOf() - startTime; + } + + const conditions = ConditionExpressionGroup.fromMonitor(monitor); + const handleConditions = (data) => + conditions ? evaluateExpressionGroup(conditions, data) : true; + + // Since result is now a single value, pass it directly to conditions + const conditionsResult = handleConditions({ result: String(result) }); + + if (!conditionsResult) { + throw new Error(`Query result did not meet the specified conditions (${result})`); + } + + heartbeat.msg = ""; + heartbeat.status = UP; + } + + /** + * Run a query on MySQL/MariaDB + * @param {string} connectionString The database connection string + * @param {string} query The query to execute + * @param {string} password Optional password override + * @returns {Promise} Single value from the first column of the first row + */ + mysqlQuery(connectionString, query, password = undefined) { + return new Promise((resolve, reject) => { + const connection = mysql.createConnection({ + uri: connectionString, + password + }); + + connection.on("error", (err) => { + reject(err); + }); + + connection.query(query, (err, res) => { + try { + connection.end(); + } catch (_) { + connection.destroy(); + } + + if (err) { + reject(err); + return; + } + + // Check if we have results + if (!Array.isArray(res) || res.length === 0) { + reject(new Error("Query returned no results")); + return; + } + + // Check if we have multiple rows + if (res.length > 1) { + reject(new Error("Multiple values were found, expected only one value")); + return; + } + + const firstRow = res[0]; + const columnNames = Object.keys(firstRow); + + // Check if we have multiple columns + if (columnNames.length > 1) { + reject(new Error("Multiple columns were found, expected only one value")); + return; + } + + // Return the single value from the first (and only) column + resolve(firstRow[columnNames[0]]); + }); + }); + } +} + +module.exports = { + MysqlMonitorType, +}; diff --git a/server/uptime-kuma-server.js b/server/uptime-kuma-server.js index f813f8e80..5739d268e 100644 --- a/server/uptime-kuma-server.js +++ b/server/uptime-kuma-server.js @@ -128,6 +128,7 @@ class UptimeKumaServer { UptimeKumaServer.monitorTypeList["redis"] = new RedisMonitorType(); UptimeKumaServer.monitorTypeList["system-service"] = new SystemServiceMonitorType(); UptimeKumaServer.monitorTypeList["sqlserver"] = new MssqlMonitorType(); + UptimeKumaServer.monitorTypeList["mysql"] = new MysqlMonitorType(); // Allow all CORS origins (polling) in development let cors = undefined; @@ -580,5 +581,6 @@ const { ManualMonitorType } = require("./monitor-types/manual"); const { RedisMonitorType } = require("./monitor-types/redis"); const { SystemServiceMonitorType } = require("./monitor-types/system-service"); const { MssqlMonitorType } = require("./monitor-types/mssql"); +const { MysqlMonitorType } = require("./monitor-types/mysql"); const Monitor = require("./model/monitor"); diff --git a/test/backend-test/monitors/test-mysql.js b/test/backend-test/monitors/test-mysql.js new file mode 100644 index 000000000..898b79e95 --- /dev/null +++ b/test/backend-test/monitors/test-mysql.js @@ -0,0 +1,156 @@ +const { describe, test } = require("node:test"); +const assert = require("node:assert"); +const { MariaDbContainer } = require("@testcontainers/mariadb"); +const { MysqlMonitorType } = require("../../../server/monitor-types/mysql"); +const { UP, PENDING } = require("../../../src/util"); + +/** + * Helper function to create and start a MariaDB container + * @returns {Promise} The started MariaDB container + */ +async function createAndStartMariaDBContainer() { + return await new MariaDbContainer("mariadb:10.11") + .withStartupTimeout(90000) + .start(); +} + +describe( + "MySQL/MariaDB Monitor", + { + skip: + !!process.env.CI && + (process.platform !== "linux" || process.arch !== "x64"), + }, + () => { + test("check() sets status to UP when MariaDB server is reachable", async () => { + const mariadbContainer = await createAndStartMariaDBContainer(); + + const mysqlMonitor = new MysqlMonitorType(); + const monitor = { + databaseConnectionString: `mysql://${mariadbContainer.getUsername()}:${mariadbContainer.getUserPassword()}@${mariadbContainer.getHost()}:${mariadbContainer.getPort()}/${mariadbContainer.getDatabase()}`, + conditions: "[]", + }; + + const heartbeat = { + msg: "", + status: PENDING, + }; + + try { + await mysqlMonitor.check(monitor, heartbeat, {}); + assert.strictEqual( + heartbeat.status, + UP, + `Expected status ${UP} but got ${heartbeat.status}` + ); + } finally { + await mariadbContainer.stop(); + } + }); + + test("check() rejects when MariaDB server is not reachable", async () => { + const mysqlMonitor = new MysqlMonitorType(); + const monitor = { + databaseConnectionString: + "mysql://invalid:invalid@localhost:13306/test", + conditions: "[]", + }; + + const heartbeat = { + msg: "", + status: PENDING, + }; + + await assert.rejects( + mysqlMonitor.check(monitor, heartbeat, {}), + (err) => { + assert.ok( + err.message.includes("Database connection/query failed"), + `Expected error message to include "Database connection/query failed" but got: ${err.message}` + ); + return true; + } + ); + assert.notStrictEqual( + heartbeat.status, + UP, + `Expected status should not be ${UP}` + ); + }); + + test("check() sets status to UP when custom query result meets condition", async () => { + const mariadbContainer = await createAndStartMariaDBContainer(); + + const mysqlMonitor = new MysqlMonitorType(); + const monitor = { + databaseConnectionString: `mysql://${mariadbContainer.getUsername()}:${mariadbContainer.getUserPassword()}@${mariadbContainer.getHost()}:${mariadbContainer.getPort()}/${mariadbContainer.getDatabase()}`, + databaseQuery: "SELECT 42 AS value", + conditions: JSON.stringify([ + { + type: "expression", + andOr: "and", + variable: "result", + operator: "equals", + value: "42", + }, + ]), + }; + + const heartbeat = { + msg: "", + status: PENDING, + }; + + try { + await mysqlMonitor.check(monitor, heartbeat, {}); + assert.strictEqual( + heartbeat.status, + UP, + `Expected status ${UP} but got ${heartbeat.status}` + ); + } finally { + await mariadbContainer.stop(); + } + }); + + test("check() rejects when custom query result does not meet condition", async () => { + const mariadbContainer = await createAndStartMariaDBContainer(); + + const mysqlMonitor = new MysqlMonitorType(); + const monitor = { + databaseConnectionString: `mysql://${mariadbContainer.getUsername()}:${mariadbContainer.getUserPassword()}@${mariadbContainer.getHost()}:${mariadbContainer.getPort()}/${mariadbContainer.getDatabase()}`, + databaseQuery: "SELECT 99 AS value", + conditions: JSON.stringify([ + { + type: "expression", + andOr: "and", + variable: "result", + operator: "equals", + value: "42", + }, + ]), + }; + + const heartbeat = { + msg: "", + status: PENDING, + }; + + try { + await assert.rejects( + mysqlMonitor.check(monitor, heartbeat, {}), + new Error( + "Query result did not meet the specified conditions (99)" + ) + ); + assert.strictEqual( + heartbeat.status, + PENDING, + `Expected status should not be ${heartbeat.status}` + ); + } finally { + await mariadbContainer.stop(); + } + }); + } +); From 2d94803876aab96e77a544649e126d326adbcba4 Mon Sep 17 00:00:00 2001 From: leonace924 Date: Tue, 6 Jan 2026 00:48:29 -0500 Subject: [PATCH 02/12] fix: remove unused code --- server/util-server.js | 39 --------------------------------------- 1 file changed, 39 deletions(-) diff --git a/server/util-server.js b/server/util-server.js index 552ae6a21..f87965da1 100644 --- a/server/util-server.js +++ b/server/util-server.js @@ -10,7 +10,6 @@ const { Resolver } = require("dns"); const iconv = require("iconv-lite"); const chardet = require("chardet"); const chroma = require("chroma-js"); -const mysql = require("mysql2"); const { NtlmClient } = require("./modules/axios-ntlm/lib/ntlmClient.js"); const { Settings } = require("./settings"); const RadiusClient = require("./radius-client"); @@ -321,44 +320,6 @@ exports.dnsResolve = function (hostname, resolverServer, resolverPort, rrtype) { }); }; -/** - * Run a query on MySQL/MariaDB - * @param {string} connectionString The database connection string - * @param {string} query The query to validate the database with - * @param {?string} password The password to use - * @returns {Promise<(string)>} Response from server - */ -exports.mysqlQuery = function (connectionString, query, password = undefined) { - return new Promise((resolve, reject) => { - const connection = mysql.createConnection({ - uri: connectionString, - password - }); - - connection.on("error", (err) => { - reject(err); - }); - - connection.query(query, (err, res) => { - if (err) { - reject(err); - } else { - if (Array.isArray(res)) { - resolve("Rows: " + res.length); - } else { - resolve("No Error, but the result is not an array. Type: " + typeof res); - } - } - - try { - connection.end(); - } catch (_) { - connection.destroy(); - } - }); - }); -}; - /** * Query radius server * @param {string} hostname Hostname of radius server From af0866ec7da40663a36acfbd6caad5a93754e618 Mon Sep 17 00:00:00 2001 From: leonace924 Date: Tue, 6 Jan 2026 00:50:13 -0500 Subject: [PATCH 03/12] chore: add package-lock.json --- package-lock.json | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/package-lock.json b/package-lock.json index f07c2b7fb..3c7d62865 100644 --- a/package-lock.json +++ b/package-lock.json @@ -101,6 +101,7 @@ "@playwright/test": "~1.39.0", "@popperjs/core": "~2.10.2", "@testcontainers/hivemq": "^10.13.1", + "@testcontainers/mariadb": "^10.13.0", "@testcontainers/mssqlserver": "^10.28.0", "@testcontainers/postgresql": "^11.9.0", "@testcontainers/rabbitmq": "^10.13.2", @@ -5645,6 +5646,15 @@ "testcontainers": "^10.28.0" } }, + "node_modules/@testcontainers/mariadb": { + "version": "10.28.0", + "resolved": "https://registry.npmjs.org/@testcontainers/mariadb/-/mariadb-10.28.0.tgz", + "integrity": "sha512-+ETpRbHOWxEj6uwMfhTVvE6ap0U+olD+v8XbAE2+88YgsHzlmfWWi/EXsOfW1VZsWblYE5kR0k1O//a9Sei4Mg==", + "dev": true, + "dependencies": { + "testcontainers": "^10.28.0" + } + }, "node_modules/@testcontainers/mssqlserver": { "version": "10.28.0", "resolved": "https://registry.npmjs.org/@testcontainers/mssqlserver/-/mssqlserver-10.28.0.tgz", From e6481fa8aa82ecc03ac7dfe61408e4121f44e04f Mon Sep 17 00:00:00 2001 From: leonace924 Date: Tue, 6 Jan 2026 01:13:42 -0500 Subject: [PATCH 04/12] fix: remove duplicate and keep old behavior --- server/monitor-types/mysql.js | 81 ++++++++++++++++++------ test/backend-test/monitors/test-mysql.js | 29 +++++---- 2 files changed, 81 insertions(+), 29 deletions(-) diff --git a/server/monitor-types/mysql.js b/server/monitor-types/mysql.js index 5b2c2c050..549d2db02 100644 --- a/server/monitor-types/mysql.js +++ b/server/monitor-types/mysql.js @@ -30,39 +30,84 @@ class MysqlMonitorType extends MonitorType { // TODO: rename `radius_password` to `password` later for general use const password = monitor.radiusPassword; - let result; + const conditions = ConditionExpressionGroup.fromMonitor(monitor); + const hasConditions = conditions && conditions.length > 0; + try { - result = await this.mysqlQuery(monitor.databaseConnectionString, query, password); + if (hasConditions) { + // When conditions are enabled, expect a single value result + const result = await this.mysqlQuerySingleValue(monitor.databaseConnectionString, query, password); + heartbeat.ping = dayjs().valueOf() - startTime; + + const conditionsResult = evaluateExpressionGroup(conditions, { result: String(result) }); + + if (!conditionsResult) { + throw new Error(`Query result did not meet the specified conditions (${result})`); + } + + heartbeat.msg = ""; + } else { + // Backwards compatible: just check connection and return row count + const result = await this.mysqlQuery(monitor.databaseConnectionString, query, password); + heartbeat.ping = dayjs().valueOf() - startTime; + heartbeat.msg = result; + } } catch (error) { + heartbeat.ping = dayjs().valueOf() - startTime; log.error("mysql", "Database query failed:", error.message); throw new Error(`Database connection/query failed: ${error.message}`); - } finally { - heartbeat.ping = dayjs().valueOf() - startTime; } - const conditions = ConditionExpressionGroup.fromMonitor(monitor); - const handleConditions = (data) => - conditions ? evaluateExpressionGroup(conditions, data) : true; - - // Since result is now a single value, pass it directly to conditions - const conditionsResult = handleConditions({ result: String(result) }); - - if (!conditionsResult) { - throw new Error(`Query result did not meet the specified conditions (${result})`); - } - - heartbeat.msg = ""; heartbeat.status = UP; } /** - * Run a query on MySQL/MariaDB + * Run a query on MySQL/MariaDB (backwards compatible - returns row count) + * @param {string} connectionString The database connection string + * @param {string} query The query to execute + * @param {string} password Optional password override + * @returns {Promise} Row count message + */ + mysqlQuery(connectionString, query, password = undefined) { + return new Promise((resolve, reject) => { + const connection = mysql.createConnection({ + uri: connectionString, + password + }); + + connection.on("error", (err) => { + reject(err); + }); + + connection.query(query, (err, res) => { + try { + connection.end(); + } catch (_) { + connection.destroy(); + } + + if (err) { + reject(err); + return; + } + + if (Array.isArray(res)) { + resolve("Rows: " + res.length); + } else { + resolve("No Error, but the result is not an array. Type: " + typeof res); + } + }); + }); + } + + /** + * Run a query on MySQL/MariaDB expecting a single value result * @param {string} connectionString The database connection string * @param {string} query The query to execute * @param {string} password Optional password override * @returns {Promise} Single value from the first column of the first row */ - mysqlQuery(connectionString, query, password = undefined) { + mysqlQuerySingleValue(connectionString, query, password = undefined) { return new Promise((resolve, reject) => { const connection = mysql.createConnection({ uri: connectionString, diff --git a/test/backend-test/monitors/test-mysql.js b/test/backend-test/monitors/test-mysql.js index 898b79e95..d14a6a1a6 100644 --- a/test/backend-test/monitors/test-mysql.js +++ b/test/backend-test/monitors/test-mysql.js @@ -6,12 +6,19 @@ const { UP, PENDING } = require("../../../src/util"); /** * Helper function to create and start a MariaDB container - * @returns {Promise} The started MariaDB container + * @returns {Promise<{container: MariaDbContainer, connectionString: string}>} The started container and connection string */ async function createAndStartMariaDBContainer() { - return await new MariaDbContainer("mariadb:10.11") + const container = await new MariaDbContainer("mariadb:10.11") .withStartupTimeout(90000) .start(); + + const connectionString = `mysql://${container.getUsername()}:${container.getUserPassword()}@${container.getHost()}:${container.getPort()}/${container.getDatabase()}`; + + return { + container, + connectionString + }; } describe( @@ -23,11 +30,11 @@ describe( }, () => { test("check() sets status to UP when MariaDB server is reachable", async () => { - const mariadbContainer = await createAndStartMariaDBContainer(); + const { container, connectionString } = await createAndStartMariaDBContainer(); const mysqlMonitor = new MysqlMonitorType(); const monitor = { - databaseConnectionString: `mysql://${mariadbContainer.getUsername()}:${mariadbContainer.getUserPassword()}@${mariadbContainer.getHost()}:${mariadbContainer.getPort()}/${mariadbContainer.getDatabase()}`, + databaseConnectionString: connectionString, conditions: "[]", }; @@ -44,7 +51,7 @@ describe( `Expected status ${UP} but got ${heartbeat.status}` ); } finally { - await mariadbContainer.stop(); + await container.stop(); } }); @@ -79,11 +86,11 @@ describe( }); test("check() sets status to UP when custom query result meets condition", async () => { - const mariadbContainer = await createAndStartMariaDBContainer(); + const { container, connectionString } = await createAndStartMariaDBContainer(); const mysqlMonitor = new MysqlMonitorType(); const monitor = { - databaseConnectionString: `mysql://${mariadbContainer.getUsername()}:${mariadbContainer.getUserPassword()}@${mariadbContainer.getHost()}:${mariadbContainer.getPort()}/${mariadbContainer.getDatabase()}`, + databaseConnectionString: connectionString, databaseQuery: "SELECT 42 AS value", conditions: JSON.stringify([ { @@ -109,16 +116,16 @@ describe( `Expected status ${UP} but got ${heartbeat.status}` ); } finally { - await mariadbContainer.stop(); + await container.stop(); } }); test("check() rejects when custom query result does not meet condition", async () => { - const mariadbContainer = await createAndStartMariaDBContainer(); + const { container, connectionString } = await createAndStartMariaDBContainer(); const mysqlMonitor = new MysqlMonitorType(); const monitor = { - databaseConnectionString: `mysql://${mariadbContainer.getUsername()}:${mariadbContainer.getUserPassword()}@${mariadbContainer.getHost()}:${mariadbContainer.getPort()}/${mariadbContainer.getDatabase()}`, + databaseConnectionString: connectionString, databaseQuery: "SELECT 99 AS value", conditions: JSON.stringify([ { @@ -149,7 +156,7 @@ describe( `Expected status should not be ${heartbeat.status}` ); } finally { - await mariadbContainer.stop(); + await container.stop(); } }); } From 0188769636a8d383b0f8968b20590b443bf79fe4 Mon Sep 17 00:00:00 2001 From: Pegasus <42954461+leonace924@users.noreply.github.com> Date: Tue, 6 Jan 2026 01:30:04 -0500 Subject: [PATCH 05/12] Update server/monitor-types/mysql.js Co-authored-by: Frank Elsinga --- server/monitor-types/mysql.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/server/monitor-types/mysql.js b/server/monitor-types/mysql.js index 549d2db02..5910227d3 100644 --- a/server/monitor-types/mysql.js +++ b/server/monitor-types/mysql.js @@ -45,7 +45,8 @@ class MysqlMonitorType extends MonitorType { throw new Error(`Query result did not meet the specified conditions (${result})`); } - heartbeat.msg = ""; + heartbeat.status = UP; + heartbeat.msg = "query did meet specified conditions"; } else { // Backwards compatible: just check connection and return row count const result = await this.mysqlQuery(monitor.databaseConnectionString, query, password); From 857f4e9550d5bea18e4fc024fee10b4a31f14a10 Mon Sep 17 00:00:00 2001 From: Pegasus <42954461+leonace924@users.noreply.github.com> Date: Tue, 6 Jan 2026 01:30:19 -0500 Subject: [PATCH 06/12] Update server/monitor-types/mysql.js Co-authored-by: Frank Elsinga --- server/monitor-types/mysql.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/monitor-types/mysql.js b/server/monitor-types/mysql.js index 5910227d3..c08f30403 100644 --- a/server/monitor-types/mysql.js +++ b/server/monitor-types/mysql.js @@ -42,7 +42,7 @@ class MysqlMonitorType extends MonitorType { const conditionsResult = evaluateExpressionGroup(conditions, { result: String(result) }); if (!conditionsResult) { - throw new Error(`Query result did not meet the specified conditions (${result})`); + throw new Error(`Query result (${result}) did not meet the specified conditions`); } heartbeat.status = UP; From 9437d25074f7787a60ba7bc202a4edb854001d18 Mon Sep 17 00:00:00 2001 From: Pegasus <42954461+leonace924@users.noreply.github.com> Date: Tue, 6 Jan 2026 01:30:59 -0500 Subject: [PATCH 07/12] Update server/monitor-types/mysql.js Co-authored-by: Frank Elsinga --- server/monitor-types/mysql.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/server/monitor-types/mysql.js b/server/monitor-types/mysql.js index c08f30403..9c31fdd20 100644 --- a/server/monitor-types/mysql.js +++ b/server/monitor-types/mysql.js @@ -51,15 +51,13 @@ class MysqlMonitorType extends MonitorType { // Backwards compatible: just check connection and return row count const result = await this.mysqlQuery(monitor.databaseConnectionString, query, password); heartbeat.ping = dayjs().valueOf() - startTime; + heartbeat.status = UP; heartbeat.msg = result; } } catch (error) { heartbeat.ping = dayjs().valueOf() - startTime; - log.error("mysql", "Database query failed:", error.message); throw new Error(`Database connection/query failed: ${error.message}`); } - - heartbeat.status = UP; } /** From 32456d32fe56971b8efc0914d2d0350dae23a79c Mon Sep 17 00:00:00 2001 From: leonace924 Date: Tue, 6 Jan 2026 01:41:53 -0500 Subject: [PATCH 08/12] fix: address the comment --- server/monitor-types/mysql.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/server/monitor-types/mysql.js b/server/monitor-types/mysql.js index 9c31fdd20..09b0a84c3 100644 --- a/server/monitor-types/mysql.js +++ b/server/monitor-types/mysql.js @@ -1,5 +1,5 @@ const { MonitorType } = require("./monitor-type"); -const { log, UP } = require("../../src/util"); +const { UP } = require("../../src/util"); const dayjs = require("dayjs"); const mysql = require("mysql2"); const { ConditionVariable } = require("../monitor-conditions/variables"); @@ -19,8 +19,6 @@ class MysqlMonitorType extends MonitorType { * @inheritdoc */ async check(monitor, heartbeat, _server) { - let startTime = dayjs().valueOf(); - let query = monitor.databaseQuery; if (!query || (typeof query === "string" && query.trim() === "")) { query = "SELECT 1"; @@ -33,6 +31,7 @@ class MysqlMonitorType extends MonitorType { const conditions = ConditionExpressionGroup.fromMonitor(monitor); const hasConditions = conditions && conditions.length > 0; + const startTime = dayjs().valueOf(); try { if (hasConditions) { // When conditions are enabled, expect a single value result From a034436769026e4418faa06cecac816132e43e3e Mon Sep 17 00:00:00 2001 From: leonace924 Date: Tue, 6 Jan 2026 01:54:38 -0500 Subject: [PATCH 09/12] fix: apply both updates to mssql server monitor and mssql test --- server/monitor-types/mssql.js | 95 +++-- test/backend-test/monitors/test-mssql.js | 460 ++++++++++++----------- 2 files changed, 298 insertions(+), 257 deletions(-) diff --git a/server/monitor-types/mssql.js b/server/monitor-types/mssql.js index 40385351f..c87714b8f 100644 --- a/server/monitor-types/mssql.js +++ b/server/monitor-types/mssql.js @@ -21,53 +21,88 @@ class MssqlMonitorType extends MonitorType { * @inheritdoc */ async check(monitor, heartbeat, _server) { - let startTime = dayjs().valueOf(); - let query = monitor.databaseQuery; // No query provided by user, use SELECT 1 if (!query || (typeof query === "string" && query.trim() === "")) { query = "SELECT 1"; } - let result; - try { - result = await this.mssqlQuery( - monitor.databaseConnectionString, - query - ); - } catch (error) { - log.error("sqlserver", "Database query failed:", error.message); - throw new Error( - `Database connection/query failed: ${error.message}` - ); - } finally { - heartbeat.ping = dayjs().valueOf() - startTime; - } - const conditions = ConditionExpressionGroup.fromMonitor(monitor); - const handleConditions = (data) => - conditions ? evaluateExpressionGroup(conditions, data) : true; + const hasConditions = conditions && conditions.length > 0; - // Since result is now a single value, pass it directly to conditions - const conditionsResult = handleConditions({ result: String(result) }); + const startTime = dayjs().valueOf(); + try { + if (hasConditions) { + // When conditions are enabled, expect a single value result + const result = await this.mssqlQuerySingleValue( + monitor.databaseConnectionString, + query + ); + heartbeat.ping = dayjs().valueOf() - startTime; - if (!conditionsResult) { - throw new Error( - `Query result did not meet the specified conditions (${result})` - ); + const conditionsResult = evaluateExpressionGroup(conditions, { result: String(result) }); + + if (!conditionsResult) { + throw new Error(`Query result (${result}) did not meet the specified conditions`); + } + + heartbeat.status = UP; + heartbeat.msg = "Query did meet specified conditions"; + } else { + // Backwards compatible: just check connection and return row count + const result = await this.mssqlQuery( + monitor.databaseConnectionString, + query + ); + heartbeat.ping = dayjs().valueOf() - startTime; + heartbeat.status = UP; + heartbeat.msg = result; + } + } catch (error) { + heartbeat.ping = dayjs().valueOf() - startTime; + throw new Error(`Database connection/query failed: ${error.message}`); } - - heartbeat.msg = ""; - heartbeat.status = UP; } /** - * Run a query on MSSQL server + * Run a query on MSSQL server (backwards compatible - returns row count) + * @param {string} connectionString The database connection string + * @param {string} query The query to validate the database with + * @returns {Promise} Row count message + */ + async mssqlQuery(connectionString, query) { + let pool; + try { + pool = new mssql.ConnectionPool(connectionString); + await pool.connect(); + const result = await pool.request().query(query); + + if (result.recordset) { + return "Rows: " + result.recordset.length; + } else { + return "No Error, but the result is not an array. Type: " + typeof result.recordset; + } + } catch (err) { + log.debug( + "sqlserver", + "Error caught in the query execution.", + err.message + ); + throw err; + } finally { + if (pool) { + await pool.close(); + } + } + } + + /** + * Run a query on MSSQL server expecting a single value result * @param {string} connectionString The database connection string * @param {string} query The query to validate the database with * @returns {Promise} Single value from the first column of the first row */ - async mssqlQuery(connectionString, query) { + async mssqlQuerySingleValue(connectionString, query) { let pool; try { pool = new mssql.ConnectionPool(connectionString); diff --git a/test/backend-test/monitors/test-mssql.js b/test/backend-test/monitors/test-mssql.js index f265bcdff..e9289aa7b 100644 --- a/test/backend-test/monitors/test-mssql.js +++ b/test/backend-test/monitors/test-mssql.js @@ -6,20 +6,25 @@ const { UP, PENDING } = require("../../../src/util"); /** * Helper function to create and start a MSSQL container - * @returns {Promise} The started MSSQL container + * @returns {Promise<{container: MSSQLServerContainer, connectionString: string}>} The started container and connection string */ async function createAndStartMSSQLContainer() { - return await new MSSQLServerContainer( + const container = await new MSSQLServerContainer( "mcr.microsoft.com/mssql/server:2022-latest" ) .acceptLicense() // The default timeout of 30 seconds might not be enough for the container to start .withStartupTimeout(60000) .start(); + + return { + container, + connectionString: container.getConnectionUri(false) + }; } describe( - "MSSQL Single Node", + "MSSQL Monitor", { skip: !!process.env.CI && @@ -27,56 +32,11 @@ describe( }, () => { test("check() sets status to UP when MSSQL server is reachable", async () => { - let mssqlContainer; - - try { - mssqlContainer = await createAndStartMSSQLContainer(); - - const mssqlMonitor = new MssqlMonitorType(); - const monitor = { - databaseConnectionString: - mssqlContainer.getConnectionUri(false), - conditions: "[]", - }; - - const heartbeat = { - msg: "", - status: PENDING, - }; - - await mssqlMonitor.check(monitor, heartbeat, {}); - assert.strictEqual( - heartbeat.status, - UP, - `Expected status ${UP} but got ${heartbeat.status}` - ); - } catch (error) { - console.error("Test failed with error:", error.message); - console.error("Error stack:", error.stack); - if (mssqlContainer) { - console.error("Container ID:", mssqlContainer.getId()); - console.error( - "Container logs:", - await mssqlContainer.logs() - ); - } - throw error; - } finally { - if (mssqlContainer) { - console.log("Stopping MSSQL container..."); - await mssqlContainer.stop(); - } - } - }); - - test("check() sets status to UP when custom query returns single value", async () => { - const mssqlContainer = await createAndStartMSSQLContainer(); + const { container, connectionString } = await createAndStartMSSQLContainer(); const mssqlMonitor = new MssqlMonitorType(); const monitor = { - databaseConnectionString: - mssqlContainer.getConnectionUri(false), - databaseQuery: "SELECT 42", + databaseConnectionString: connectionString, conditions: "[]", }; @@ -93,183 +53,7 @@ describe( `Expected status ${UP} but got ${heartbeat.status}` ); } finally { - await mssqlContainer.stop(); - } - }); - - test("check() sets status to UP when custom query result meets condition", async () => { - const mssqlContainer = await createAndStartMSSQLContainer(); - - const mssqlMonitor = new MssqlMonitorType(); - const monitor = { - databaseConnectionString: - mssqlContainer.getConnectionUri(false), - databaseQuery: "SELECT 42 as value", - conditions: JSON.stringify([ - { - type: "expression", - andOr: "and", - variable: "result", - operator: "equals", - value: "42", - }, - ]), - }; - - const heartbeat = { - msg: "", - status: PENDING, - }; - - try { - await mssqlMonitor.check(monitor, heartbeat, {}); - assert.strictEqual( - heartbeat.status, - UP, - `Expected status ${UP} but got ${heartbeat.status}` - ); - } finally { - await mssqlContainer.stop(); - } - }); - - test("check() rejects when custom query result does not meet condition", async () => { - const mssqlContainer = await createAndStartMSSQLContainer(); - - const mssqlMonitor = new MssqlMonitorType(); - const monitor = { - databaseConnectionString: - mssqlContainer.getConnectionUri(false), - databaseQuery: "SELECT 99 as value", - conditions: JSON.stringify([ - { - type: "expression", - andOr: "and", - variable: "result", - operator: "equals", - value: "42", - }, - ]), - }; - - const heartbeat = { - msg: "", - status: PENDING, - }; - - try { - await assert.rejects( - mssqlMonitor.check(monitor, heartbeat, {}), - new Error( - "Query result did not meet the specified conditions (99)" - ) - ); - assert.strictEqual( - heartbeat.status, - PENDING, - `Expected status should not be ${heartbeat.status}` - ); - } finally { - await mssqlContainer.stop(); - } - }); - - test("check() rejects when query returns no results", async () => { - const mssqlContainer = await createAndStartMSSQLContainer(); - - const mssqlMonitor = new MssqlMonitorType(); - const monitor = { - databaseConnectionString: - mssqlContainer.getConnectionUri(false), - databaseQuery: "SELECT 1 WHERE 1 = 0", - conditions: "[]", - }; - - const heartbeat = { - msg: "", - status: PENDING, - }; - - try { - await assert.rejects( - mssqlMonitor.check(monitor, heartbeat, {}), - new Error( - "Database connection/query failed: Query returned no results" - ) - ); - assert.strictEqual( - heartbeat.status, - PENDING, - `Expected status should not be ${heartbeat.status}` - ); - } finally { - await mssqlContainer.stop(); - } - }); - - test("check() rejects when query returns multiple rows", async () => { - const mssqlContainer = await createAndStartMSSQLContainer(); - - const mssqlMonitor = new MssqlMonitorType(); - const monitor = { - databaseConnectionString: - mssqlContainer.getConnectionUri(false), - databaseQuery: "SELECT 1 UNION ALL SELECT 2", - conditions: "[]", - }; - - const heartbeat = { - msg: "", - status: PENDING, - }; - - try { - await assert.rejects( - mssqlMonitor.check(monitor, heartbeat, {}), - new Error( - "Database connection/query failed: Multiple values were found, expected only one value" - ) - ); - assert.strictEqual( - heartbeat.status, - PENDING, - `Expected status should not be ${heartbeat.status}` - ); - } finally { - await mssqlContainer.stop(); - } - }); - - test("check() rejects when query returns multiple columns", async () => { - const mssqlContainer = await createAndStartMSSQLContainer(); - - const mssqlMonitor = new MssqlMonitorType(); - const monitor = { - databaseConnectionString: - mssqlContainer.getConnectionUri(false), - databaseQuery: "SELECT 1 AS col1, 2 AS col2", - conditions: "[]", - }; - - const heartbeat = { - msg: "", - status: PENDING, - }; - - try { - await assert.rejects( - mssqlMonitor.check(monitor, heartbeat, {}), - new Error( - "Database connection/query failed: Multiple columns were found, expected only one value" - ) - ); - assert.strictEqual( - heartbeat.status, - PENDING, - `Expected status should not be ${heartbeat.status}` - ); - } finally { - await mssqlContainer.stop(); + await container.stop(); } }); @@ -298,5 +82,227 @@ describe( `Expected status should not be ${heartbeat.status}` ); }); + + test("check() sets status to UP when custom query returns single value", async () => { + const { container, connectionString } = await createAndStartMSSQLContainer(); + + const mssqlMonitor = new MssqlMonitorType(); + const monitor = { + databaseConnectionString: connectionString, + databaseQuery: "SELECT 42", + conditions: "[]", + }; + + const heartbeat = { + msg: "", + status: PENDING, + }; + + try { + await mssqlMonitor.check(monitor, heartbeat, {}); + assert.strictEqual( + heartbeat.status, + UP, + `Expected status ${UP} but got ${heartbeat.status}` + ); + } finally { + await container.stop(); + } + }); + + test("check() sets status to UP when custom query result meets condition", async () => { + const { container, connectionString } = await createAndStartMSSQLContainer(); + + const mssqlMonitor = new MssqlMonitorType(); + const monitor = { + databaseConnectionString: connectionString, + databaseQuery: "SELECT 42 AS value", + conditions: JSON.stringify([ + { + type: "expression", + andOr: "and", + variable: "result", + operator: "equals", + value: "42", + }, + ]), + }; + + const heartbeat = { + msg: "", + status: PENDING, + }; + + try { + await mssqlMonitor.check(monitor, heartbeat, {}); + assert.strictEqual( + heartbeat.status, + UP, + `Expected status ${UP} but got ${heartbeat.status}` + ); + } finally { + await container.stop(); + } + }); + + test("check() rejects when custom query result does not meet condition", async () => { + const { container, connectionString } = await createAndStartMSSQLContainer(); + + const mssqlMonitor = new MssqlMonitorType(); + const monitor = { + databaseConnectionString: connectionString, + databaseQuery: "SELECT 99 AS value", + conditions: JSON.stringify([ + { + type: "expression", + andOr: "and", + variable: "result", + operator: "equals", + value: "42", + }, + ]), + }; + + const heartbeat = { + msg: "", + status: PENDING, + }; + + try { + await assert.rejects( + mssqlMonitor.check(monitor, heartbeat, {}), + new Error( + "Query result (99) did not meet the specified conditions" + ) + ); + assert.strictEqual( + heartbeat.status, + PENDING, + `Expected status should not be ${heartbeat.status}` + ); + } finally { + await container.stop(); + } + }); + + test("check() rejects when query returns no results with conditions", async () => { + const { container, connectionString } = await createAndStartMSSQLContainer(); + + const mssqlMonitor = new MssqlMonitorType(); + const monitor = { + databaseConnectionString: connectionString, + databaseQuery: "SELECT 1 WHERE 1 = 0", + conditions: JSON.stringify([ + { + type: "expression", + andOr: "and", + variable: "result", + operator: "equals", + value: "1", + }, + ]), + }; + + const heartbeat = { + msg: "", + status: PENDING, + }; + + try { + await assert.rejects( + mssqlMonitor.check(monitor, heartbeat, {}), + new Error( + "Database connection/query failed: Query returned no results" + ) + ); + assert.strictEqual( + heartbeat.status, + PENDING, + `Expected status should not be ${heartbeat.status}` + ); + } finally { + await container.stop(); + } + }); + + test("check() rejects when query returns multiple rows with conditions", async () => { + const { container, connectionString } = await createAndStartMSSQLContainer(); + + const mssqlMonitor = new MssqlMonitorType(); + const monitor = { + databaseConnectionString: connectionString, + databaseQuery: "SELECT 1 UNION ALL SELECT 2", + conditions: JSON.stringify([ + { + type: "expression", + andOr: "and", + variable: "result", + operator: "equals", + value: "1", + }, + ]), + }; + + const heartbeat = { + msg: "", + status: PENDING, + }; + + try { + await assert.rejects( + mssqlMonitor.check(monitor, heartbeat, {}), + new Error( + "Database connection/query failed: Multiple values were found, expected only one value" + ) + ); + assert.strictEqual( + heartbeat.status, + PENDING, + `Expected status should not be ${heartbeat.status}` + ); + } finally { + await container.stop(); + } + }); + + test("check() rejects when query returns multiple columns with conditions", async () => { + const { container, connectionString } = await createAndStartMSSQLContainer(); + + const mssqlMonitor = new MssqlMonitorType(); + const monitor = { + databaseConnectionString: connectionString, + databaseQuery: "SELECT 1 AS col1, 2 AS col2", + conditions: JSON.stringify([ + { + type: "expression", + andOr: "and", + variable: "result", + operator: "equals", + value: "1", + }, + ]), + }; + + const heartbeat = { + msg: "", + status: PENDING, + }; + + try { + await assert.rejects( + mssqlMonitor.check(monitor, heartbeat, {}), + new Error( + "Database connection/query failed: Multiple columns were found, expected only one value" + ) + ); + assert.strictEqual( + heartbeat.status, + PENDING, + `Expected status should not be ${heartbeat.status}` + ); + } finally { + await container.stop(); + } + }); } ); From c7702a3b2360d05357420a91bd10ffe192796045 Mon Sep 17 00:00:00 2001 From: leonace924 Date: Tue, 6 Jan 2026 02:05:23 -0500 Subject: [PATCH 10/12] fix: update the test to pass CI test --- server/monitor-types/mssql.js | 6 +++++- server/monitor-types/mysql.js | 8 ++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/server/monitor-types/mssql.js b/server/monitor-types/mssql.js index c87714b8f..e4bd6ca85 100644 --- a/server/monitor-types/mssql.js +++ b/server/monitor-types/mssql.js @@ -43,7 +43,7 @@ class MssqlMonitorType extends MonitorType { const conditionsResult = evaluateExpressionGroup(conditions, { result: String(result) }); if (!conditionsResult) { - throw new Error(`Query result (${result}) did not meet the specified conditions`); + throw new Error(`Query result did not meet the specified conditions (${result})`); } heartbeat.status = UP; @@ -60,6 +60,10 @@ class MssqlMonitorType extends MonitorType { } } catch (error) { heartbeat.ping = dayjs().valueOf() - startTime; + // Re-throw condition errors as-is, wrap database errors + if (error.message.includes("did not meet the specified conditions")) { + throw error; + } throw new Error(`Database connection/query failed: ${error.message}`); } } diff --git a/server/monitor-types/mysql.js b/server/monitor-types/mysql.js index 09b0a84c3..7d9a9eb45 100644 --- a/server/monitor-types/mysql.js +++ b/server/monitor-types/mysql.js @@ -41,11 +41,11 @@ class MysqlMonitorType extends MonitorType { const conditionsResult = evaluateExpressionGroup(conditions, { result: String(result) }); if (!conditionsResult) { - throw new Error(`Query result (${result}) did not meet the specified conditions`); + throw new Error(`Query result did not meet the specified conditions (${result})`); } heartbeat.status = UP; - heartbeat.msg = "query did meet specified conditions"; + heartbeat.msg = "Query did meet specified conditions"; } else { // Backwards compatible: just check connection and return row count const result = await this.mysqlQuery(monitor.databaseConnectionString, query, password); @@ -55,6 +55,10 @@ class MysqlMonitorType extends MonitorType { } } catch (error) { heartbeat.ping = dayjs().valueOf() - startTime; + // Re-throw condition errors as-is, wrap database errors + if (error.message.includes("did not meet the specified conditions")) { + throw error; + } throw new Error(`Database connection/query failed: ${error.message}`); } } From 806dadce5c91d1224d283dbf1eb5be5adf7524f7 Mon Sep 17 00:00:00 2001 From: leonace924 Date: Tue, 6 Jan 2026 02:11:49 -0500 Subject: [PATCH 11/12] fix: update the test --- server/monitor-types/mssql.js | 2 +- server/monitor-types/mysql.js | 2 +- test/backend-test/monitors/test-mssql.js | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/server/monitor-types/mssql.js b/server/monitor-types/mssql.js index e4bd6ca85..444615263 100644 --- a/server/monitor-types/mssql.js +++ b/server/monitor-types/mssql.js @@ -28,7 +28,7 @@ class MssqlMonitorType extends MonitorType { } const conditions = ConditionExpressionGroup.fromMonitor(monitor); - const hasConditions = conditions && conditions.length > 0; + const hasConditions = conditions !== null; const startTime = dayjs().valueOf(); try { diff --git a/server/monitor-types/mysql.js b/server/monitor-types/mysql.js index 7d9a9eb45..c5863d50b 100644 --- a/server/monitor-types/mysql.js +++ b/server/monitor-types/mysql.js @@ -29,7 +29,7 @@ class MysqlMonitorType extends MonitorType { const password = monitor.radiusPassword; const conditions = ConditionExpressionGroup.fromMonitor(monitor); - const hasConditions = conditions && conditions.length > 0; + const hasConditions = conditions !== null; const startTime = dayjs().valueOf(); try { diff --git a/test/backend-test/monitors/test-mssql.js b/test/backend-test/monitors/test-mssql.js index e9289aa7b..37c050682 100644 --- a/test/backend-test/monitors/test-mssql.js +++ b/test/backend-test/monitors/test-mssql.js @@ -172,7 +172,7 @@ describe( await assert.rejects( mssqlMonitor.check(monitor, heartbeat, {}), new Error( - "Query result (99) did not meet the specified conditions" + "Query result did not meet the specified conditions (99)" ) ); assert.strictEqual( From f0751fcf5a7a0039229d6ee4b15b78b62af60a59 Mon Sep 17 00:00:00 2001 From: leonace924 Date: Tue, 6 Jan 2026 02:37:05 -0500 Subject: [PATCH 12/12] fix: update the test --- server/monitor-types/mssql.js | 4 ++-- server/monitor-types/mysql.js | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/server/monitor-types/mssql.js b/server/monitor-types/mssql.js index 444615263..1f4284aa2 100644 --- a/server/monitor-types/mssql.js +++ b/server/monitor-types/mssql.js @@ -27,8 +27,8 @@ class MssqlMonitorType extends MonitorType { query = "SELECT 1"; } - const conditions = ConditionExpressionGroup.fromMonitor(monitor); - const hasConditions = conditions !== null; + const conditions = monitor.conditions ? ConditionExpressionGroup.fromMonitor(monitor) : null; + const hasConditions = conditions && conditions.children && conditions.children.length > 0; const startTime = dayjs().valueOf(); try { diff --git a/server/monitor-types/mysql.js b/server/monitor-types/mysql.js index c5863d50b..508745e1c 100644 --- a/server/monitor-types/mysql.js +++ b/server/monitor-types/mysql.js @@ -28,8 +28,8 @@ class MysqlMonitorType extends MonitorType { // TODO: rename `radius_password` to `password` later for general use const password = monitor.radiusPassword; - const conditions = ConditionExpressionGroup.fromMonitor(monitor); - const hasConditions = conditions !== null; + const conditions = monitor.conditions ? ConditionExpressionGroup.fromMonitor(monitor) : null; + const hasConditions = conditions && conditions.children && conditions.children.length > 0; const startTime = dayjs().valueOf(); try {