From d82535241056864200e404e6720c8a77faec8d55 Mon Sep 17 00:00:00 2001 From: leonace924 Date: Tue, 6 Jan 2026 00:43:17 -0500 Subject: [PATCH] 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(); + } + }); + } +);