feat(system-service): add PM2 picker and platform selection (#7114)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Frank Elsinga <frank@elsinga.de>
This commit is contained in:
KraoESPfan1n
2026-07-13 17:13:11 -04:00
committed by GitHub
parent c673d17102
commit 590f90e3f9
9 changed files with 400 additions and 10 deletions
+16
View File
@@ -1692,6 +1692,22 @@ class Monitor extends BeanModel {
} }
} }
if (["system-service", "pm2"].includes(this.type)) {
this.system_service_name = (this.system_service_name || "").trim();
if (!this.system_service_name) {
throw new Error(this.type === "pm2" ? "PM2 process name is required." : "Service Name is required.");
}
}
if (this.type === "system-service" && !/^[a-zA-Z0-9._\-@]+$/.test(this.system_service_name)) {
throw new Error("Invalid service name. Please use the internal Service Name (no spaces).");
}
if (this.type === "pm2" && /[\u0000-\u001F\u007F]/.test(this.system_service_name)) {
throw new Error("Invalid PM2 process name.");
}
if (this.type === "ping") { if (this.type === "ping") {
// ping parameters validation // ping parameters validation
if (this.packetSize && (this.packetSize < PING_PACKET_SIZE_MIN || this.packetSize > PING_PACKET_SIZE_MAX)) { if (this.packetSize && (this.packetSize < PING_PACKET_SIZE_MIN || this.packetSize > PING_PACKET_SIZE_MAX)) {
+36
View File
@@ -0,0 +1,36 @@
const { MonitorType } = require("./monitor-type");
const { UP } = require("../../src/util");
const { getPM2ProcessList } = require("../util/pm2");
class PM2MonitorType extends MonitorType {
name = "pm2";
description = "Checks if a PM2 process is online.";
/**
* Check the PM2 process status.
* @param {object} monitor The monitor object containing monitor.system_service_name.
* @param {object} heartbeat The heartbeat object to update.
* @returns {Promise<void>}
*/
async check(monitor, heartbeat) {
const processName = (monitor.system_service_name || "").trim();
const processList = await getPM2ProcessList();
const entry = processList.find((item) => item.name === processName || item.id === processName);
if (!entry) {
throw new Error(`PM2 process '${processName}' was not found.`);
}
if (entry.status === "online") {
heartbeat.status = UP;
heartbeat.msg = `PM2 process '${processName}' is online.`;
return;
}
throw new Error(`PM2 process '${processName}' is ${entry.status}.`);
}
}
module.exports = {
PM2MonitorType,
};
+7 -6
View File
@@ -15,17 +15,19 @@ class SystemServiceMonitorType extends MonitorType {
* @returns {Promise<void>} Resolves when check is complete. * @returns {Promise<void>} Resolves when check is complete.
*/ */
async check(monitor, heartbeat) { async check(monitor, heartbeat) {
if (!monitor.system_service_name) { const serviceName = (monitor.system_service_name || "").trim();
if (!serviceName) {
throw new Error("Service Name is required."); throw new Error("Service Name is required.");
} }
if (process.platform === "win32") { if (process.platform === "win32") {
return this.checkWindows(monitor.system_service_name, heartbeat); return this.checkWindows(serviceName, heartbeat);
} else if (process.platform === "linux") { } 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}`);
} }
throw new Error(`System Service monitoring is not supported on ${process.platform}`);
} }
/** /**
@@ -80,7 +82,6 @@ class SystemServiceMonitorType extends MonitorType {
"-NoProfile", "-NoProfile",
"-NonInteractive", "-NonInteractive",
"-Command", "-Command",
// Single quotes around the service name
`(Get-Service -Name '${serviceName.replaceAll("'", "''")}').Status`, `(Get-Service -Name '${serviceName.replaceAll("'", "''")}').Status`,
]; ];
@@ -4,6 +4,7 @@ const { sendInfo } = require("../client");
const { checkLogin } = require("../util-server"); const { checkLogin } = require("../util-server");
const { games } = require("gamedig"); const { games } = require("gamedig");
const { testChrome } = require("../monitor-types/real-browser-monitor-type"); const { testChrome } = require("../monitor-types/real-browser-monitor-type");
const { getPM2ProcessList } = require("../util/pm2");
const fsAsync = require("fs").promises; const fsAsync = require("fs").promises;
const path = require("path"); const path = require("path");
@@ -68,6 +69,21 @@ module.exports.generalSocketHandler = (socket, server) => {
} }
}); });
socket.on("getPM2ProcessList", async (callback) => {
try {
checkLogin(socket);
callback({
ok: true,
processList: await getPM2ProcessList(),
});
} catch (e) {
callback({
ok: false,
msg: e.message,
});
}
});
socket.on("testChrome", (executable, callback) => { socket.on("testChrome", (executable, callback) => {
try { try {
checkLogin(socket); checkLogin(socket);
+2
View File
@@ -129,6 +129,7 @@ class UptimeKumaServer {
UptimeKumaServer.monitorTypeList["manual"] = new ManualMonitorType(); UptimeKumaServer.monitorTypeList["manual"] = new ManualMonitorType();
UptimeKumaServer.monitorTypeList["globalping"] = new GlobalpingMonitorType(this.getUserAgent()); UptimeKumaServer.monitorTypeList["globalping"] = new GlobalpingMonitorType(this.getUserAgent());
UptimeKumaServer.monitorTypeList["redis"] = new RedisMonitorType(); UptimeKumaServer.monitorTypeList["redis"] = new RedisMonitorType();
UptimeKumaServer.monitorTypeList["pm2"] = new PM2MonitorType();
UptimeKumaServer.monitorTypeList["system-service"] = new SystemServiceMonitorType(); UptimeKumaServer.monitorTypeList["system-service"] = new SystemServiceMonitorType();
UptimeKumaServer.monitorTypeList["sqlserver"] = new MssqlMonitorType(); UptimeKumaServer.monitorTypeList["sqlserver"] = new MssqlMonitorType();
UptimeKumaServer.monitorTypeList["mysql"] = new MysqlMonitorType(); UptimeKumaServer.monitorTypeList["mysql"] = new MysqlMonitorType();
@@ -582,6 +583,7 @@ const { TCPMonitorType } = require("./monitor-types/tcp.js");
const { ManualMonitorType } = require("./monitor-types/manual"); const { ManualMonitorType } = require("./monitor-types/manual");
const { GlobalpingMonitorType } = require("./monitor-types/globalping"); const { GlobalpingMonitorType } = require("./monitor-types/globalping");
const { RedisMonitorType } = require("./monitor-types/redis"); const { RedisMonitorType } = require("./monitor-types/redis");
const { PM2MonitorType } = require("./monitor-types/pm2");
const { SystemServiceMonitorType } = require("./monitor-types/system-service"); const { SystemServiceMonitorType } = require("./monitor-types/system-service");
const { MssqlMonitorType } = require("./monitor-types/mssql"); const { MssqlMonitorType } = require("./monitor-types/mssql");
const { MysqlMonitorType } = require("./monitor-types/mysql"); const { MysqlMonitorType } = require("./monitor-types/mysql");
+74
View File
@@ -0,0 +1,74 @@
const { execFile } = require("child_process");
const process = require("process");
const PM2_EXEC_OPTIONS = {
timeout: 5000,
maxBuffer: 10 * 1024 * 1024,
};
/**
* Truncate command output to keep error messages compact.
* @param {string | Buffer} output Command output.
* @returns {string} The truncated output text.
*/
function truncateOutput(output) {
const text = (output || "").toString().trim();
if (text.length > 200) {
return text.substring(0, 200) + "...";
}
return text;
}
/**
* Query PM2 for the current process list.
* @returns {Promise<{id: string, name: string, status: string}[]>} The normalized PM2 process list.
*/
function getPM2ProcessList() {
return new Promise((resolve, reject) => {
execFile(
process.platform === "win32" ? "pm2.cmd" : "pm2",
["jlist"],
PM2_EXEC_OPTIONS,
(error, stdout, stderr) => {
if (error) {
const details = truncateOutput(stderr) || error.code || error.message;
reject(new Error(`Unable to query PM2 process list (${details}).`));
return;
}
try {
const parsed = JSON.parse((stdout || "").toString());
if (!Array.isArray(parsed)) {
reject(new Error("Unexpected PM2 process list output."));
return;
}
resolve(
parsed
.map((item) => {
const id = item?.pm_id != null ? String(item.pm_id) : null;
const name = item?.name || null;
if (!id && !name) {
return null;
}
return {
id: id || name,
name: name || id,
status: item?.pm2_env?.status?.toString().toLowerCase() || "unknown",
};
})
.filter(Boolean)
);
} catch (parseError) {
reject(new Error(truncateOutput(stderr) || "Unable to parse PM2 process list output."));
}
}
);
});
}
module.exports = {
getPM2ProcessList,
};
+5 -1
View File
@@ -1181,6 +1181,8 @@
"systemServiceDescriptionWindows": "Checks if Windows Service Manager {service_name} is running", "systemServiceDescriptionWindows": "Checks if Windows Service Manager {service_name} is running",
"systemServiceCommandHint": "Command used: {command}", "systemServiceCommandHint": "Command used: {command}",
"systemServiceExpectedOutput": "Expected Output: \"{0}\"", "systemServiceExpectedOutput": "Expected Output: \"{0}\"",
"pm2Description": "Checks if PM2 process {service_name} is online",
"pm2ExpectedStates": "Expected state: online. States stopped and errored are treated as DOWN.",
"Browser Screenshot": "Browser Screenshot", "Browser Screenshot": "Browser Screenshot",
"Command": "Command", "Command": "Command",
"mongodbCommandDescription": "Run a MongoDB command against the database. For information about the available commands check out the {documentation}", "mongodbCommandDescription": "Run a MongoDB command against the database. For information about the available commands check out the {documentation}",
@@ -1513,7 +1515,9 @@
"GrafanaOncallURL": "Grafana Oncall URL", "GrafanaOncallURL": "Grafana Oncall URL",
"Never": "Never", "Never": "Never",
"Json Query": "Json Query", "Json Query": "Json Query",
"System Service": "System Service", "PM2 Process": "PM2 Process",
"Refresh": "Refresh",
"Select PM2 process": "Select PM2 process",
"SSL/TLS": "SSL/TLS", "SSL/TLS": "SSL/TLS",
"playground": "playground", "playground": "playground",
"Check Type": "Check Type", "Check Type": "Check Type",
+121 -3
View File
@@ -52,7 +52,10 @@
" "
value="system-service" value="system-service"
> >
{{ $t("System Service") }} {{ $t("systemService") }}
</option>
<option value="pm2">
{{ $t("PM2 Process") }}
</option> </option>
<option value="real-browser"> <option value="real-browser">
HTTP(s) - Browser Engine (Chrome/Chromium) (Beta) HTTP(s) - Browser Engine (Chrome/Chromium) (Beta)
@@ -1242,14 +1245,16 @@
<template v-if="monitor.type === 'system-service'"> <template v-if="monitor.type === 'system-service'">
<div class="my-3"> <div class="my-3">
<label for="system-service-name" class="form-label">{{ $t("Service Name") }}</label> <label for="system-service-name" class="form-label">
{{ $t("systemServiceName") }}
</label>
<input <input
id="system-service-name" id="system-service-name"
v-model="monitor.system_service_name" v-model="monitor.system_service_name"
type="text" type="text"
class="form-control" class="form-control"
required required
placeholder="nginx" :placeholder="$root.info.runtime.platform === 'win32' ? 'Dnscache' : 'nginx'"
/> />
<div class="form-text"> <div class="form-text">
@@ -1319,6 +1324,71 @@
</div> </div>
</template> </template>
<template v-if="monitor.type === 'pm2'">
<div class="my-3">
<div class="d-flex justify-content-between align-items-center">
<label for="pm2-process-name" class="form-label mb-0">
{{ $t("PM2 Process") }}
</label>
<button
class="btn btn-outline-secondary btn-sm"
type="button"
:disabled="pm2ProcessLoading"
@click="loadPM2ProcessList"
>
{{ pm2ProcessLoading ? $t("Loading...") : $t("Refresh") }}
</button>
</div>
<select
v-if="pm2ProcessOptions.length > 0"
id="pm2-process-name"
v-model="monitor.system_service_name"
class="form-select mt-2"
required
>
<option disabled value="">{{ $t("Select PM2 process") }}</option>
<!-- Save the PM2 name because PM2 can reassign numeric IDs after delete/recreate. -->
<option
v-for="item in pm2ProcessOptions"
:key="`${item.name}-${item.id}`"
:value="item.name"
>
{{ item.name }} (#{{ item.id }}) - {{ item.status }}
</option>
</select>
<input
v-else
id="pm2-process-name"
v-model="monitor.system_service_name"
type="text"
class="form-control mt-2"
placeholder="api"
required
/>
<div v-if="pm2ProcessError" class="text-danger small mt-2">
{{ pm2ProcessError }}
</div>
<div class="form-text">
{{
$t("pm2Description", {
service_name: monitor.system_service_name || "api",
})
}}
<div class="mt-2">
<i18n-t keypath="systemServiceCommandHint" tag="span">
<template #command>
<code>pm2 jlist</code>
</template>
</i18n-t>
</div>
<div class="text-secondary small">
{{ $t("pm2ExpectedStates") }}
</div>
</div>
</div>
</template>
<template v-if="monitor.type === 'mysql'"> <template v-if="monitor.type === 'mysql'">
<div class="my-3"> <div class="my-3">
<label for="mysql-password" class="form-label">{{ $t("Password") }}</label> <label for="mysql-password" class="form-label">{{ $t("Password") }}</label>
@@ -3246,6 +3316,9 @@ export default {
confirmed: false, confirmed: false,
editedValue: false, editedValue: false,
}, },
pm2ProcessOptions: [],
pm2ProcessLoading: false,
pm2ProcessError: "",
}; };
}, },
@@ -3596,6 +3669,10 @@ message HealthCheckResponse {
"monitor.type"(newType, oldType) { "monitor.type"(newType, oldType) {
this.checkDomain(); this.checkDomain();
if (newType === "pm2") {
this.loadPM2ProcessList();
}
if (newType === "globalping" && !this.monitor.subtype) { if (newType === "globalping" && !this.monitor.subtype) {
this.monitor.subtype = "ping"; this.monitor.subtype = "ping";
} }
@@ -3828,6 +3905,39 @@ message HealthCheckResponse {
this.kafkaSaslMechanismOptions = kafkaSaslMechanismOptions; this.kafkaSaslMechanismOptions = kafkaSaslMechanismOptions;
}, },
methods: { methods: {
loadPM2ProcessList() {
this.pm2ProcessLoading = true;
this.pm2ProcessError = "";
this.$root.getSocket().emit("getPM2ProcessList", (res) => {
this.pm2ProcessLoading = false;
if (!res.ok) {
this.pm2ProcessOptions = [];
this.pm2ProcessError = res.msg || "Unable to query PM2 process list.";
return;
}
this.pm2ProcessOptions = (res.processList || []).map((item) => {
return {
id: item.id,
name: item.name,
status: item.status,
};
});
const selectedProcess = this.pm2ProcessOptions.find(
(item) =>
item.id === this.monitor.system_service_name || item.name === this.monitor.system_service_name
);
// Convert a legacy numeric id to the stable process name without replacing a missing saved target.
if (selectedProcess) {
this.monitor.system_service_name = selectedProcess.name;
}
});
},
/** /**
* Initialize the edit monitor form * Initialize the edit monitor form
* @returns {void} * @returns {void}
@@ -3918,6 +4028,10 @@ message HealthCheckResponse {
this.monitor.timeout = ~~(this.monitor.interval * 8) / 10; this.monitor.timeout = ~~(this.monitor.interval * 8) / 10;
} }
} }
if (this.monitor.type === "pm2") {
this.loadPM2ProcessList();
}
} else { } else {
this.$root.toastError(res.msg); this.$root.toastError(res.msg);
} }
@@ -4137,6 +4251,10 @@ message HealthCheckResponse {
this.monitor.url = this.monitor.url.trim(); this.monitor.url = this.monitor.url.trim();
} }
if (["system-service", "pm2"].includes(this.monitor.type) && this.monitor.system_service_name) {
this.monitor.system_service_name = this.monitor.system_service_name.trim();
}
if (this.monitor.databaseConnectionString) { if (this.monitor.databaseConnectionString) {
this.monitor.databaseConnectionString = this.monitor.databaseConnectionString.trim(); this.monitor.databaseConnectionString = this.monitor.databaseConnectionString.trim();
} }
+123
View File
@@ -0,0 +1,123 @@
const { describe, test, beforeEach, afterEach } = require("node:test");
const assert = require("node:assert");
const childProcess = require("child_process");
const { DOWN, UP } = require("../../src/util");
/**
* Load the PM2 monitor after swapping child_process.execFile.
* @param {typeof childProcess.execFile} execFileStub Stub implementation.
* @returns {import("../../server/monitor-types/pm2").PM2MonitorType} A PM2 monitor instance wired to the stub.
*/
function createMonitorType(execFileStub) {
childProcess.execFile = execFileStub;
delete require.cache[require.resolve("../../server/util/pm2")];
delete require.cache[require.resolve("../../server/monitor-types/pm2")];
const { PM2MonitorType } = require("../../server/monitor-types/pm2");
return new PM2MonitorType();
}
describe("PM2MonitorType", () => {
let monitorType;
let heartbeat;
let originalExecFile;
beforeEach(() => {
monitorType = null;
heartbeat = {
status: DOWN,
msg: "",
};
originalExecFile = childProcess.execFile;
});
afterEach(() => {
childProcess.execFile = originalExecFile;
});
test("check() returns UP for an online PM2 process", async () => {
monitorType = createMonitorType((command, args, options, callback) => {
callback(
null,
JSON.stringify([
{
pm_id: 0,
name: "api",
pm2_env: {
status: "online",
},
},
]),
""
);
});
await monitorType.check(
{
system_service_name: "api",
},
heartbeat
);
assert.strictEqual(heartbeat.status, UP);
assert.ok(heartbeat.msg.includes("online"));
});
test("check() matches PM2 process by name when pm_id changes", async () => {
monitorType = createMonitorType((command, args, options, callback) => {
callback(
null,
JSON.stringify([
{
// PM2 may assign a different id when a process is deleted and recreated.
pm_id: 7,
name: "api",
pm2_env: {
status: "online",
},
},
]),
""
);
});
await monitorType.check(
{
system_service_name: "api",
},
heartbeat
);
assert.strictEqual(heartbeat.status, UP);
assert.ok(heartbeat.msg.includes("online"));
});
test("check() returns DOWN for a stopped PM2 process", async () => {
monitorType = createMonitorType((command, args, options, callback) => {
callback(
null,
JSON.stringify([
{
pm_id: 0,
name: "api",
pm2_env: {
status: "stopped",
},
},
]),
""
);
});
await assert.rejects(
monitorType.check(
{
system_service_name: "api",
},
heartbeat
),
/stopped/
);
assert.strictEqual(heartbeat.status, DOWN);
});
});