feat: add OracleDB monitor (#7156)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
Ryan
2026-03-18 19:04:13 +08:00
committed by GitHub
parent 9b28ddd923
commit 77425f7a71
8 changed files with 484 additions and 16 deletions

46
package-lock.json generated
View File

@@ -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": {

View File

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

View File

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

View File

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

View File

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

View File

@@ -86,6 +86,13 @@
MQTT: {{ monitor.hostname }}:{{ monitor.port }}/{{ monitor.mqttTopic }}
</span>
<span v-if="monitor.type === 'mysql'">{{ filterPassword(monitor.databaseConnectionString) }}</span>
<span v-if="monitor.type === 'oracledb'">
{{
$t("oracledbConnectionString", {
connectionString: filterPassword(monitor.databaseConnectionString),
})
}}
</span>
<span v-if="monitor.type === 'postgres'">{{ filterPassword(monitor.databaseConnectionString) }}</span>
<span v-if="monitor.type === 'push'">
Push:

View File

@@ -98,6 +98,7 @@
<option value="sqlserver">Microsoft SQL Server</option>
<option value="mongodb">MongoDB</option>
<option value="mysql">MySQL/MariaDB</option>
<option value="oracledb">Oracle Database</option>
<option value="postgres">PostgreSQL</option>
<option value="radius">Radius</option>
<option value="redis">Redis</option>
@@ -1161,12 +1162,13 @@
</div>
</template>
<!-- SQL Server / PostgreSQL / MySQL / Redis / MongoDB -->
<!-- SQL Server / PostgreSQL / MySQL / Oracle / Redis / MongoDB -->
<template
v-if="
monitor.type === 'sqlserver' ||
monitor.type === 'postgres' ||
monitor.type === 'mysql' ||
monitor.type === 'oracledb' ||
monitor.type === 'redis' ||
monitor.type === 'mongodb'
"
@@ -1185,6 +1187,29 @@
</div>
</template>
<template v-if="monitor.type === 'oracledb'">
<div class="my-3">
<label for="oracledb-user" class="form-label">{{ $t("Username") }}</label>
<input
id="oracledb-user"
v-model="monitor.basic_auth_user"
type="text"
class="form-control"
required
/>
</div>
<div class="my-3">
<label for="oracledb-pass" class="form-label">{{ $t("Password") }}</label>
<HiddenInput
id="oracledb-pass"
v-model="monitor.basic_auth_pass"
autocomplete="new-password"
:required="true"
/>
</div>
</template>
<template v-if="monitor.type === 'system-service'">
<div class="my-3">
<label for="system-service-name" class="form-label">{{ $t("Service Name") }}</label>
@@ -1276,12 +1301,13 @@
</div>
</template>
<!-- SQL Server / PostgreSQL / MySQL -->
<!-- SQL Server / PostgreSQL / MySQL / Oracle -->
<template
v-if="
monitor.type === 'sqlserver' ||
monitor.type === 'postgres' ||
monitor.type === 'mysql'
monitor.type === 'mysql' ||
monitor.type === 'oracledb'
"
>
<div class="my-3">
@@ -1290,7 +1316,11 @@
id="sqlQuery"
v-model="monitor.databaseQuery"
class="form-control"
:placeholder="$t('Example:', ['SELECT 1'])"
:placeholder="
$t('Example:', [
monitor.type === 'oracledb' ? 'SELECT 1 FROM DUAL' : 'SELECT 1',
])
"
></textarea>
</div>
</template>
@@ -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=<hostname>,<port>;Database=<your database>;User Id=<your user id>;Password=<your password>;Encrypt=<true/false>;TrustServerCertificate=<Yes/No>;Connection Timeout=<int>",
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) {

View File

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