This commit is contained in:
Louis Lam
2026-07-16 14:40:28 +08:00
parent 23e53e4964
commit afaeab96f9
7 changed files with 154 additions and 28 deletions
@@ -0,0 +1,11 @@
exports.up = function (knex) {
return knex.schema.alterTable("monitor", function (table) {
table.text("config", "longtext").defaultTo("{}").notNullable();
});
};
exports.down = function (knex) {
return knex.schema.alterTable("monitor", function (table) {
table.dropColumn("config");
});
};
@@ -0,0 +1,55 @@
exports.up = async function (knex) {
// Migrate system_service_name data into config JSON
const monitors = await knex("monitor").whereNotNull("system_service_name").select("id", "system_service_name", "config");
for (const monitor of monitors) {
let config = {};
if (monitor.config) {
try {
config = JSON.parse(monitor.config);
} catch (e) {
config = {};
}
}
config.system_service_name = monitor.system_service_name;
await knex("monitor").where("id", monitor.id).update({
config: JSON.stringify(config),
});
}
// Drop the column
await knex.schema.alterTable("monitor", function (table) {
table.dropColumn("system_service_name");
});
};
exports.down = async function (knex) {
// Re-add the column
await knex.schema.alterTable("monitor", function (table) {
table.string("system_service_name");
});
// Migrate data back from config to column
const monitors = await knex("monitor").whereNotNull("config").select("id", "config");
for (const monitor of monitors) {
try {
const config = JSON.parse(monitor.config);
if (config.system_service_name !== undefined) {
await knex("monitor").where("id", monitor.id).update({
system_service_name: config.system_service_name,
});
delete config.system_service_name;
await knex("monitor").where("id", monitor.id).update({
config: JSON.stringify(config),
});
}
} catch (e) {
// ignore parse errors
}
}
};
+50 -1
View File
@@ -186,7 +186,6 @@ class Monitor extends BeanModel {
httpBodyEncoding: this.httpBodyEncoding,
jsonPath: this.jsonPath,
expectedValue: this.expectedValue,
system_service_name: this.system_service_name,
kafkaProducerTopic: this.kafkaProducerTopic,
kafkaProducerBrokers: JSON.parse(this.kafkaProducerBrokers),
kafkaProducerSsl: this.getKafkaProducerSsl(),
@@ -213,6 +212,9 @@ class Monitor extends BeanModel {
saveResponse: this.getSaveResponse(),
saveErrorResponse: this.getSaveErrorResponse(),
responseMaxLength: this.response_max_length ?? RESPONSE_BODY_LENGTH_DEFAULT,
// extensible config for new monitor type settings
config: this.getConfig(),
};
if (includeSensitiveData) {
@@ -403,6 +405,53 @@ class Monitor extends BeanModel {
return Boolean(this.save_error_response);
}
/**
* Get the full config
* @returns {object} Parsed config, empty object if null
*/
getConfig() {
if (this.config) {
return JSON.parse(this.config);
}
return {};
}
/**
* Set the full config object
* @param {object} config The config object to store
* @returns {void}
*/
setConfig(config) {
this.config = JSON.stringify(config);
}
/**
* Get a value from the config
*
* PS: if you want to get multiple fields at the same time, use getConfig() to avoid multiple JSON.parse/stringify calls.
* @param {string} key The config key to retrieve
* @param {*} defaultValue Default value if key doesn't exist
* @returns {*} The config value or defaultValue
*/
getConfigValue(key, defaultValue = undefined) {
const config = this.getConfig();
return key in config ? config[key] : defaultValue;
}
/**
* Set a value in the config
*
* PS: If update multiple fields at the same time, use setConfig() instead to avoid multiple JSON.parse/stringify calls.
* @param {string} key The config key to set
* @param {*} value The value to store
* @returns {void}
*/
setConfigValue(key, value) {
const config = this.getConfig();
config[key] = value;
this.setConfig(config);
}
/**
* Start monitor
* @param {Server} io Socket server instance
+5 -3
View File
@@ -15,14 +15,16 @@ class SystemServiceMonitorType extends MonitorType {
* @returns {Promise<void>} Resolves when check is complete.
*/
async check(monitor, heartbeat) {
if (!monitor.system_service_name) {
const serviceName = monitor.getConfigValue("system_service_name");
if (!serviceName) {
throw new Error("Service Name is required.");
}
if (process.platform === "win32") {
return this.checkWindows(monitor.system_service_name, heartbeat);
return this.checkWindows(serviceName, heartbeat);
} else if (process.platform === "linux") {
return this.checkLinux(monitor.system_service_name, heartbeat);
return this.checkLinux(serviceName, heartbeat);
} else {
throw new Error(`System Service monitoring is not supported on ${process.platform}`);
}
+2 -1
View File
@@ -743,6 +743,7 @@ let needSetup = false;
monitor.conditions = JSON.stringify(monitor.conditions);
monitor.rabbitmqNodes = JSON.stringify(monitor.rabbitmqNodes);
monitor.config = JSON.stringify(monitor.config || {});
/*
* List of frontend-only properties that should not be saved to the database.
@@ -931,7 +932,7 @@ let needSetup = false;
bean.rabbitmqPassword = monitor.rabbitmqPassword;
bean.conditions = JSON.stringify(monitor.conditions);
bean.manual_status = monitor.manual_status;
bean.system_service_name = monitor.system_service_name;
bean.setConfigValue("system_service_name", monitor.system_service_name);
bean.expected_tls_alert = monitor.expectedTlsAlert;
// ping advanced options
+11 -11
View File
@@ -1245,7 +1245,7 @@
<label for="system-service-name" class="form-label">{{ $t("Service Name") }}</label>
<input
id="system-service-name"
v-model="monitor.system_service_name"
v-model="monitor.config.system_service_name"
type="text"
class="form-control"
required
@@ -1256,29 +1256,29 @@
<template v-if="$root.info.runtime.platform === 'linux'">
{{
$t("systemServiceDescriptionLinux", {
service_name: monitor.system_service_name || "nginx",
service_name: monitor.config.system_service_name || "nginx",
})
}}
</template>
<template v-else-if="$root.info.runtime.platform === 'win32'">
{{
$t("systemServiceDescriptionWindows", {
service_name: monitor.system_service_name || "Dnscache",
service_name: monitor.config.system_service_name || "Dnscache",
})
}}
</template>
<template v-else>
{{
$t("systemServiceDescription", {
service_name: monitor.system_service_name || "nginx",
service_name: monitor.config.system_service_name || "nginx",
})
}}
</template>
<template
v-if="
!monitor.system_service_name ||
/^[a-zA-Z0-9_\-\.\@\ ]+$/.test(monitor.system_service_name)
!monitor.config.system_service_name ||
/^[a-zA-Z0-9_\-\.\@\ ]+$/.test(monitor.config.system_service_name)
"
>
<div v-if="$root.info.runtime.platform === 'linux'" class="mt-2">
@@ -1287,7 +1287,7 @@
<template #command>
<code>
systemctl is-active
{{ monitor.system_service_name || "nginx" }}
{{ monitor.config.system_service_name || "nginx" }}
</code>
</template>
</i18n-t>
@@ -1303,7 +1303,7 @@
<code>
(Get-Service -Name '{{
(
monitor.system_service_name || "Dnscache"
monitor.config.system_service_name || "Dnscache"
).replaceAll("'", "''")
}}').Status
</code>
@@ -3188,7 +3188,7 @@ const monitorDefaults = {
rabbitmqUsername: "",
rabbitmqPassword: "",
conditions: [],
system_service_name: "",
config: {},
};
export default {
@@ -3259,8 +3259,8 @@ export default {
if (this.monitor.hostname) {
return this.monitor.hostname;
}
if (this.monitor.system_service_name) {
return this.monitor.system_service_name;
if (this.monitor.config?.system_service_name) {
return this.monitor.config.system_service_name;
}
if (this.monitor.url) {
if (this.monitor.url !== "http://" && this.monitor.url !== "https://") {
+20 -12
View File
@@ -1,6 +1,7 @@
const { describe, test, beforeEach, afterEach } = require("node:test");
const assert = require("node:assert");
const { SystemServiceMonitorType } = require("../../server/monitor-types/system-service");
const { Monitor } = require("../../server/model/monitor");
const { DOWN, UP } = require("../../src/util");
const process = require("process");
const { execSync } = require("node:child_process");
@@ -27,6 +28,21 @@ function shouldSkip() {
}
}
/**
* Create a mock monitor with config support
* @param {string} serviceName The system service name
* @returns {object} Mock monitor object
*/
function createMockMonitor(serviceName) {
return Object.create(Monitor.prototype, {
config: {
value: JSON.stringify({ system_service_name: serviceName }),
writable: true,
enumerable: true,
},
});
}
describe("SystemServiceMonitorType", { skip: shouldSkip() }, () => {
let monitorType;
let heartbeat;
@@ -52,9 +68,7 @@ describe("SystemServiceMonitorType", { skip: shouldSkip() }, () => {
// Linux: 'dbus' or 'cron' are standard services.
const serviceName = process.platform === "win32" ? "Dnscache" : "dbus";
const monitor = {
system_service_name: serviceName,
};
const monitor = createMockMonitor(serviceName);
await monitorType.check(monitor, heartbeat);
@@ -63,9 +77,7 @@ describe("SystemServiceMonitorType", { skip: shouldSkip() }, () => {
});
test("check() returns DOWN for a stopped service", async () => {
const monitor = {
system_service_name: "non-existent-service-12345",
};
const monitor = createMockMonitor("non-existent-service-12345");
// Query a non-existent service to force an error/down state.
// We pass the promise directly to assert.rejects, avoiding unnecessary async wrappers.
@@ -81,9 +93,7 @@ describe("SystemServiceMonitorType", { skip: shouldSkip() }, () => {
configurable: true,
});
const monitor = {
system_service_name: "invalid&service;name",
};
const monitor = createMockMonitor("invalid&service;name");
// Expected validation error
await assert.rejects(monitorType.check(monitor, heartbeat));
@@ -98,9 +108,7 @@ describe("SystemServiceMonitorType", { skip: shouldSkip() }, () => {
configurable: true,
});
const monitor = {
system_service_name: "test-service",
};
const monitor = createMockMonitor("test-service");
await assert.rejects(monitorType.check(monitor, heartbeat), /not supported/);
});