diff --git a/server/monitor-types/system-service.js b/server/monitor-types/system-service.js index 05c18cd800c..65d2978688c 100644 --- a/server/monitor-types/system-service.js +++ b/server/monitor-types/system-service.js @@ -5,7 +5,12 @@ const { UP } = require("../../src/util"); class SystemServiceMonitorType extends MonitorType { name = "system-service"; - description = "Checks if a system service is running (systemd on Linux, Service Manager on Windows)."; + description = "Checks if a system service is running (systemd on Linux, Service Manager on Windows) or a PM2 process."; + + constructor() { + super(); + this.execFile = execFile; + } /** * Check the system service status. @@ -15,17 +20,153 @@ class SystemServiceMonitorType extends MonitorType { * @returns {Promise} Resolves when check is complete. */ async check(monitor, heartbeat) { - if (!monitor.system_service_name) { + const target = this.parseTarget(monitor.system_service_name); + + if (!target.name) { throw new Error("Service Name is required."); } - if (process.platform === "win32") { - return this.checkWindows(monitor.system_service_name, heartbeat); - } else if (process.platform === "linux") { - return this.checkLinux(monitor.system_service_name, heartbeat); + if (target.mode === "pm2") { + return this.checkPM2(target.name, heartbeat); + } + + if (target.platform === "win32") { + if (process.platform !== "win32") { + throw new Error("Selected platform Windows Server is not supported on this host."); + } + return this.checkWindows(target.name, heartbeat); + } else if (target.platform === "linux") { + if (process.platform !== "linux") { + throw new Error("Selected platform Linux is not supported on this host."); + } + return this.checkLinux(target.name, heartbeat); } else { - throw new Error(`System Service monitoring is not supported on ${process.platform}`); + throw new Error(`System Service monitoring is not supported on ${target.platform}`); + } + } + + /** + * Parse encoded monitor target. + * Supported formats: + * - pm2: + * - svc:: where platform is linux|win32 + * - (legacy; platform inferred from current host) + * @param {string} raw Raw monitor.system_service_name. + * @returns {{mode: "service" | "pm2", platform: string, name: string}} + */ + parseTarget(raw) { + const value = (raw || "").trim(); + + if (!value) { + return { + mode: "service", + platform: process.platform, + name: "", + }; + } + + if (value.toLowerCase().startsWith("pm2:")) { + return { + mode: "pm2", + platform: process.platform, + name: value.slice(4).trim(), + }; } + + const serviceWithPlatform = value.match(/^svc:(linux|win32):([\s\S]+)$/i); + if (serviceWithPlatform) { + return { + mode: "service", + platform: serviceWithPlatform[1].toLowerCase(), + name: serviceWithPlatform[2].trim(), + }; + } + + return { + mode: "service", + platform: process.platform, + name: value, + }; + } + + /** + * PM2 process check + * @param {string} processName PM2 process name or numeric id (after pm2: prefix). + * @param {object} heartbeat The heartbeat object. + * @returns {Promise} + */ + async checkPM2(processName, heartbeat) { + return new Promise((resolve, reject) => { + if (!processName || /[\u0000-\u001F\u007F]/.test(processName)) { + reject(new Error("Invalid PM2 process name.")); + return; + } + + const isWindows = process.platform === "win32"; + const command = isWindows ? (process.env.ComSpec || "cmd.exe") : "pm2"; + const args = isWindows ? ["/d", "/s", "/c", "pm2 jlist"] : ["jlist"]; + + this.execFile(command, args, { timeout: 5000 }, (error, stdout, stderr) => { + if (error) { + let output = (stderr || "").toString().trim(); + if (output.length > 200) { + output = output.substring(0, 200) + "..."; + } + const details = output || error.code || error.message; + reject(new Error(`Unable to query PM2 status (${details}). Ensure PM2 is installed and accessible.`)); + return; + } + + let processList; + try { + processList = JSON.parse((stdout || "").toString()); + } catch (parseError) { + reject(new Error("Unable to parse PM2 status output.")); + return; + } + + if (!Array.isArray(processList)) { + reject(new Error("Unexpected PM2 status output.")); + return; + } + + const entry = processList.find((item) => { + if (!item || typeof item !== "object") { + return false; + } + const itemName = item.name; + const itemId = item.pm_id; + return itemName === processName || String(itemId) === processName; + }); + + if (!entry) { + reject(new Error(`PM2 process '${processName}' was not found.`)); + return; + } + + const status = entry.pm2_env?.status?.toString().toLowerCase() || "unknown"; + + if (status === "online") { + heartbeat.status = UP; + heartbeat.msg = `PM2 process '${processName}' is online.`; + resolve(); + return; + } + + // Explicitly map stopped/errored states to down status. + if (status === "stopped" || status === "errored") { + reject(new Error(`PM2 process '${processName}' is ${status}.`)); + return; + } + + let output = (stderr || "").toString().trim(); + if (output.length > 200) { + output = output.substring(0, 200) + "..."; + } + + reject(new Error(output || `PM2 process '${processName}' is ${status}.`)); + }); + }); } /** @@ -43,7 +184,7 @@ class SystemServiceMonitorType extends MonitorType { return; } - execFile("systemctl", ["is-active", serviceName], { timeout: 5000 }, (error, stdout, stderr) => { + this.execFile("systemctl", ["is-active", serviceName], { timeout: 5000 }, (error, stdout, stderr) => { // Combine output and truncate to ~200 chars to prevent DB bloat let output = (stderr || stdout || "").toString().trim(); if (output.length > 200) { @@ -84,7 +225,7 @@ class SystemServiceMonitorType extends MonitorType { `(Get-Service -Name '${serviceName.replaceAll("'", "''")}').Status`, ]; - execFile(cmd, args, { timeout: 5000 }, (error, stdout, stderr) => { + this.execFile(cmd, args, { timeout: 5000 }, (error, stdout, stderr) => { let output = (stderr || stdout || "").toString().trim(); if (output.length > 200) { output = output.substring(0, 200) + "..."; diff --git a/server/socket-handlers/general-socket-handler.js b/server/socket-handlers/general-socket-handler.js index e879a4a065d..58667ebfb71 100644 --- a/server/socket-handlers/general-socket-handler.js +++ b/server/socket-handlers/general-socket-handler.js @@ -6,6 +6,8 @@ const { games } = require("gamedig"); const { testChrome } = require("../monitor-types/real-browser-monitor-type"); const fsAsync = require("fs").promises; const path = require("path"); +const process = require("process"); +const { execFile } = require("child_process"); /** * Get a game list via GameDig @@ -68,6 +70,63 @@ module.exports.generalSocketHandler = (socket, server) => { } }); + socket.on("getPM2ProcessList", async (callback) => { + try { + checkLogin(socket); + + const isWindows = process.platform === "win32"; + const command = isWindows ? (process.env.ComSpec || "cmd.exe") : "pm2"; + const args = isWindows ? ["/d", "/s", "/c", "pm2 jlist"] : ["jlist"]; + + execFile(command, args, { timeout: 5000 }, (error, stdout, stderr) => { + if (error) { + callback({ + ok: false, + msg: "Unable to query PM2 process list.", + }); + return; + } + + try { + const parsed = JSON.parse((stdout || "").toString()); + if (!Array.isArray(parsed)) { + throw new Error("Unexpected PM2 output"); + } + + const processList = parsed.map((item) => { + const id = item.pm_id != null ? String(item.pm_id) : (item.name || ""); + const name = item.name || id; + const status = item.pm2_env?.status || "unknown"; + return { + id, + name, + status, + }; + }).filter((item) => item.id !== ""); + + callback({ + ok: true, + processList, + }); + } catch (parseError) { + let output = (stderr || "").toString().trim(); + if (output.length > 200) { + output = output.substring(0, 200) + "..."; + } + callback({ + ok: false, + msg: output || "Unable to parse PM2 process list output.", + }); + } + }); + } catch (e) { + callback({ + ok: false, + msg: e.message, + }); + } + }); + socket.on("testChrome", (executable, callback) => { try { checkLogin(socket); diff --git a/src/pages/EditMonitor.vue b/src/pages/EditMonitor.vue index 3f3cf95f914..7b1efbfd2aa 100644 --- a/src/pages/EditMonitor.vue +++ b/src/pages/EditMonitor.vue @@ -52,7 +52,7 @@ " value="system-service" > - {{ $t("System Service") }} + {{ $t("System Service") }} / PM2