@jxa13/pm2ui 1.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +198 -0
- package/Services/pm2Service.js +91 -0
- package/Services/systemService.js +28 -0
- package/bin/pm2ui.js +3 -0
- package/changelog.md +250 -0
- package/frontend/dist/assets/index-BhYqcf4u.css +1 -0
- package/frontend/dist/assets/index-aVk9yHhV.js +224 -0
- package/frontend/dist/index.html +13 -0
- package/package.json +43 -0
- package/roadmap.md +170 -0
- package/server.js +889 -0
- package/user_manual.md +458 -0
package/server.js
ADDED
|
@@ -0,0 +1,889 @@
|
|
|
1
|
+
const express = require('express');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { execFile, spawn } = require('child_process');
|
|
6
|
+
const { promisify } = require('util');
|
|
7
|
+
const {
|
|
8
|
+
listProcesses,
|
|
9
|
+
startProcess,
|
|
10
|
+
stopProcess,
|
|
11
|
+
restartProcess
|
|
12
|
+
} = require('./Services/pm2Service');
|
|
13
|
+
const { getSystemStats } = require('./Services/systemService');
|
|
14
|
+
|
|
15
|
+
const app = express();
|
|
16
|
+
const PORT = process.env.PORT || 3000;
|
|
17
|
+
const DEFAULT_PROTECTED_PROCESS_NAMES = ['node-webui'];
|
|
18
|
+
const execFileAsync = promisify(execFile);
|
|
19
|
+
const reactDistPath = path.join(__dirname, 'frontend', 'dist');
|
|
20
|
+
const reactIndexPath = path.join(reactDistPath, 'index.html');
|
|
21
|
+
const pm2CliPath = (() => {
|
|
22
|
+
try {
|
|
23
|
+
return require.resolve('pm2/bin/pm2');
|
|
24
|
+
} catch (error) {
|
|
25
|
+
return 'pm2';
|
|
26
|
+
}
|
|
27
|
+
})();
|
|
28
|
+
|
|
29
|
+
app.use(express.json());
|
|
30
|
+
|
|
31
|
+
function getProtectedProcessNames() {
|
|
32
|
+
const rawValue = process.env.PROTECTED_PM2_PROCESSES;
|
|
33
|
+
const names = rawValue
|
|
34
|
+
? rawValue.split(',')
|
|
35
|
+
: DEFAULT_PROTECTED_PROCESS_NAMES;
|
|
36
|
+
|
|
37
|
+
return names
|
|
38
|
+
.map((name) => String(name || '').trim().toLowerCase())
|
|
39
|
+
.filter(Boolean);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function findProcessByTarget(processes, target) {
|
|
43
|
+
const normalizedTarget = String(target || '').trim();
|
|
44
|
+
const normalizedNameTarget = normalizedTarget.toLowerCase();
|
|
45
|
+
|
|
46
|
+
return processes.find((process) => {
|
|
47
|
+
return String(process.pm_id ?? '') === normalizedTarget
|
|
48
|
+
|| String(process.name || '').toLowerCase() === normalizedNameTarget;
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function assertProcessCanBeControlled(target, actionName) {
|
|
53
|
+
const processes = await listProcesses();
|
|
54
|
+
const process = findProcessByTarget(processes, target);
|
|
55
|
+
|
|
56
|
+
if (!process) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const protectedNames = getProtectedProcessNames();
|
|
61
|
+
const processName = String(process.name || '').toLowerCase();
|
|
62
|
+
|
|
63
|
+
if (protectedNames.includes(processName)) {
|
|
64
|
+
const error = new Error(`Protected process cannot be ${actionName}`);
|
|
65
|
+
error.statusCode = 403;
|
|
66
|
+
throw error;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function sendActionError(res, error, fallbackMessage) {
|
|
71
|
+
res.status(error.statusCode || 500).json({
|
|
72
|
+
ok: false,
|
|
73
|
+
error: error.message || fallbackMessage
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function getFileStatus(filePath) {
|
|
78
|
+
try {
|
|
79
|
+
const stats = fs.statSync(filePath);
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
exists: true,
|
|
83
|
+
path: filePath,
|
|
84
|
+
updatedAt: stats.mtime.toISOString()
|
|
85
|
+
};
|
|
86
|
+
} catch (error) {
|
|
87
|
+
return {
|
|
88
|
+
exists: false,
|
|
89
|
+
path: filePath,
|
|
90
|
+
updatedAt: null
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function readTextFile(filePath) {
|
|
96
|
+
try {
|
|
97
|
+
return fs.readFileSync(filePath, 'utf8').trim();
|
|
98
|
+
} catch (error) {
|
|
99
|
+
return '';
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function getCommandOutput(command, args) {
|
|
104
|
+
try {
|
|
105
|
+
const { stdout } = await execFileAsync(command, args);
|
|
106
|
+
return String(stdout || '').trim();
|
|
107
|
+
} catch (error) {
|
|
108
|
+
return '';
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function getPm2RuntimeStatus(processes = []) {
|
|
113
|
+
const homeDirectory = os.homedir();
|
|
114
|
+
const pm2Home = process.env.PM2_HOME || path.join(homeDirectory, '.pm2');
|
|
115
|
+
const userName = process.env.USER || process.env.LOGNAME || '';
|
|
116
|
+
const dumpFile = getFileStatus(path.join(pm2Home, 'dump.pm2'));
|
|
117
|
+
const daemonPidFile = getFileStatus(path.join(pm2Home, 'pm2.pid'));
|
|
118
|
+
const launchAgentFile = getFileStatus(path.join(
|
|
119
|
+
homeDirectory,
|
|
120
|
+
'Library',
|
|
121
|
+
'LaunchAgents',
|
|
122
|
+
userName ? `pm2.${userName}.plist` : 'pm2.plist'
|
|
123
|
+
));
|
|
124
|
+
const version = await getCommandOutput(pm2CliPath, ['-v']);
|
|
125
|
+
const daemonPid = daemonPidFile.exists ? readTextFile(daemonPidFile.path) : '';
|
|
126
|
+
const daemonUptime = daemonPid
|
|
127
|
+
? await getCommandOutput('ps', ['-p', daemonPid, '-o', 'etime='])
|
|
128
|
+
: '';
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
home: pm2Home,
|
|
132
|
+
version,
|
|
133
|
+
daemon: {
|
|
134
|
+
connected: true,
|
|
135
|
+
pid: daemonPid || '',
|
|
136
|
+
pidFile: daemonPidFile.path,
|
|
137
|
+
uptime: daemonUptime || '',
|
|
138
|
+
uptimeSource: daemonUptime ? 'ps etime for the PM2 daemon process' : ''
|
|
139
|
+
},
|
|
140
|
+
managedAppCount: processes.length,
|
|
141
|
+
saved: dumpFile.exists,
|
|
142
|
+
savedAt: dumpFile.updatedAt,
|
|
143
|
+
dumpFile,
|
|
144
|
+
startup: {
|
|
145
|
+
platform: process.platform,
|
|
146
|
+
configured: process.platform === 'darwin' ? launchAgentFile.exists : null,
|
|
147
|
+
checkedPath: process.platform === 'darwin' ? launchAgentFile.path : '',
|
|
148
|
+
note: process.platform === 'darwin'
|
|
149
|
+
? 'Detected by checking the PM2 LaunchAgent plist path.'
|
|
150
|
+
: 'Startup detection is only implemented for macOS LaunchAgent installs.'
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function parseProcessArgsInput(value) {
|
|
156
|
+
const args = [];
|
|
157
|
+
let current = '';
|
|
158
|
+
let quote = '';
|
|
159
|
+
let isEscaped = false;
|
|
160
|
+
|
|
161
|
+
for (const char of String(value || '')) {
|
|
162
|
+
if (isEscaped) {
|
|
163
|
+
current += char;
|
|
164
|
+
isEscaped = false;
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (char === '\\') {
|
|
169
|
+
isEscaped = true;
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (quote) {
|
|
174
|
+
if (char === quote) {
|
|
175
|
+
quote = '';
|
|
176
|
+
} else {
|
|
177
|
+
current += char;
|
|
178
|
+
}
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (char === '"' || char === "'") {
|
|
183
|
+
quote = char;
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (/\s/.test(char)) {
|
|
188
|
+
if (current) {
|
|
189
|
+
args.push(current);
|
|
190
|
+
current = '';
|
|
191
|
+
}
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
current += char;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (isEscaped) {
|
|
199
|
+
current += '\\';
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (quote) {
|
|
203
|
+
const error = new Error('Arguments contain an unmatched quote');
|
|
204
|
+
error.statusCode = 400;
|
|
205
|
+
throw error;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (current) {
|
|
209
|
+
args.push(current);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return args;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function validateOptionalPath(filePath, label) {
|
|
216
|
+
if (!filePath) {
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const parentDirectory = path.dirname(filePath);
|
|
221
|
+
|
|
222
|
+
if (!fs.existsSync(parentDirectory)) {
|
|
223
|
+
const error = new Error(`${label} parent directory does not exist`);
|
|
224
|
+
error.statusCode = 400;
|
|
225
|
+
throw error;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function normalizeInstances(value) {
|
|
230
|
+
const normalizedValue = String(value || '').trim().toLowerCase();
|
|
231
|
+
|
|
232
|
+
if (!normalizedValue) {
|
|
233
|
+
return '';
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (normalizedValue === 'max') {
|
|
237
|
+
return 'max';
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (!/^[1-9]\d*$/.test(normalizedValue)) {
|
|
241
|
+
const error = new Error('Instances must be a positive number or max');
|
|
242
|
+
error.statusCode = 400;
|
|
243
|
+
throw error;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return normalizedValue;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function normalizePm2Args(value) {
|
|
250
|
+
if (Array.isArray(value)) {
|
|
251
|
+
return value.map((item) => String(item));
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (value === undefined || value === null || value === '') {
|
|
255
|
+
return [];
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return [String(value)];
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function parsePm2LogText(rawText, type, target) {
|
|
262
|
+
const ansiRegex = /\u001b\[[0-9;]*m/g;
|
|
263
|
+
const prefixRegex = /^\d+\|[^|]+\|\s*/;
|
|
264
|
+
const timestampRegex = /^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\s*/;
|
|
265
|
+
|
|
266
|
+
return String(rawText || '')
|
|
267
|
+
.split(/\r?\n/)
|
|
268
|
+
.map((line) => line.replace(ansiRegex, '').trimEnd())
|
|
269
|
+
.filter(Boolean)
|
|
270
|
+
.map((line) => {
|
|
271
|
+
const timestampMatch = line.match(timestampRegex);
|
|
272
|
+
const timestamp = timestampMatch ? timestampMatch[1] : '';
|
|
273
|
+
const afterTimestamp = line.replace(timestampRegex, '').trimStart();
|
|
274
|
+
const withoutPrefix = afterTimestamp.replace(prefixRegex, '').trim();
|
|
275
|
+
const message = withoutPrefix;
|
|
276
|
+
|
|
277
|
+
return {
|
|
278
|
+
timestamp: timestamp || new Date().toLocaleString(),
|
|
279
|
+
type,
|
|
280
|
+
processId: null,
|
|
281
|
+
appName: target,
|
|
282
|
+
message: message || withoutPrefix
|
|
283
|
+
};
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function mapPm2Process(pm2Process) {
|
|
288
|
+
const env = pm2Process.pm2_env || {};
|
|
289
|
+
const processEnv = env.env || {};
|
|
290
|
+
|
|
291
|
+
return {
|
|
292
|
+
pmId: pm2Process.pm_id,
|
|
293
|
+
pid: pm2Process.pid,
|
|
294
|
+
name: pm2Process.name,
|
|
295
|
+
status: env.status || 'unknown',
|
|
296
|
+
cpu: pm2Process.monit?.cpu || 0,
|
|
297
|
+
memory: pm2Process.monit?.memory || 0,
|
|
298
|
+
uptime: env.pm_uptime || null,
|
|
299
|
+
startedAt: env.pm_uptime || null,
|
|
300
|
+
restartCount: env.restart_time || 0,
|
|
301
|
+
path: env.pm_exec_path || '',
|
|
302
|
+
cwd: env.pm_cwd || '',
|
|
303
|
+
interpreter: env.exec_interpreter || '',
|
|
304
|
+
execInterpreter: env.exec_interpreter || '',
|
|
305
|
+
execMode: env.exec_mode || '',
|
|
306
|
+
namespace: env.namespace || '',
|
|
307
|
+
args: normalizePm2Args(env.args),
|
|
308
|
+
nodeArgs: normalizePm2Args(env.node_args),
|
|
309
|
+
outLogPath: env.pm_out_log_path || '',
|
|
310
|
+
errorLogPath: env.pm_err_log_path || '',
|
|
311
|
+
pidPath: env.pm_pid_path || '',
|
|
312
|
+
pm2Home: env.pm2_home || process.env.PM2_HOME || path.join(os.homedir(), '.pm2'),
|
|
313
|
+
watch: Boolean(env.watch),
|
|
314
|
+
unstableRestarts: env.unstable_restarts || 0,
|
|
315
|
+
version: env.version || '',
|
|
316
|
+
nodeEnv: processEnv.NODE_ENV || env.NODE_ENV || '',
|
|
317
|
+
envName: env.env_name || processEnv.name || '',
|
|
318
|
+
nodeAppInstance: processEnv.NODE_APP_INSTANCE || env.NODE_APP_INSTANCE || '',
|
|
319
|
+
instances: env.instances ?? '',
|
|
320
|
+
autorestart: env.autorestart ?? '',
|
|
321
|
+
maxMemoryRestart: env.max_memory_restart || '',
|
|
322
|
+
createdAt: env.created_at || null
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async function getDashboardData() {
|
|
327
|
+
const [processes, systemStats] = await Promise.all([
|
|
328
|
+
listProcesses(),
|
|
329
|
+
getSystemStats()
|
|
330
|
+
]);
|
|
331
|
+
|
|
332
|
+
const totalPm2Cpu = processes.reduce((sum, process) => {
|
|
333
|
+
return sum + Number(process.monit?.cpu || 0);
|
|
334
|
+
}, 0);
|
|
335
|
+
|
|
336
|
+
const totalPm2MemoryBytes = processes.reduce((sum, process) => {
|
|
337
|
+
return sum + Number(process.monit?.memory || 0);
|
|
338
|
+
}, 0);
|
|
339
|
+
|
|
340
|
+
const totalSystemMemoryBytes = Number(systemStats?.memory?.total || 0);
|
|
341
|
+
const totalPm2MemoryPercent = totalSystemMemoryBytes > 0
|
|
342
|
+
? (totalPm2MemoryBytes / totalSystemMemoryBytes) * 100
|
|
343
|
+
: 0;
|
|
344
|
+
|
|
345
|
+
return {
|
|
346
|
+
ok: true,
|
|
347
|
+
summary: {
|
|
348
|
+
total: processes.length,
|
|
349
|
+
online: processes.filter((process) => process.pm2_env?.status === 'online').length,
|
|
350
|
+
stopped: processes.filter((process) => process.pm2_env?.status === 'stopped').length,
|
|
351
|
+
errored: processes.filter((process) => process.pm2_env?.status === 'errored').length
|
|
352
|
+
},
|
|
353
|
+
system: {
|
|
354
|
+
cpu: {
|
|
355
|
+
currentLoad: systemStats?.cpu?.currentLoad || 0
|
|
356
|
+
},
|
|
357
|
+
memory: {
|
|
358
|
+
usedPercent: systemStats?.memory?.usedPercent || 0,
|
|
359
|
+
usedBytes: systemStats?.memory?.used || 0
|
|
360
|
+
}
|
|
361
|
+
},
|
|
362
|
+
pm2Usage: {
|
|
363
|
+
cpu: {
|
|
364
|
+
currentLoad: totalPm2Cpu
|
|
365
|
+
},
|
|
366
|
+
memory: {
|
|
367
|
+
usedPercent: totalPm2MemoryPercent,
|
|
368
|
+
usedBytes: totalPm2MemoryBytes
|
|
369
|
+
}
|
|
370
|
+
},
|
|
371
|
+
pm2: await getPm2RuntimeStatus(processes),
|
|
372
|
+
processes: processes.map(mapPm2Process)
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
app.get('/api/health', (req, res) => {
|
|
377
|
+
res.json({
|
|
378
|
+
ok: true,
|
|
379
|
+
app: 'Node WebUI',
|
|
380
|
+
message: 'Server is running'
|
|
381
|
+
});
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
app.get('/api/dashboard', async (req, res) => {
|
|
385
|
+
try {
|
|
386
|
+
res.json(await getDashboardData());
|
|
387
|
+
} catch (error) {
|
|
388
|
+
console.error('Dashboard API error:', error);
|
|
389
|
+
res.status(500).json({
|
|
390
|
+
ok: false,
|
|
391
|
+
error: error.message || 'Failed to load dashboard data'
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
app.get('/api/dashboard/stream', async (req, res) => {
|
|
397
|
+
res.setHeader('Content-Type', 'text/event-stream');
|
|
398
|
+
res.setHeader('Cache-Control', 'no-cache, no-transform');
|
|
399
|
+
res.setHeader('Connection', 'keep-alive');
|
|
400
|
+
res.setHeader('X-Accel-Buffering', 'no');
|
|
401
|
+
res.flushHeaders?.();
|
|
402
|
+
res.write('retry: 5000\n\n');
|
|
403
|
+
|
|
404
|
+
let isClosed = false;
|
|
405
|
+
|
|
406
|
+
async function sendDashboardEvent() {
|
|
407
|
+
if (isClosed) {
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
try {
|
|
412
|
+
const data = await getDashboardData();
|
|
413
|
+
res.write(`data: ${JSON.stringify(data)}\n\n`);
|
|
414
|
+
} catch (error) {
|
|
415
|
+
console.error('Dashboard stream error:', error);
|
|
416
|
+
res.write(`event: dashboard-error\n`);
|
|
417
|
+
res.write(`data: ${JSON.stringify({ ok: false, error: error.message || 'Failed to stream dashboard data' })}\n\n`);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
await sendDashboardEvent();
|
|
422
|
+
const intervalId = setInterval(sendDashboardEvent, 5000);
|
|
423
|
+
|
|
424
|
+
req.on('close', () => {
|
|
425
|
+
isClosed = true;
|
|
426
|
+
clearInterval(intervalId);
|
|
427
|
+
});
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
app.get('/api/pm2/status', async (req, res) => {
|
|
431
|
+
try {
|
|
432
|
+
const processes = await listProcesses();
|
|
433
|
+
|
|
434
|
+
res.json({
|
|
435
|
+
ok: true,
|
|
436
|
+
pm2: await getPm2RuntimeStatus(processes)
|
|
437
|
+
});
|
|
438
|
+
} catch (error) {
|
|
439
|
+
console.error('PM2 status API error:', error);
|
|
440
|
+
res.status(500).json({
|
|
441
|
+
ok: false,
|
|
442
|
+
error: error.message || 'Failed to load PM2 status'
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
app.post('/api/pm2/save', async (req, res) => {
|
|
448
|
+
try {
|
|
449
|
+
execFile(pm2CliPath, ['save'], (error, stdout, stderr) => {
|
|
450
|
+
if (error) {
|
|
451
|
+
console.error('Save PM2 state API error:', error);
|
|
452
|
+
res.status(500).json({
|
|
453
|
+
ok: false,
|
|
454
|
+
error: stderr || error.message || 'Failed to save PM2 state'
|
|
455
|
+
});
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
res.json({
|
|
460
|
+
ok: true,
|
|
461
|
+
message: 'PM2 state saved',
|
|
462
|
+
output: stdout || '',
|
|
463
|
+
errors: stderr || ''
|
|
464
|
+
});
|
|
465
|
+
});
|
|
466
|
+
} catch (error) {
|
|
467
|
+
console.error('Save PM2 state API error:', error);
|
|
468
|
+
res.status(500).json({
|
|
469
|
+
ok: false,
|
|
470
|
+
error: error.message || 'Failed to save PM2 state'
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
app.post('/api/processes', async (req, res) => {
|
|
476
|
+
try {
|
|
477
|
+
const scriptPath = String(req.body?.scriptPath || '').trim();
|
|
478
|
+
const processName = String(req.body?.name || '').trim();
|
|
479
|
+
const cwd = String(req.body?.cwd || '').trim();
|
|
480
|
+
const argsInput = String(req.body?.args || '').trim();
|
|
481
|
+
const interpreter = String(req.body?.interpreter || '').trim();
|
|
482
|
+
const nodeEnv = String(req.body?.nodeEnv || '').trim();
|
|
483
|
+
const nodeArgsInput = String(req.body?.nodeArgs || '').trim();
|
|
484
|
+
const instances = normalizeInstances(req.body?.instances);
|
|
485
|
+
const maxMemoryRestart = String(req.body?.maxMemoryRestart || '').trim();
|
|
486
|
+
const watch = req.body?.watch === true || req.body?.watch === 'true';
|
|
487
|
+
const outLogPath = String(req.body?.outLogPath || '').trim();
|
|
488
|
+
const errorLogPath = String(req.body?.errorLogPath || '').trim();
|
|
489
|
+
|
|
490
|
+
if (!scriptPath) {
|
|
491
|
+
res.status(400).json({
|
|
492
|
+
ok: false,
|
|
493
|
+
error: 'Script path is required'
|
|
494
|
+
});
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
if (!processName) {
|
|
499
|
+
res.status(400).json({
|
|
500
|
+
ok: false,
|
|
501
|
+
error: 'Process name is required'
|
|
502
|
+
});
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
if (!cwd) {
|
|
507
|
+
res.status(400).json({
|
|
508
|
+
ok: false,
|
|
509
|
+
error: 'Working directory is required'
|
|
510
|
+
});
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
if (!fs.existsSync(scriptPath)) {
|
|
515
|
+
res.status(400).json({
|
|
516
|
+
ok: false,
|
|
517
|
+
error: 'Script path does not exist'
|
|
518
|
+
});
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
if (!fs.existsSync(cwd)) {
|
|
523
|
+
res.status(400).json({
|
|
524
|
+
ok: false,
|
|
525
|
+
error: 'Working directory does not exist'
|
|
526
|
+
});
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
validateOptionalPath(outLogPath, 'Output log path');
|
|
531
|
+
validateOptionalPath(errorLogPath, 'Error log path');
|
|
532
|
+
|
|
533
|
+
const parsedArgs = parseProcessArgsInput(argsInput);
|
|
534
|
+
const parsedNodeArgs = parseProcessArgsInput(nodeArgsInput);
|
|
535
|
+
const pm2Args = ['start', scriptPath, '--name', processName, '--cwd', cwd];
|
|
536
|
+
|
|
537
|
+
if (interpreter) {
|
|
538
|
+
pm2Args.push('--interpreter', interpreter);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
if (parsedNodeArgs.length > 0) {
|
|
542
|
+
pm2Args.push('--node-args', parsedNodeArgs.join(' '));
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
if (instances) {
|
|
546
|
+
pm2Args.push('--instances', instances);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
if (watch) {
|
|
550
|
+
pm2Args.push('--watch');
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
if (maxMemoryRestart) {
|
|
554
|
+
pm2Args.push('--max-memory-restart', maxMemoryRestart);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
if (outLogPath) {
|
|
558
|
+
pm2Args.push('--output', outLogPath);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
if (errorLogPath) {
|
|
562
|
+
pm2Args.push('--error', errorLogPath);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
if (parsedArgs.length > 0) {
|
|
566
|
+
pm2Args.push('--', ...parsedArgs);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
execFile(pm2CliPath, pm2Args, {
|
|
570
|
+
env: {
|
|
571
|
+
...process.env,
|
|
572
|
+
...(nodeEnv ? { NODE_ENV: nodeEnv } : {})
|
|
573
|
+
}
|
|
574
|
+
}, (error, stdout, stderr) => {
|
|
575
|
+
if (error) {
|
|
576
|
+
console.error('Create process API error:', error);
|
|
577
|
+
res.status(500).json({
|
|
578
|
+
ok: false,
|
|
579
|
+
error: stderr || error.message || 'Failed to create process'
|
|
580
|
+
});
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
res.json({
|
|
585
|
+
ok: true,
|
|
586
|
+
message: `Created process: ${processName}`,
|
|
587
|
+
output: stdout || '',
|
|
588
|
+
errors: stderr || ''
|
|
589
|
+
});
|
|
590
|
+
});
|
|
591
|
+
} catch (error) {
|
|
592
|
+
console.error('Create process API error:', error);
|
|
593
|
+
sendActionError(res, error, 'Failed to create process');
|
|
594
|
+
}
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
app.delete('/api/processes/:target', async (req, res) => {
|
|
598
|
+
try {
|
|
599
|
+
const target = String(req.params.target || '').trim();
|
|
600
|
+
|
|
601
|
+
if (!target) {
|
|
602
|
+
res.status(400).json({
|
|
603
|
+
ok: false,
|
|
604
|
+
error: 'PM2 target is required'
|
|
605
|
+
});
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
await assertProcessCanBeControlled(target, 'deleted');
|
|
610
|
+
|
|
611
|
+
execFile(pm2CliPath, ['delete', target], (error, stdout, stderr) => {
|
|
612
|
+
if (error) {
|
|
613
|
+
console.error('Delete process API error:', error);
|
|
614
|
+
res.status(500).json({
|
|
615
|
+
ok: false,
|
|
616
|
+
error: stderr || error.message || 'Failed to delete process'
|
|
617
|
+
});
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
res.json({
|
|
622
|
+
ok: true,
|
|
623
|
+
message: `Deleted process PM2 ID: ${target}`,
|
|
624
|
+
output: stdout || '',
|
|
625
|
+
errors: stderr || ''
|
|
626
|
+
});
|
|
627
|
+
});
|
|
628
|
+
} catch (error) {
|
|
629
|
+
console.error('Delete process API error:', error);
|
|
630
|
+
sendActionError(res, error, 'Failed to delete process');
|
|
631
|
+
}
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
app.post('/api/processes/:target/start', async (req, res) => {
|
|
635
|
+
try {
|
|
636
|
+
const target = String(req.params.target || '');
|
|
637
|
+
await startProcess(target);
|
|
638
|
+
res.json({
|
|
639
|
+
ok: true,
|
|
640
|
+
message: `Started process PM2 ID: ${target}`
|
|
641
|
+
});
|
|
642
|
+
} catch (error) {
|
|
643
|
+
console.error('Start process API error:', error);
|
|
644
|
+
res.status(500).json({
|
|
645
|
+
ok: false,
|
|
646
|
+
error: error.message || 'Failed to start process'
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
app.post('/api/processes/:target/stop', async (req, res) => {
|
|
652
|
+
try {
|
|
653
|
+
const target = String(req.params.target || '');
|
|
654
|
+
await assertProcessCanBeControlled(target, 'stopped');
|
|
655
|
+
await stopProcess(target);
|
|
656
|
+
res.json({
|
|
657
|
+
ok: true,
|
|
658
|
+
message: `Stopped process PM2 ID: ${target}`
|
|
659
|
+
});
|
|
660
|
+
} catch (error) {
|
|
661
|
+
console.error('Stop process API error:', error);
|
|
662
|
+
sendActionError(res, error, 'Failed to stop process');
|
|
663
|
+
}
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
app.post('/api/processes/:target/restart', async (req, res) => {
|
|
667
|
+
try {
|
|
668
|
+
const target = String(req.params.target || '');
|
|
669
|
+
await assertProcessCanBeControlled(target, 'restarted');
|
|
670
|
+
await restartProcess(target);
|
|
671
|
+
res.json({
|
|
672
|
+
ok: true,
|
|
673
|
+
message: `Restarted process PM2 ID: ${target}`
|
|
674
|
+
});
|
|
675
|
+
} catch (error) {
|
|
676
|
+
console.error('Restart process API error:', error);
|
|
677
|
+
sendActionError(res, error, 'Failed to restart process');
|
|
678
|
+
}
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
app.get('/api/processes/:target/logs', async (req, res) => {
|
|
682
|
+
try {
|
|
683
|
+
const target = String(req.params.target || '');
|
|
684
|
+
const requestedLines = Number(req.query.lines || 100);
|
|
685
|
+
const lines = Number.isFinite(requestedLines)
|
|
686
|
+
? Math.min(Math.max(Math.trunc(requestedLines), 1), 500)
|
|
687
|
+
: 100;
|
|
688
|
+
|
|
689
|
+
execFile(pm2CliPath, ['logs', target, '--nostream', '--lines', String(lines), '--timestamp', 'YYYY-MM-DD HH:mm:ss'], {
|
|
690
|
+
maxBuffer: 1024 * 1024 * 10
|
|
691
|
+
}, (error, stdout, stderr) => {
|
|
692
|
+
if (error && !stdout && !stderr) {
|
|
693
|
+
console.error('Logs API error:', error);
|
|
694
|
+
res.status(500).json({
|
|
695
|
+
ok: false,
|
|
696
|
+
error: error.message || 'Failed to load logs'
|
|
697
|
+
});
|
|
698
|
+
return;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
const entries = [
|
|
702
|
+
...parsePm2LogText(stdout, 'stdout', target),
|
|
703
|
+
...parsePm2LogText(stderr, 'stderr', target)
|
|
704
|
+
];
|
|
705
|
+
|
|
706
|
+
res.json({
|
|
707
|
+
ok: true,
|
|
708
|
+
target,
|
|
709
|
+
entries
|
|
710
|
+
});
|
|
711
|
+
});
|
|
712
|
+
} catch (error) {
|
|
713
|
+
console.error('Logs API error:', error);
|
|
714
|
+
res.status(500).json({
|
|
715
|
+
ok: false,
|
|
716
|
+
error: error.message || 'Failed to load logs'
|
|
717
|
+
});
|
|
718
|
+
}
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
app.get('/api/processes/:target/logs/stream', async (req, res) => {
|
|
722
|
+
const target = String(req.params.target || '');
|
|
723
|
+
|
|
724
|
+
res.setHeader('Content-Type', 'text/event-stream');
|
|
725
|
+
res.setHeader('Cache-Control', 'no-cache, no-transform');
|
|
726
|
+
res.setHeader('Connection', 'keep-alive');
|
|
727
|
+
res.setHeader('X-Accel-Buffering', 'no');
|
|
728
|
+
res.flushHeaders?.();
|
|
729
|
+
res.write('retry: 5000\n\n');
|
|
730
|
+
|
|
731
|
+
let isClosed = false;
|
|
732
|
+
const logProcess = spawn(pm2CliPath, ['logs', target, '--lines', '0', '--timestamp', 'YYYY-MM-DD HH:mm:ss'], {
|
|
733
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
734
|
+
});
|
|
735
|
+
const buffers = {
|
|
736
|
+
stdout: '',
|
|
737
|
+
stderr: ''
|
|
738
|
+
};
|
|
739
|
+
|
|
740
|
+
function writeEntries(rawText, type) {
|
|
741
|
+
if (isClosed) {
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
const entries = parsePm2LogText(rawText, type, target);
|
|
746
|
+
|
|
747
|
+
entries.forEach((entry) => {
|
|
748
|
+
res.write(`data: ${JSON.stringify(entry)}\n\n`);
|
|
749
|
+
});
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
function handleChunk(type, chunk) {
|
|
753
|
+
buffers[type] += chunk.toString('utf8');
|
|
754
|
+
const lines = buffers[type].split(/\r?\n/);
|
|
755
|
+
buffers[type] = lines.pop() || '';
|
|
756
|
+
writeEntries(lines.join('\n'), type);
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
const heartbeatIntervalId = setInterval(() => {
|
|
760
|
+
if (isClosed) {
|
|
761
|
+
return;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
res.write(`event: heartbeat\n`);
|
|
765
|
+
res.write(`data: ${JSON.stringify({ ok: true })}\n\n`);
|
|
766
|
+
}, 15000);
|
|
767
|
+
|
|
768
|
+
logProcess.stdout.on('data', (chunk) => {
|
|
769
|
+
handleChunk('stdout', chunk);
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
logProcess.stderr.on('data', (chunk) => {
|
|
773
|
+
handleChunk('stderr', chunk);
|
|
774
|
+
});
|
|
775
|
+
|
|
776
|
+
logProcess.on('error', (error) => {
|
|
777
|
+
console.error('Logs stream API error:', error);
|
|
778
|
+
if (isClosed) {
|
|
779
|
+
return;
|
|
780
|
+
}
|
|
781
|
+
res.write(`event: logs-error\n`);
|
|
782
|
+
res.write(`data: ${JSON.stringify({ ok: false, error: error.message || 'Failed to stream logs' })}\n\n`);
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
logProcess.on('close', () => {
|
|
786
|
+
writeEntries(buffers.stdout, 'stdout');
|
|
787
|
+
writeEntries(buffers.stderr, 'stderr');
|
|
788
|
+
clearInterval(heartbeatIntervalId);
|
|
789
|
+
if (!isClosed) {
|
|
790
|
+
res.end();
|
|
791
|
+
}
|
|
792
|
+
});
|
|
793
|
+
|
|
794
|
+
req.on('close', () => {
|
|
795
|
+
isClosed = true;
|
|
796
|
+
clearInterval(heartbeatIntervalId);
|
|
797
|
+
logProcess.kill();
|
|
798
|
+
});
|
|
799
|
+
});
|
|
800
|
+
|
|
801
|
+
app.post('/api/processes/:target/logs/clear', async (req, res) => {
|
|
802
|
+
try {
|
|
803
|
+
const target = String(req.params.target || '');
|
|
804
|
+
|
|
805
|
+
execFile(pm2CliPath, ['flush', target], (error, stdout, stderr) => {
|
|
806
|
+
if (error) {
|
|
807
|
+
console.error('Clear logs API error:', error);
|
|
808
|
+
res.status(500).json({
|
|
809
|
+
ok: false,
|
|
810
|
+
error: stderr || error.message || 'Failed to clear logs'
|
|
811
|
+
});
|
|
812
|
+
return;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
res.json({
|
|
816
|
+
ok: true,
|
|
817
|
+
target,
|
|
818
|
+
message: `Cleared logs for ${target}`,
|
|
819
|
+
output: stdout || '',
|
|
820
|
+
errors: stderr || ''
|
|
821
|
+
});
|
|
822
|
+
});
|
|
823
|
+
} catch (error) {
|
|
824
|
+
console.error('Clear logs API error:', error);
|
|
825
|
+
res.status(500).json({
|
|
826
|
+
ok: false,
|
|
827
|
+
error: error.message || 'Failed to clear logs'
|
|
828
|
+
});
|
|
829
|
+
}
|
|
830
|
+
});
|
|
831
|
+
|
|
832
|
+
app.post('/api/open-folder', async (req, res) => {
|
|
833
|
+
try {
|
|
834
|
+
const targetPath = req.body?.path;
|
|
835
|
+
|
|
836
|
+
if (!targetPath) {
|
|
837
|
+
res.status(400).json({
|
|
838
|
+
ok: false,
|
|
839
|
+
error: 'Path is required'
|
|
840
|
+
});
|
|
841
|
+
return;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
execFile('open', ['-R', targetPath], (error) => {
|
|
845
|
+
if (error) {
|
|
846
|
+
console.error('Open folder API error:', error);
|
|
847
|
+
res.status(500).json({
|
|
848
|
+
ok: false,
|
|
849
|
+
error: error.message || 'Failed to open path'
|
|
850
|
+
});
|
|
851
|
+
return;
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
res.json({
|
|
855
|
+
ok: true,
|
|
856
|
+
message: `Opened path: ${targetPath}`
|
|
857
|
+
});
|
|
858
|
+
});
|
|
859
|
+
} catch (error) {
|
|
860
|
+
console.error('Open folder API error:', error);
|
|
861
|
+
res.status(500).json({
|
|
862
|
+
ok: false,
|
|
863
|
+
error: error.message || 'Failed to open path'
|
|
864
|
+
});
|
|
865
|
+
}
|
|
866
|
+
});
|
|
867
|
+
|
|
868
|
+
if (fs.existsSync(reactIndexPath)) {
|
|
869
|
+
app.use('/react', express.static(reactDistPath));
|
|
870
|
+
app.use(express.static(reactDistPath));
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
app.use((req, res, next) => {
|
|
874
|
+
if (req.method !== 'GET' || req.path.startsWith('/api/')) {
|
|
875
|
+
next();
|
|
876
|
+
return;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
if (!fs.existsSync(reactIndexPath)) {
|
|
880
|
+
res.status(503).send('Node WebUI React build is missing. Run `npm run react:build` and restart the server.');
|
|
881
|
+
return;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
res.sendFile(reactIndexPath);
|
|
885
|
+
});
|
|
886
|
+
|
|
887
|
+
app.listen(PORT, '127.0.0.1', () => {
|
|
888
|
+
console.log(`Node WebUI running at http://127.0.0.1:${PORT}`);
|
|
889
|
+
});
|