Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
159 changes: 150 additions & 9 deletions server/monitor-types/system-service.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,12 @@

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() {

Check failure on line 10 in server/monitor-types/system-service.js

View workflow job for this annotation

GitHub Actions / check-linters

Missing JSDoc comment

Check failure on line 10 in server/monitor-types/system-service.js

View workflow job for this annotation

GitHub Actions / check-linters

Missing JSDoc comment

Check failure on line 10 in server/monitor-types/system-service.js

View workflow job for this annotation

GitHub Actions / check-linters

Missing JSDoc comment
super();
this.execFile = execFile;
}

/**
* Check the system service status.
Expand All @@ -15,17 +20,153 @@
* @returns {Promise<void>} 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:<processNameOrId>
* - svc:<platform>:<serviceName> where platform is linux|win32
* - <serviceName> (legacy; platform inferred from current host)
* @param {string} raw Raw monitor.system_service_name.
* @returns {{mode: "service" | "pm2", platform: string, name: string}}

Check warning on line 55 in server/monitor-types/system-service.js

View workflow job for this annotation

GitHub Actions / check-linters

Missing JSDoc @returns description

Check warning on line 55 in server/monitor-types/system-service.js

View workflow job for this annotation

GitHub Actions / check-linters

Missing JSDoc @returns description

Check warning on line 55 in server/monitor-types/system-service.js

View workflow job for this annotation

GitHub Actions / check-linters

Missing JSDoc @returns description
*/
parseTarget(raw) {
const value = (raw || "").trim();

Check warning on line 58 in server/monitor-types/system-service.js

View workflow job for this annotation

GitHub Actions / autofix

Missing JSDoc @returns description

Check warning on line 58 in server/monitor-types/system-service.js

View workflow job for this annotation

GitHub Actions / autofix

Missing JSDoc @returns description

Check warning on line 58 in server/monitor-types/system-service.js

View workflow job for this annotation

GitHub Actions / autofix

Missing JSDoc @returns description

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<void>}
*/
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}.`));
});
});
}

/**
Expand All @@ -43,7 +184,7 @@
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) {
Expand Down Expand Up @@ -84,7 +225,7 @@
`(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) + "...";
Expand Down
59 changes: 59 additions & 0 deletions server/socket-handlers/general-socket-handler.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading