mirror of
https://github.com/louislam/uptime-kuma.git
synced 2026-08-07 09:14:58 +00:00
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:
@@ -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") {
|
||||
// ping parameters validation
|
||||
if (this.packetSize && (this.packetSize < PING_PACKET_SIZE_MIN || this.packetSize > PING_PACKET_SIZE_MAX)) {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -15,17 +15,19 @@ class SystemServiceMonitorType extends MonitorType {
|
||||
* @returns {Promise<void>} Resolves when check is complete.
|
||||
*/
|
||||
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.");
|
||||
}
|
||||
|
||||
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);
|
||||
} else {
|
||||
throw new Error(`System Service monitoring is not supported on ${process.platform}`);
|
||||
return this.checkLinux(serviceName, heartbeat);
|
||||
}
|
||||
|
||||
throw new Error(`System Service monitoring is not supported on ${process.platform}`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -80,7 +82,6 @@ class SystemServiceMonitorType extends MonitorType {
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-Command",
|
||||
// Single quotes around the service name
|
||||
`(Get-Service -Name '${serviceName.replaceAll("'", "''")}').Status`,
|
||||
];
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ const { sendInfo } = require("../client");
|
||||
const { checkLogin } = require("../util-server");
|
||||
const { games } = require("gamedig");
|
||||
const { testChrome } = require("../monitor-types/real-browser-monitor-type");
|
||||
const { getPM2ProcessList } = require("../util/pm2");
|
||||
const fsAsync = require("fs").promises;
|
||||
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) => {
|
||||
try {
|
||||
checkLogin(socket);
|
||||
|
||||
@@ -129,6 +129,7 @@ class UptimeKumaServer {
|
||||
UptimeKumaServer.monitorTypeList["manual"] = new ManualMonitorType();
|
||||
UptimeKumaServer.monitorTypeList["globalping"] = new GlobalpingMonitorType(this.getUserAgent());
|
||||
UptimeKumaServer.monitorTypeList["redis"] = new RedisMonitorType();
|
||||
UptimeKumaServer.monitorTypeList["pm2"] = new PM2MonitorType();
|
||||
UptimeKumaServer.monitorTypeList["system-service"] = new SystemServiceMonitorType();
|
||||
UptimeKumaServer.monitorTypeList["sqlserver"] = new MssqlMonitorType();
|
||||
UptimeKumaServer.monitorTypeList["mysql"] = new MysqlMonitorType();
|
||||
@@ -582,6 +583,7 @@ const { TCPMonitorType } = require("./monitor-types/tcp.js");
|
||||
const { ManualMonitorType } = require("./monitor-types/manual");
|
||||
const { GlobalpingMonitorType } = require("./monitor-types/globalping");
|
||||
const { RedisMonitorType } = require("./monitor-types/redis");
|
||||
const { PM2MonitorType } = require("./monitor-types/pm2");
|
||||
const { SystemServiceMonitorType } = require("./monitor-types/system-service");
|
||||
const { MssqlMonitorType } = require("./monitor-types/mssql");
|
||||
const { MysqlMonitorType } = require("./monitor-types/mysql");
|
||||
|
||||
@@ -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
@@ -1181,6 +1181,8 @@
|
||||
"systemServiceDescriptionWindows": "Checks if Windows Service Manager {service_name} is running",
|
||||
"systemServiceCommandHint": "Command used: {command}",
|
||||
"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",
|
||||
"Command": "Command",
|
||||
"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",
|
||||
"Never": "Never",
|
||||
"Json Query": "Json Query",
|
||||
"System Service": "System Service",
|
||||
"PM2 Process": "PM2 Process",
|
||||
"Refresh": "Refresh",
|
||||
"Select PM2 process": "Select PM2 process",
|
||||
"SSL/TLS": "SSL/TLS",
|
||||
"playground": "playground",
|
||||
"Check Type": "Check Type",
|
||||
|
||||
+121
-3
@@ -52,7 +52,10 @@
|
||||
"
|
||||
value="system-service"
|
||||
>
|
||||
{{ $t("System Service") }}
|
||||
{{ $t("systemService") }}
|
||||
</option>
|
||||
<option value="pm2">
|
||||
{{ $t("PM2 Process") }}
|
||||
</option>
|
||||
<option value="real-browser">
|
||||
HTTP(s) - Browser Engine (Chrome/Chromium) (Beta)
|
||||
@@ -1242,14 +1245,16 @@
|
||||
|
||||
<template v-if="monitor.type === 'system-service'">
|
||||
<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
|
||||
id="system-service-name"
|
||||
v-model="monitor.system_service_name"
|
||||
type="text"
|
||||
class="form-control"
|
||||
required
|
||||
placeholder="nginx"
|
||||
:placeholder="$root.info.runtime.platform === 'win32' ? 'Dnscache' : 'nginx'"
|
||||
/>
|
||||
|
||||
<div class="form-text">
|
||||
@@ -1319,6 +1324,71 @@
|
||||
</div>
|
||||
</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'">
|
||||
<div class="my-3">
|
||||
<label for="mysql-password" class="form-label">{{ $t("Password") }}</label>
|
||||
@@ -3246,6 +3316,9 @@ export default {
|
||||
confirmed: false,
|
||||
editedValue: false,
|
||||
},
|
||||
pm2ProcessOptions: [],
|
||||
pm2ProcessLoading: false,
|
||||
pm2ProcessError: "",
|
||||
};
|
||||
},
|
||||
|
||||
@@ -3596,6 +3669,10 @@ message HealthCheckResponse {
|
||||
"monitor.type"(newType, oldType) {
|
||||
this.checkDomain();
|
||||
|
||||
if (newType === "pm2") {
|
||||
this.loadPM2ProcessList();
|
||||
}
|
||||
|
||||
if (newType === "globalping" && !this.monitor.subtype) {
|
||||
this.monitor.subtype = "ping";
|
||||
}
|
||||
@@ -3828,6 +3905,39 @@ message HealthCheckResponse {
|
||||
this.kafkaSaslMechanismOptions = kafkaSaslMechanismOptions;
|
||||
},
|
||||
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
|
||||
* @returns {void}
|
||||
@@ -3918,6 +4028,10 @@ message HealthCheckResponse {
|
||||
this.monitor.timeout = ~~(this.monitor.interval * 8) / 10;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.monitor.type === "pm2") {
|
||||
this.loadPM2ProcessList();
|
||||
}
|
||||
} else {
|
||||
this.$root.toastError(res.msg);
|
||||
}
|
||||
@@ -4137,6 +4251,10 @@ message HealthCheckResponse {
|
||||
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) {
|
||||
this.monitor.databaseConnectionString = this.monitor.databaseConnectionString.trim();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user