@jxa13/pm2ui 1.17.1 → 1.18.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/server.js CHANGED
@@ -1,11 +1,16 @@
1
1
  const express = require('express');
2
+ const crypto = require('crypto');
2
3
  const fs = require('fs');
3
4
  const os = require('os');
4
5
  const path = require('path');
5
- const { execFile, spawn } = require('child_process');
6
- const { promisify } = require('util');
6
+ const { execFile } = require('child_process');
7
7
  const {
8
+ connectLogBus,
9
+ createProcess,
10
+ deleteProcess,
11
+ flushProcessLogs,
8
12
  listProcesses,
13
+ savePm2State,
9
14
  startProcess,
10
15
  stopProcess,
11
16
  restartProcess
@@ -15,19 +20,64 @@ const { getSystemStats } = require('./Services/systemService');
15
20
  const app = express();
16
21
  const PORT = process.env.PORT || 3210;
17
22
  const DEFAULT_PROTECTED_PROCESS_NAMES = ['node-webui'];
18
- const execFileAsync = promisify(execFile);
23
+ const UNSAFE_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
24
+ const apiSessionToken = crypto.randomBytes(32).toString('hex');
19
25
  const reactDistPath = path.join(__dirname, 'frontend', 'dist');
20
26
  const reactIndexPath = path.join(reactDistPath, 'index.html');
21
- const pm2CliPath = (() => {
27
+ const pm2PackageVersion = (() => {
22
28
  try {
23
- return require.resolve('pm2/bin/pm2');
29
+ return require('pm2/package.json').version;
24
30
  } catch (error) {
25
- return 'pm2';
31
+ return '';
26
32
  }
27
33
  })();
28
34
 
29
35
  app.use(express.json());
30
36
 
37
+ function isSameOriginRequest(req) {
38
+ const origin = req.get('origin');
39
+
40
+ if (!origin) {
41
+ return true;
42
+ }
43
+
44
+ try {
45
+ const originUrl = new URL(origin);
46
+ return originUrl.host === req.get('host');
47
+ } catch (error) {
48
+ return false;
49
+ }
50
+ }
51
+
52
+ function hasValidApiSessionToken(req) {
53
+ const token = String(req.get('X-PM2UI-Token') || '');
54
+ const expectedToken = Buffer.from(apiSessionToken);
55
+ const receivedToken = Buffer.from(token);
56
+
57
+ if (!token || receivedToken.length !== expectedToken.length) {
58
+ return false;
59
+ }
60
+
61
+ return crypto.timingSafeEqual(receivedToken, expectedToken);
62
+ }
63
+
64
+ app.use((req, res, next) => {
65
+ if (!req.path.startsWith('/api/') || !UNSAFE_METHODS.has(req.method)) {
66
+ next();
67
+ return;
68
+ }
69
+
70
+ if (!isSameOriginRequest(req) || !hasValidApiSessionToken(req)) {
71
+ res.status(403).json({
72
+ ok: false,
73
+ error: 'Invalid API session'
74
+ });
75
+ return;
76
+ }
77
+
78
+ next();
79
+ });
80
+
31
81
  function getProtectedProcessNames() {
32
82
  const rawValue = process.env.PROTECTED_PM2_PROCESSES;
33
83
  const names = rawValue
@@ -100,15 +150,6 @@ function readTextFile(filePath) {
100
150
  }
101
151
  }
102
152
 
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
153
  async function getPm2RuntimeStatus(processes = []) {
113
154
  const homeDirectory = os.homedir();
114
155
  const pm2Home = process.env.PM2_HOME || path.join(homeDirectory, '.pm2');
@@ -121,21 +162,17 @@ async function getPm2RuntimeStatus(processes = []) {
121
162
  'LaunchAgents',
122
163
  userName ? `pm2.${userName}.plist` : 'pm2.plist'
123
164
  ));
124
- const version = await getCommandOutput(pm2CliPath, ['-v']);
125
165
  const daemonPid = daemonPidFile.exists ? readTextFile(daemonPidFile.path) : '';
126
- const daemonUptime = daemonPid
127
- ? await getCommandOutput('ps', ['-p', daemonPid, '-o', 'etime='])
128
- : '';
129
166
 
130
167
  return {
131
168
  home: pm2Home,
132
- version,
169
+ version: pm2PackageVersion,
133
170
  daemon: {
134
171
  connected: true,
135
172
  pid: daemonPid || '',
136
173
  pidFile: daemonPidFile.path,
137
- uptime: daemonUptime || '',
138
- uptimeSource: daemonUptime ? 'ps etime for the PM2 daemon process' : ''
174
+ uptime: '',
175
+ uptimeSource: ''
139
176
  },
140
177
  managedAppCount: processes.length,
141
178
  saved: dumpFile.exists,
@@ -258,10 +295,11 @@ function normalizePm2Args(value) {
258
295
  return [String(value)];
259
296
  }
260
297
 
261
- function parsePm2LogText(rawText, type, target) {
298
+ function parsePm2LogText(rawText, type, target, options = {}) {
262
299
  const ansiRegex = /\u001b\[[0-9;]*m/g;
263
300
  const prefixRegex = /^\d+\|[^|]+\|\s*/;
264
301
  const timestampRegex = /^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\s*/;
302
+ const fallbackTimestamp = options.timestamp || new Date().toLocaleString();
265
303
 
266
304
  return String(rawText || '')
267
305
  .split(/\r?\n/)
@@ -275,15 +313,79 @@ function parsePm2LogText(rawText, type, target) {
275
313
  const message = withoutPrefix;
276
314
 
277
315
  return {
278
- timestamp: timestamp || new Date().toLocaleString(),
316
+ timestamp: timestamp || fallbackTimestamp,
279
317
  type,
280
- processId: null,
318
+ processId: options.processId ?? null,
281
319
  appName: target,
282
320
  message: message || withoutPrefix
283
321
  };
284
322
  });
285
323
  }
286
324
 
325
+ async function readTailTextFile(filePath, maxBytes = 1024 * 1024 * 10) {
326
+ if (!filePath) {
327
+ return '';
328
+ }
329
+
330
+ try {
331
+ const stats = await fs.promises.stat(filePath);
332
+ const length = Math.min(stats.size, maxBytes);
333
+ const start = Math.max(stats.size - length, 0);
334
+ const handle = await fs.promises.open(filePath, 'r');
335
+
336
+ try {
337
+ const buffer = Buffer.alloc(length);
338
+ await handle.read(buffer, 0, length, start);
339
+ return buffer.toString('utf8');
340
+ } finally {
341
+ await handle.close();
342
+ }
343
+ } catch (error) {
344
+ if (error.code !== 'ENOENT') {
345
+ console.error('Read PM2 log file error:', error);
346
+ }
347
+
348
+ return '';
349
+ }
350
+ }
351
+
352
+ function getPm2ProcessLogFiles(process) {
353
+ const env = process?.pm2_env || {};
354
+
355
+ return [
356
+ { type: 'stdout', path: env.pm_out_log_path || '' },
357
+ { type: 'stderr', path: env.pm_err_log_path || '' }
358
+ ];
359
+ }
360
+
361
+ async function getLogEntriesForTarget(target, lineCount) {
362
+ const processes = await listProcesses();
363
+ const process = findProcessByTarget(processes, target);
364
+
365
+ if (!process) {
366
+ const error = new Error('PM2 target was not found');
367
+ error.statusCode = 404;
368
+ throw error;
369
+ }
370
+
371
+ const entriesByFile = await Promise.all(
372
+ getPm2ProcessLogFiles(process).map(async (file) => {
373
+ const rawText = await readTailTextFile(file.path);
374
+ return parsePm2LogText(rawText, file.type, process.name || target, {
375
+ processId: process.pm_id
376
+ });
377
+ })
378
+ );
379
+
380
+ return entriesByFile
381
+ .flat()
382
+ .slice(-lineCount);
383
+ }
384
+
385
+ function getLiveLogType(type) {
386
+ return type === 'err' ? 'stderr' : 'stdout';
387
+ }
388
+
287
389
  function mapPm2Process(pm2Process) {
288
390
  const env = pm2Process.pm2_env || {};
289
391
  const processEnv = env.env || {};
@@ -381,6 +483,13 @@ app.get('/api/health', (req, res) => {
381
483
  });
382
484
  });
383
485
 
486
+ app.get('/api/session', (req, res) => {
487
+ res.json({
488
+ ok: true,
489
+ token: apiSessionToken
490
+ });
491
+ });
492
+
384
493
  app.get('/api/dashboard', async (req, res) => {
385
494
  try {
386
495
  res.json(await getDashboardData());
@@ -446,22 +555,12 @@ app.get('/api/pm2/status', async (req, res) => {
446
555
 
447
556
  app.post('/api/pm2/save', async (req, res) => {
448
557
  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
- });
558
+ await savePm2State();
559
+ res.json({
560
+ ok: true,
561
+ message: 'PM2 state saved',
562
+ output: '',
563
+ errors: ''
465
564
  });
466
565
  } catch (error) {
467
566
  console.error('Save PM2 state API error:', error);
@@ -532,61 +631,28 @@ app.post('/api/processes', async (req, res) => {
532
631
 
533
632
  const parsedArgs = parseProcessArgsInput(argsInput);
534
633
  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, {
634
+ await createProcess({
635
+ script: scriptPath,
636
+ name: processName,
637
+ cwd,
638
+ ...(interpreter ? { interpreter } : {}),
639
+ ...(parsedNodeArgs.length > 0 ? { node_args: parsedNodeArgs } : {}),
640
+ ...(instances ? { instances } : {}),
641
+ ...(watch ? { watch: true } : {}),
642
+ ...(maxMemoryRestart ? { max_memory_restart: maxMemoryRestart } : {}),
643
+ ...(outLogPath ? { output: outLogPath } : {}),
644
+ ...(errorLogPath ? { error: errorLogPath } : {}),
645
+ ...(parsedArgs.length > 0 ? { args: parsedArgs } : {}),
570
646
  env: {
571
647
  ...process.env,
572
648
  ...(nodeEnv ? { NODE_ENV: nodeEnv } : {})
573
649
  }
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
- });
650
+ });
651
+ res.json({
652
+ ok: true,
653
+ message: `Created process: ${processName}`,
654
+ output: '',
655
+ errors: ''
590
656
  });
591
657
  } catch (error) {
592
658
  console.error('Create process API error:', error);
@@ -607,23 +673,12 @@ app.delete('/api/processes/:target', async (req, res) => {
607
673
  }
608
674
 
609
675
  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
- });
676
+ await deleteProcess(target);
677
+ res.json({
678
+ ok: true,
679
+ message: `Deleted process PM2 ID: ${target}`,
680
+ output: '',
681
+ errors: ''
627
682
  });
628
683
  } catch (error) {
629
684
  console.error('Delete process API error:', error);
@@ -686,32 +741,14 @@ app.get('/api/processes/:target/logs', async (req, res) => {
686
741
  ? Math.min(Math.max(Math.trunc(requestedLines), 1), 500)
687
742
  : 100;
688
743
 
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
- });
744
+ res.json({
745
+ ok: true,
746
+ target,
747
+ entries: await getLogEntriesForTarget(target, lines)
711
748
  });
712
749
  } catch (error) {
713
750
  console.error('Logs API error:', error);
714
- res.status(500).json({
751
+ res.status(error.statusCode || 500).json({
715
752
  ok: false,
716
753
  error: error.message || 'Failed to load logs'
717
754
  });
@@ -729,13 +766,7 @@ app.get('/api/processes/:target/logs/stream', async (req, res) => {
729
766
  res.write('retry: 5000\n\n');
730
767
 
731
768
  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
- };
769
+ let logBus = null;
739
770
 
740
771
  function writeEntries(rawText, type) {
741
772
  if (isClosed) {
@@ -749,13 +780,6 @@ app.get('/api/processes/:target/logs/stream', async (req, res) => {
749
780
  });
750
781
  }
751
782
 
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
783
  const heartbeatIntervalId = setInterval(() => {
760
784
  if (isClosed) {
761
785
  return;
@@ -765,60 +789,55 @@ app.get('/api/processes/:target/logs/stream', async (req, res) => {
765
789
  res.write(`data: ${JSON.stringify({ ok: true })}\n\n`);
766
790
  }, 15000);
767
791
 
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) => {
792
+ function handleStreamError(error) {
777
793
  console.error('Logs stream API error:', error);
778
794
  if (isClosed) {
779
795
  return;
780
796
  }
781
797
  res.write(`event: logs-error\n`);
782
798
  res.write(`data: ${JSON.stringify({ ok: false, error: error.message || 'Failed to stream logs' })}\n\n`);
783
- });
799
+ }
784
800
 
785
- logProcess.on('close', () => {
786
- writeEntries(buffers.stdout, 'stdout');
787
- writeEntries(buffers.stderr, 'stderr');
788
- clearInterval(heartbeatIntervalId);
789
- if (!isClosed) {
790
- res.end();
801
+ try {
802
+ const processes = await listProcesses();
803
+ const process = findProcessByTarget(processes, target);
804
+
805
+ if (!process) {
806
+ throw Object.assign(new Error('PM2 target was not found'), { statusCode: 404 });
791
807
  }
792
- });
808
+
809
+ logBus = await connectLogBus();
810
+ logBus.bus.on('log:*', (type, packet) => {
811
+ const packetProcess = packet?.process || {};
812
+ const normalizedTarget = String(target || '').trim();
813
+
814
+ if (String(packetProcess.pm_id ?? '') !== normalizedTarget && String(packetProcess.name || '') !== normalizedTarget) {
815
+ return;
816
+ }
817
+
818
+ writeEntries(String(packet?.data || ''), getLiveLogType(type));
819
+ });
820
+ } catch (error) {
821
+ handleStreamError(error);
822
+ }
793
823
 
794
824
  req.on('close', () => {
795
825
  isClosed = true;
796
826
  clearInterval(heartbeatIntervalId);
797
- logProcess.kill();
827
+ logBus?.close();
798
828
  });
799
829
  });
800
830
 
801
831
  app.post('/api/processes/:target/logs/clear', async (req, res) => {
802
832
  try {
803
833
  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
- });
834
+ await flushProcessLogs(target);
835
+ res.json({
836
+ ok: true,
837
+ target,
838
+ message: `Cleared logs for ${target}`,
839
+ output: '',
840
+ errors: ''
822
841
  });
823
842
  } catch (error) {
824
843
  console.error('Clear logs API error:', error);
package/user_manual.md CHANGED
@@ -21,10 +21,9 @@ Node WebUI is a local web dashboard for monitoring and managing Node.js services
21
21
  - macOS
22
22
  - Node.js 18 or newer
23
23
  - npm
24
- - PM2 installed and available on your shell path
25
- - One or more PM2-managed processes, unless you only want to create a process from the UI
24
+ - One or more PM2-managed processes, unless you only want to create one from the UI
26
25
 
27
- Install PM2 globally if it is not installed:
26
+ Node WebUI includes PM2 as a package dependency for dashboard operations. Install PM2 globally only if you want to use the `pm2` command in your terminal:
28
27
 
29
28
  ```bash
30
29
  npm install -g pm2
@@ -52,6 +51,8 @@ Install dependencies:
52
51
  npm install
53
52
  ```
54
53
 
54
+ This installs both runtime dependencies and development dependencies needed to rebuild the React frontend. The published npm package ships the built frontend bundle for normal CLI use.
55
+
55
56
  Start Node WebUI:
56
57
 
57
58
  ```bash
@@ -212,6 +213,8 @@ Per-process action buttons:
212
213
 
213
214
  Some actions require confirmation before they run.
214
215
 
216
+ Process actions, create, delete, save, log clearing, and Finder reveal are sent through Node WebUI's local API with a same-origin session token. If the page was loaded from the Node WebUI server, this is handled automatically.
217
+
215
218
  ## Bulk Actions
216
219
 
217
220
  Select one or more rows using the table checkboxes. The bulk action bar supports:
@@ -255,6 +258,8 @@ The logs workspace supports:
255
258
 
256
259
  The row-level log action opens logs for that specific process.
257
260
 
261
+ Recent log history is read from the selected process stdout and stderr log files reported by PM2. Live log updates use PM2's Node API event bus. If a PM2 log file is missing or has been rotated away, that stream may show no recent entries until the process writes new output.
262
+
258
263
  ## Create PM2 App
259
264
 
260
265
  Click `Create PM2 App` to register a new PM2 process.
@@ -407,7 +412,7 @@ npm start
407
412
 
408
413
  ### Process actions fail
409
414
 
410
- Check that PM2 is installed and available:
415
+ Check PM2 from the same shell/user that starts Node WebUI:
411
416
 
412
417
  ```bash
413
418
  pm2 -v
@@ -421,6 +426,8 @@ pm2 list
421
426
 
422
427
  If the process is protected, remove it from the protected list or adjust `PROTECTED_PM2_PROCESSES`.
423
428
 
429
+ If the browser tab has been open across a Node WebUI restart, refresh the page so it fetches a fresh local API session token.
430
+
424
431
  ### Open folder does not work
425
432
 
426
433
  Open folder is designed for macOS Finder. It requires the process to have a valid script path or working directory.
@@ -456,3 +463,5 @@ Node WebUI is designed for local use. The server binds to:
456
463
  ```
457
464
 
458
465
  Do not expose it directly to the public internet. If you need remote access, put it behind authentication and a reverse proxy.
466
+
467
+ Unsafe API methods require a same-origin session token generated by the local server. This reduces the risk of another website triggering local PM2 actions from your browser, but it is not full authentication and should not be treated as protection for LAN or internet exposure.