From 77425f7a71c2a90cf738e9c43599c4c5c88bd4f8 Mon Sep 17 00:00:00 2001 From: Ryan Date: Wed, 18 Mar 2026 19:04:13 +0800 Subject: [PATCH] feat: add OracleDB monitor (#7156) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- package-lock.json | 46 +++- package.json | 2 + server/monitor-types/oracledb.js | 155 +++++++++++++ server/uptime-kuma-server.js | 2 + src/lang/en.json | 1 + src/pages/Details.vue | 7 + src/pages/EditMonitor.vue | 55 ++++- test/backend-test/monitors/test-oracledb.js | 232 ++++++++++++++++++++ 8 files changed, 484 insertions(+), 16 deletions(-) create mode 100644 server/monitor-types/oracledb.js create mode 100644 test/backend-test/monitors/test-oracledb.js diff --git a/package-lock.json b/package-lock.json index 2d34f73bb..4c66721c4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -66,6 +66,7 @@ "nostr-tools": "~2.20.0", "notp": "~2.0.3", "openid-client": "~5.7.1", + "oracledb": "~6.10.0", "password-hash": "~1.2.2", "pg": "~8.11.6", "pg-connection-string": "~2.6.4", @@ -104,6 +105,7 @@ "@testcontainers/mariadb": "^10.28.0", "@testcontainers/mssqlserver": "^10.28.0", "@testcontainers/mysql": "^11.12.0", + "@testcontainers/oraclefree": "^11.13.0", "@testcontainers/postgresql": "^11.12.0", "@testcontainers/rabbitmq": "^10.28.0", "@types/bootstrap": "~5.1.13", @@ -4133,6 +4135,16 @@ "testcontainers": "^11.12.0" } }, + "node_modules/@testcontainers/oraclefree": { + "version": "11.13.0", + "resolved": "https://registry.npmjs.org/@testcontainers/oraclefree/-/oraclefree-11.13.0.tgz", + "integrity": "sha512-qYy7Q9L5XOM++4aCjcJnmxvRIXaAkyR0zOL0Sa6nkI2YfTeLgZ+GUFaLht4Tox3COuCEw5po8DJWqYcKmmgtjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "testcontainers": "^11.13.0" + } + }, "node_modules/@testcontainers/postgresql": { "version": "11.12.0", "resolved": "https://registry.npmjs.org/@testcontainers/postgresql/-/postgresql-11.12.0.tgz", @@ -7670,9 +7682,9 @@ "license": "MIT" }, "node_modules/docker-compose": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/docker-compose/-/docker-compose-1.3.1.tgz", - "integrity": "sha512-rF0wH69G3CCcmkN9J1RVMQBaKe8o77LT/3XmqcLIltWWVxcWAzp2TnO7wS3n/umZHN3/EVrlT3exSBMal+Ou1w==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/docker-compose/-/docker-compose-1.3.2.tgz", + "integrity": "sha512-FO/Jemn08gf9o9E6qtqOPQpyauwf2rQAzfpoUlMyqNpdaVb0ImR/wXKoutLZKp1tks58F8Z8iR7va7H1ne09cw==", "dev": true, "license": "MIT", "dependencies": { @@ -12731,6 +12743,16 @@ "node": ">= 0.8.0" } }, + "node_modules/oracledb": { + "version": "6.10.0", + "resolved": "https://registry.npmjs.org/oracledb/-/oracledb-6.10.0.tgz", + "integrity": "sha512-kGUumXmrEWbSpBuKJyb9Ip3rXcNgKK6grunI3/cLPzrRvboZ6ZoLi9JQ+z6M/RIG924tY8BLflihL4CKKQAYMA==", + "hasInstallScript": true, + "license": "(Apache-2.0 OR UPL-1.0)", + "engines": { + "node": ">=14.17" + } + }, "node_modules/own-keys": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", @@ -16139,9 +16161,9 @@ } }, "node_modules/testcontainers": { - "version": "11.12.0", - "resolved": "https://registry.npmjs.org/testcontainers/-/testcontainers-11.12.0.tgz", - "integrity": "sha512-VWtH+UQejVYYvb53ohEZRbx2naxyDvwO9lQ6A0VgmVE2Oh8r9EF09I+BfmrXpd9N9ntpzhao9di2yNwibSz5KA==", + "version": "11.13.0", + "resolved": "https://registry.npmjs.org/testcontainers/-/testcontainers-11.13.0.tgz", + "integrity": "sha512-fzTvgOtd6U/esOzgmDatJh79OSK0tU6vjDOJ3B6ICrrJf0dqCWtFdpOr6f/g/KixMxKDTDbszmZYjSORJXsVCQ==", "dev": true, "license": "MIT", "dependencies": { @@ -16151,21 +16173,21 @@ "async-lock": "^1.4.1", "byline": "^5.0.0", "debug": "^4.4.3", - "docker-compose": "^1.3.1", + "docker-compose": "^1.3.2", "dockerode": "^4.0.9", "get-port": "^7.1.0", "proper-lockfile": "^4.1.2", "properties-reader": "^3.0.1", "ssh-remote-port-forward": "^1.0.4", - "tar-fs": "^3.1.1", + "tar-fs": "^3.1.2", "tmp": "^0.2.5", - "undici": "^7.22.0" + "undici": "^7.24.3" } }, "node_modules/testcontainers/node_modules/undici": { - "version": "7.22.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.22.0.tgz", - "integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==", + "version": "7.24.4", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.4.tgz", + "integrity": "sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w==", "dev": true, "license": "MIT", "engines": { diff --git a/package.json b/package.json index 700e15e86..9ea5c35c4 100644 --- a/package.json +++ b/package.json @@ -126,6 +126,7 @@ "nostr-tools": "~2.20.0", "notp": "~2.0.3", "openid-client": "~5.7.1", + "oracledb": "~6.10.0", "password-hash": "~1.2.2", "pg": "~8.11.6", "pg-connection-string": "~2.6.4", @@ -164,6 +165,7 @@ "@testcontainers/mariadb": "^10.28.0", "@testcontainers/mssqlserver": "^10.28.0", "@testcontainers/mysql": "^11.12.0", + "@testcontainers/oraclefree": "^11.13.0", "@testcontainers/postgresql": "^11.12.0", "@testcontainers/rabbitmq": "^10.28.0", "@types/bootstrap": "~5.1.13", diff --git a/server/monitor-types/oracledb.js b/server/monitor-types/oracledb.js new file mode 100644 index 000000000..8b5af9731 --- /dev/null +++ b/server/monitor-types/oracledb.js @@ -0,0 +1,155 @@ +const { MonitorType } = require("./monitor-type"); +const { log, UP } = require("../../src/util"); +const dayjs = require("dayjs"); +const oracledb = require("oracledb"); +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 OracleDbMonitorType extends MonitorType { + name = "oracledb"; + + supportsConditions = true; + conditionVariables = [new ConditionVariable("result", defaultStringOperators)]; + + /** + * @inheritdoc + */ + async check(monitor, heartbeat, _server) { + let query = monitor.databaseQuery; + if (!query || (typeof query === "string" && query.trim() === "")) { + query = "SELECT 1 FROM DUAL"; + } + + const conditions = monitor.conditions ? ConditionExpressionGroup.fromMonitor(monitor) : null; + const hasConditions = conditions && conditions.children && conditions.children.length > 0; + + const startTime = dayjs().valueOf(); + try { + if (hasConditions) { + const result = await this.oracledbQuerySingleValue( + monitor.databaseConnectionString, + query, + monitor.basic_auth_user, + monitor.basic_auth_pass + ); + 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.status = UP; + heartbeat.msg = "Query did meet specified conditions"; + } else { + const result = await this.oracledbQuery( + monitor.databaseConnectionString, + query, + monitor.basic_auth_user, + monitor.basic_auth_pass + ); + heartbeat.ping = dayjs().valueOf() - startTime; + heartbeat.status = UP; + heartbeat.msg = result; + } + } catch (error) { + heartbeat.ping = dayjs().valueOf() - startTime; + if (error.message.includes("did not meet the specified conditions")) { + throw error; + } + throw new Error(`Database connection/query failed: ${error.message}`); + } + } + + /** + * Run a query on Oracle Database. + * @param {string} connectionString The Oracle DB connection string + * @param {string} query The query to execute + * @param {string} username Oracle DB username + * @param {string} password Oracle DB password + * @returns {Promise} Row count or execution message + */ + async oracledbQuery(connectionString, query, username, password) { + let connection; + try { + connection = await oracledb.getConnection({ + connectString: connectionString.trim(), + user: username.trim(), + password: password.trim(), + }); + const result = await connection.execute(query, [], { + outFormat: oracledb.OUT_FORMAT_OBJECT, + }); + + if (Array.isArray(result.rows)) { + return `Rows: ${result.rows.length}`; + } + + if (typeof result.rowsAffected === "number") { + return `Rows affected: ${result.rowsAffected}`; + } + + return "Query executed successfully"; + } catch (error) { + log.debug(this.name, "Error caught in the query execution.", error.message); + throw error; + } finally { + if (connection) { + await connection.close(); + } + } + } + + /** + * Run a query on Oracle Database expecting a single value result. + * @param {string} connectionString The Oracle DB connection string + * @param {string} query The query to execute + * @param {string} username Oracle DB username + * @param {string} password Oracle DB password + * @returns {Promise} Single value from the first column of the first row + */ + async oracledbQuerySingleValue(connectionString, query, username, password) { + let connection; + try { + connection = await oracledb.getConnection({ + connectString: connectionString, + user: username, + password: password, + }); + const result = await connection.execute(query, [], { + outFormat: oracledb.OUT_FORMAT_OBJECT, + }); + + if (!result.rows || result.rows.length === 0) { + throw new Error("Query returned no results"); + } + + if (result.rows.length > 1) { + throw new Error("Multiple values were found, expected only one value"); + } + + const firstRow = result.rows[0]; + const columnNames = Object.keys(firstRow); + + if (columnNames.length > 1) { + throw new Error("Multiple columns were found, expected only one value"); + } + + return firstRow[columnNames[0]]; + } catch (error) { + log.debug(this.name, "Error caught in the query execution.", error.message); + throw error; + } finally { + if (connection) { + await connection.close(); + } + } + } +} + +module.exports = { + OracleDbMonitorType, +}; diff --git a/server/uptime-kuma-server.js b/server/uptime-kuma-server.js index 6709065b9..a1ee80485 100644 --- a/server/uptime-kuma-server.js +++ b/server/uptime-kuma-server.js @@ -131,6 +131,7 @@ class UptimeKumaServer { UptimeKumaServer.monitorTypeList["system-service"] = new SystemServiceMonitorType(); UptimeKumaServer.monitorTypeList["sqlserver"] = new MssqlMonitorType(); UptimeKumaServer.monitorTypeList["mysql"] = new MysqlMonitorType(); + UptimeKumaServer.monitorTypeList["oracledb"] = new OracleDbMonitorType(); // Allow all CORS origins (polling) in development let cors = undefined; @@ -582,4 +583,5 @@ 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 { OracleDbMonitorType } = require("./monitor-types/oracledb"); const Monitor = require("./model/monitor"); diff --git a/src/lang/en.json b/src/lang/en.json index 0bebb4f52..f75f32db5 100644 --- a/src/lang/en.json +++ b/src/lang/en.json @@ -6,6 +6,7 @@ "setupDatabaseSQLite": "A simple database file, recommended for small-scale deployments. Prior to v2.0.0, Uptime Kuma used SQLite as the default database.", "settingUpDatabaseMSG": "Setting up the database. It may take a while, please be patient.", "dbName": "Database Name", + "oracledbConnectionString": "Oracle Database: {connectionString}", "enableSSL": "Enable SSL/TLS", "mariadbUseSSLHelptext": "Enable to use a encrypted connection to your database. Required for most cloud databases.", "mariadbCaCertificateLabel": "CA Certificate", diff --git a/src/pages/Details.vue b/src/pages/Details.vue index 2a4bb867e..6d02a1839 100644 --- a/src/pages/Details.vue +++ b/src/pages/Details.vue @@ -86,6 +86,13 @@ MQTT: {{ monitor.hostname }}:{{ monitor.port }}/{{ monitor.mqttTopic }} {{ filterPassword(monitor.databaseConnectionString) }} + + {{ + $t("oracledbConnectionString", { + connectionString: filterPassword(monitor.databaseConnectionString), + }) + }} + {{ filterPassword(monitor.databaseConnectionString) }} Push: diff --git a/src/pages/EditMonitor.vue b/src/pages/EditMonitor.vue index 3f3cf95f9..ac152a87d 100644 --- a/src/pages/EditMonitor.vue +++ b/src/pages/EditMonitor.vue @@ -98,6 +98,7 @@ + @@ -1161,12 +1162,13 @@ - + + + - + @@ -2880,6 +2910,8 @@ const monitorDefaults = { docker_container: "", docker_host: null, proxyId: null, + basic_auth_user: "", + basic_auth_pass: "", mqttUsername: "", mqttPassword: "", mqttTopic: "", @@ -2944,6 +2976,7 @@ export default { "Server=,;Database=;User Id=;Password=;Encrypt=;TrustServerCertificate=;Connection Timeout=", postgres: "postgres://username:password@host:port/database", mysql: "mysql://username:password@host:port/database", + oracledb: "localhost:1521/FREEPDB1", redis: "redis://user:password@host:port", mongodb: "mongodb://username:password@host:port/database", }, @@ -3842,6 +3875,20 @@ message HealthCheckResponse { this.monitor.url = this.monitor.url.trim(); } + if (this.monitor.databaseConnectionString) { + this.monitor.databaseConnectionString = this.monitor.databaseConnectionString.trim(); + } + + if (this.monitor.type === "oracledb") { + if (this.monitor.basic_auth_user) { + this.monitor.basic_auth_user = this.monitor.basic_auth_user.trim(); + } + + if (this.monitor.basic_auth_pass) { + this.monitor.basic_auth_pass = this.monitor.basic_auth_pass.trim(); + } + } + let createdNewParent = false; if (this.draftGroupName && this.monitor.parent === -1) { diff --git a/test/backend-test/monitors/test-oracledb.js b/test/backend-test/monitors/test-oracledb.js new file mode 100644 index 000000000..6859eec3b --- /dev/null +++ b/test/backend-test/monitors/test-oracledb.js @@ -0,0 +1,232 @@ +const { after, before, describe, test } = require("node:test"); +const assert = require("node:assert"); +const { OracleDbContainer } = require("@testcontainers/oraclefree"); +const { OracleDbMonitorType } = require("../../../server/monitor-types/oracledb"); +const { UP, PENDING } = require("../../../src/util"); + +const ORACLE_IMAGE = "gvenzl/oracle-free:23-slim-faststart"; +const APP_USER = "uptimekuma"; +const APP_USER_PASSWORD = "Oracle123"; + +/** + * Create a monitor payload for Oracle monitor tests. + * @param {object} overrides Partial monitor overrides + * @returns {object} Monitor payload + */ +function createMonitor(overrides = {}) { + return { + basic_auth_user: APP_USER, + basic_auth_pass: APP_USER_PASSWORD, + conditions: "[]", + ...overrides, + }; +} + +/** + * Create a baseline heartbeat object for Oracle monitor tests. + * @returns {{msg: string, status: string}} Heartbeat payload + */ +function createHeartbeat() { + return { + msg: "", + status: PENDING, + }; +} + +/** + * Helper function to create and start an Oracle container. + * @returns {Promise<{container: import("@testcontainers/oraclefree").StartedOracleDbContainer, connectString: string}>} + */ +async function createAndStartOracleContainer() { + const container = await new OracleDbContainer(ORACLE_IMAGE) + .withUsername(APP_USER) + .withPassword(APP_USER_PASSWORD) + .start(); + + return { + container, + connectString: container.getUrl(), + }; +} + +describe( + "Oracle Database Monitor", + { + skip: !!process.env.CI && (process.platform !== "linux" || process.arch !== "x64"), + }, + () => { + /** @type {import("@testcontainers/oraclefree").StartedOracleDbContainer | undefined} */ + let container; + /** @type {string | undefined} */ + let connectString; + + before(async () => { + const oracle = await createAndStartOracleContainer(); + container = oracle.container; + connectString = oracle.connectString; + }); + + after(async () => { + if (container) { + await container.stop(); + } + }); + + test("check() sets status to UP when Oracle server is reachable", async () => { + const oracleMonitor = new OracleDbMonitorType(); + const monitor = createMonitor({ + databaseConnectionString: connectString, + }); + const heartbeat = createHeartbeat(); + + await oracleMonitor.check(monitor, heartbeat, {}); + assert.strictEqual(heartbeat.status, UP, `Expected status ${UP} but got ${heartbeat.status}`); + }); + + test("check() rejects when Oracle server is not reachable", async () => { + const oracleMonitor = new OracleDbMonitorType(); + const monitor = createMonitor({ + databaseConnectionString: "localhost:1/FREEPDB1", + }); + const heartbeat = createHeartbeat(); + + await assert.rejects(oracleMonitor.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 returns single value", async () => { + const oracleMonitor = new OracleDbMonitorType(); + const monitor = createMonitor({ + databaseConnectionString: connectString, + databaseQuery: "SELECT 42 FROM DUAL", + }); + const heartbeat = createHeartbeat(); + + await oracleMonitor.check(monitor, heartbeat, {}); + assert.strictEqual(heartbeat.status, UP, `Expected status ${UP} but got ${heartbeat.status}`); + }); + + test("check() sets status to UP when custom query result meets condition", async () => { + const oracleMonitor = new OracleDbMonitorType(); + const monitor = createMonitor({ + databaseConnectionString: connectString, + databaseQuery: "SELECT 42 AS value FROM DUAL", + conditions: JSON.stringify([ + { + type: "expression", + andOr: "and", + variable: "result", + operator: "equals", + value: "42", + }, + ]), + }); + const heartbeat = createHeartbeat(); + + await oracleMonitor.check(monitor, heartbeat, {}); + assert.strictEqual(heartbeat.status, UP, `Expected status ${UP} but got ${heartbeat.status}`); + }); + + test("check() rejects when custom query result does not meet condition", async () => { + const oracleMonitor = new OracleDbMonitorType(); + const monitor = createMonitor({ + databaseConnectionString: connectString, + databaseQuery: "SELECT 99 AS value FROM DUAL", + conditions: JSON.stringify([ + { + type: "expression", + andOr: "and", + variable: "result", + operator: "equals", + value: "42", + }, + ]), + }); + const heartbeat = createHeartbeat(); + + await assert.rejects( + oracleMonitor.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}`); + }); + + test("check() rejects when query returns no results with conditions", async () => { + const oracleMonitor = new OracleDbMonitorType(); + const monitor = createMonitor({ + databaseConnectionString: connectString, + databaseQuery: "SELECT 1 AS value FROM DUAL WHERE 1 = 0", + conditions: JSON.stringify([ + { + type: "expression", + andOr: "and", + variable: "result", + operator: "equals", + value: "1", + }, + ]), + }); + const heartbeat = createHeartbeat(); + + await assert.rejects( + oracleMonitor.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}`); + }); + + test("check() rejects when query returns multiple rows with conditions", async () => { + const oracleMonitor = new OracleDbMonitorType(); + const monitor = createMonitor({ + databaseConnectionString: connectString, + databaseQuery: "SELECT 1 AS value FROM DUAL UNION ALL SELECT 2 AS value FROM DUAL", + conditions: JSON.stringify([ + { + type: "expression", + andOr: "and", + variable: "result", + operator: "equals", + value: "1", + }, + ]), + }); + const heartbeat = createHeartbeat(); + + await assert.rejects( + oracleMonitor.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}`); + }); + + test("check() rejects when query returns multiple columns with conditions", async () => { + const oracleMonitor = new OracleDbMonitorType(); + const monitor = createMonitor({ + databaseConnectionString: connectString, + databaseQuery: "SELECT 1 AS col1, 2 AS col2 FROM DUAL", + conditions: JSON.stringify([ + { + type: "expression", + andOr: "and", + variable: "result", + operator: "equals", + value: "1", + }, + ]), + }); + const heartbeat = createHeartbeat(); + + await assert.rejects( + oracleMonitor.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}`); + }); + } +);