PM2をブラウザから管理する

npmのpm2-guiが動かなくなっているような気がするので、自作しました。
ブラウザからpm2で管理するプロセスに対して以下ができるようにします。

・プロセスの一覧表示
・プロセスの詳細表示
・プロセスを起動・停止
・out/errのログ参照・ファイル取得

 このようなWebページを作ります。

 ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/c9286b95-0f78-248d-0751-329b3c6e1d03.png)

ソースコードもろもろはGitHubに上げておきました。

poruruba/pm2_console

https://github.com/poruruba/pm2_console

# pm2のプロセスの構成

pm2の定義ファイルには管理したいアプリのプロセスの定義に加えて、今回作成するプロセス管理用のアプリも定義しておきます。
プロセス管理用のアプリと、管理したいプロセスのアプリを1つにすると、プロセスを停止した時点でプロセス管理用のアプリも停止してしまうためです。

PM2の設定の詳細は以下を参考にしてください。この投稿のうち、pm2-guiの代わりが今回の投稿です。
　[pm2でNode.js実行環境を整備する](https://qiita.com/poruruba/items/10df0d94e9127797498f)

pm2.config.js は以下のように作成しました。
xxxxはOSのユーザ名です。

```js:/home/xxxx/pm2/pm2.config.js
module.exports = {
        apps: [
                {
                        name: "PM2_Console(40081)",
                        script: "./app.js",
                        cwd: "/home/xxxx/projects/node/pm2_console"
                },
                                {
                        name: "Public_Sites(40080)",
                        script: "./app.js",
                        cwd: "/home/xxxx/projects/node/public_sites"
                }
        ]
};
```

PM2_Console(40081)がプロセス管理用のアプリ、Public_Sites(40080)が管理対象のアプリです。
PM2自体の起動はsystemdで起動するようにしました。
今回はnode.jsのバージョンを18.19.0にしました。

```:/etc/system/system/pm2-xxxx.service
[Unit]
Description=PM2 process manager
Documentation=https://pm2.keymetrics.io/
After=network.target

[Service]
Type=forking
User=xxxx
LimitNOFILE=infinity
LimitNPROC=infinity
LimitCORE=infinity
Environment=PATH=/home/xxxx/.nvm/versions/node/v18.19.0/bin:/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin
Environment=PM2_HOME=/home/xxxx/.pm2
PIDFile=/home/xxxx/.pm2/pm2.pid

ExecStart=/home/xxxx/.nvm/versions/node/v18.19.0/bin/pm2 start /home/xxxx/pm2/pm2.config.js
ExecReload=/home/xxxx/.nvm/versions/node/v18.19.0/bin/pm2 reload /home/xxxx/pm2/pm2.config.js
ExecStop=/home/xxxx/.nvm/versions/node/v18.19.0/bin/pm2 kill

[Install]
WantedBy=multi-user.target
```

# WebAPIサーバ側

主に表示はSPAでJavascriptで実施し、PM2の操作はWebAPIサーバで実施しています。
サーバ側では以下のエンドポイントを用意しています。

* /pm2-proc-list
管理しているプロセスのリストを取得します。

*  /pm2-proc-restart
プロセスを再起動します。

*  /pm2-proc-stop
プロセスを停止します。

*  /pm2-proc-describe
プロセスの詳細情報を取得します。

*  /pm2-view-log
プロセスのログの一部を取得します。

*  /pm2-get-log
プロセスのログをファイルとして取得します。

以下、Swaggerです。

```js: pm2_console\api\controllers\pm2-api\swagger.yaml
paths:
  /pm2-proc-list:
    post:
      parameters:
        - in: body
          name: body
          schema:
            type: object
      responses:
        200:
          description: Success
          schema:
            type: object

  /pm2-proc-restart:
    post:
      parameters:
        - in: body
          name: body
          schema:
            type: object
      responses:
        200:
          description: Success
          schema:
            type: object

  /pm2-proc-stop:
    post:
      parameters:
        - in: body
          name: body
          schema:
            type: object
      responses:
        200:
          description: Success
          schema:
            type: object

  /pm2-proc-describe:
    post:
      parameters:
        - in: body
          name: body
          schema:
            type: object
      responses:
        200:
          description: Success
          schema:
            type: object

  /pm2-view-log:
    post:
      produces:
        - text/plain
      parameters:
        - in: body
          name: body
          schema:
            type: object
      responses:
        200:
          description: Success
          schema:
            type: object

  /pm2-get-log:
    post:
      produces:
        - application/zip
      parameters:
        - in: body
          name: body
          schema:
            type: object
      responses:
        200:
          description: Success
          schema:
            type: object
```

サーバ側のソースコードはこんな感じです。

```js:pm2_console\api\controllers\pm2-api\index.js
'use strict';

const HELPER_BASE = process.env.HELPER_BASE || "/opt/";
const Response = require(HELPER_BASE + 'response');
const BinResponse = require(HELPER_BASE + 'binresponse');
const TextResponse = require(HELPER_BASE + 'textresponse');

const pm2 = require('pm2');
const { exec } = require('child_process');
const streamBuffers = require('stream-buffers');
const archiver = require('archiver');
const path = require('path');

exports.handler = async (event, context, callback) => {
	var body = JSON.parse(event.body);
	console.log(body);

	if( event.path == '/pm2-proc-list' ){
		return new Promise((resolve, reject) =>{
				pm2.connect(err =>{
						if( err ){
							pm2.disconnect();
							return reject(err);
						}

						pm2.list((err, processDescriptionList) =>{
								if( err )
										return reject(err);

								pm2.disconnect();
								resolve(new Response({ list: processDescriptionList }));
						})
				});
		});
	}else

	if( event.path == "/pm2-proc-restart" ){
		var pm2_id = body.pm2_id;

		return new Promise((resolve, reject) =>{
				pm2.connect(err =>{
						if( err ){
								pm2.disconnect();
								return reject(err);
						}

						pm2.restart(pm2_id, (err) =>{
								if( err )
										return reject(err);

								resolve(new Response({}));
						})
				});
		});

	}else if( event.path == "/pm2-proc-stop" ){
		var pm2_id = body.pm2_id;

		return new Promise((resolve, reject) =>{
				pm2.connect(err =>{
						if( err ){
								pm2.disconnect();
								return reject(err);
						}

						pm2.stop(pm2_id, (err) =>{
								if( err )
										return reject(err);

								pm2.disconnect();
								resolve(new Response({}));
						})
				});
		});

	}else if( event.path == "/pm2-proc-describe" ){
		var pm2_id = body.pm2_id;

		var desc = await pm2_describe(pm2_id);
		return new Response({ desc: desc });
	}else

	if( event.path == '/pm2-view-log'){
		// if (!event.requestContext.apikeyAuth || event.requestContext.apikeyAuth.apikey != APIKEY )
		// 	throw "wrong apikey";

		var pm2_id = body.pm2_id;
		var type = body.type;

		var desc = await pm2_describe(pm2_id);
		var logfname = (type == 'error') ? desc.pm2_env.pm_err_log_path : desc.pm2_env.pm_out_log_path;
		console.log(logfname);

		var num = body.num;
		return new Promise((resolve, reject) =>{
			var exec_batch;
			if (body.order == 'head'){
				exec_batch = `cat ${logfname} | head -n ${num} | sed -r "s/\\x1B\\[([0-9]{1,2}(;[0-9]{1,2})*)?m//g" | col -bx`;
			} else if (body.order == 'tail'){
				exec_batch = `cat ${logfname} | tail -n ${num} | sed -r "s/\\x1B\\[([0-9]{1,2}(;[0-9]{1,2})*)?m//g" | col -bx`;
			}else{
				reject('unknown order');
			}
			exec(exec_batch, (err, stdout, stderr) => {
				if (err) {
					reject(err);
					return;
				}
				resolve(new TextResponse("text/plain", stdout));
			});
		});
	}else
	if( event.path == '/pm2-get-log'){
		// if (!event.requestContext.apikeyAuth || event.requestContext.apikeyAuth.apikey != APIKEY )
		// 	throw "wrong apikey";

		var pm2_id = body.pm2_id;
		var type = body.type;

		var desc = await pm2_describe(pm2_id);
		var logfname = (type == 'error') ? desc.pm2_env.pm_err_log_path : desc.pm2_env.pm_out_log_path;
		console.log(logfname);

		return new Promise((resolve, reject) =>{
			var dest_stream = new streamBuffers.WritableStreamBuffer();
			const archive = archiver('zip', {
				zlib: { level: 9 }
			});
			dest_stream.on('finish', () => {
				console.log('stream finish');
				var response = new BinResponse('application/zip', dest_stream.getContents());
				response.set_filename(path.basename(logfname) + '.zip');
				resolve(response);
			});

			archive.pipe(dest_stream);
			archive.on('error', (err) => {
				reject(err);
			});

			archive.file(logfname, { name: path.basename(logfname) });
			archive.finalize();
		});
	}else{
		throw "unknown endpoint";
	}
}

async function pm2_describe(pm2_id){
	return new Promise((resolve, reject) =>{
		pm2.connect(err =>{
				if( err ){
						pm2.disconnect();
						return reject(err);
				}

				pm2.describe(pm2_id, (err, processDescription) =>{
						if( err )
								return reject(err);

						pm2.disconnect();
						resolve(processDescription[0]);
				})
		});
	});
}
```

クライアント側は、単にWebAPIを呼び出しているだけです

```js:pm2_console\public\js\start.js
'use strict';

//const vConsole = new VConsole();
//const remoteConsole = new RemoteConsole("http://[remote server]/logio-post");
//window.datgui = new dat.GUI();

const PM2_URL_BASE = "";

var vue_options = {
    el: "#top",
    mixins: [mixins_bootstrap],
    store: vue_store,
    router: vue_router,
    data: {
        proc_list: [],
        proc_detail: null,

        log_pm2_name: "",
        log_pm2_id: 0,
        log_order: "tail",
        log_num: 100,
        log_content: "",
        log_type: "out",
    },
    computed: {
    },
    methods: {
        start_proc_view_log: function(pm2_id, pm2_name){
            this.log_pm2_name = pm2_name;
            this.log_pm2_id = pm2_id;
            this.dialog_open("#proc-log");
        },
        proc_view_log: async function(){
            var result = await do_post_text(PM2_URL_BASE + '/pm2-view-log', { pm2_id: this.log_pm2_id, type: this.log_type, order: this.log_order, num: this.log_num });
            this.log_content = result;
        },
        proc_get_log: async function(){
            var blob = await do_post_blob(PM2_URL_BASE + '/pm2-get-log', { pm2_id: this.log_pm2_id, type: this.log_type });
            console.log(blob);

            var url = window.URL.createObjectURL(blob);
            var a = document.createElement('a');
            a.href = url;
            a.target = '_blank';
            a.download = "download.zip";
            a.click();
            window.URL.revokeObjectURL(url);
        },
        proc_list_update: async function(){
            var result = await do_post(PM2_URL_BASE + '/pm2-proc-list');
            console.log(result);
            this.proc_list = result.list;
        },
        proc_restart: async function(pm2_id){
            var result = await do_post(PM2_URL_BASE + '/pm2-proc-restart', { pm2_id: pm2_id });
            console.log(result);
            alert("再起動しました。");
            await this.proc_list_update();
        },
        proc_stop: async function(pm2_id){
            var result = await do_post(PM2_URL_BASE + '/pm2-proc-stop', { pm2_id: pm2_id });
            console.log(result);
            alert('停止しました。');
            await this.proc_list_update();
        },
        proc_describe: async function(pm2_id){
            var result = await do_post(PM2_URL_BASE + '/pm2-proc-describe', { pm2_id: pm2_id })
            console.log(result);
            this.proc_detail = result.desc;
            this.dialog_open('#proc-detail');
        },
    },
    created: function(){
    },
    mounted: function(){
        proc_load();

        this.proc_list_update();
    }
};
vue_add_data(vue_options, { progress_title: '' }); // for progress-dialog
vue_add_global_components(components_bootstrap);
vue_add_global_components(components_utils);

/* add additional components */
  
window.vue = new Vue( vue_options );

function do_post_text(url, body) {
    const headers = new Headers({ "Content-Type": "application/json" });
  
    return fetch(url, {
      method: 'POST',
      body: JSON.stringify(body),
      headers: headers
    })
    .then((response) => {
      if (!response.ok)
        throw new Error('status is not 200');
//      return response.json();
      return response.text();
  //    return response.blob();
  //    return response.arrayBuffer();
    });
  }

function do_post_blob(url, body) {
    const headers = new Headers({ "Content-Type": "application/json" });

    return fetch(url, {
            method: 'POST',
            body: JSON.stringify(body),
            headers: headers
        })
        .then((response) => {
            if (!response.ok)
                throw 'status is not 200';
            //    return response.json();
            //    return response.text();
            return response.blob();
            //    return response.arrayBuffer();
    });
}
```

HTMLは以下です。

```html:pm2_console\public\index.html
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
  <meta http-equiv="Content-Security-Policy" content="default-src * data: gap: https://ssl.gstatic.com 'unsafe-eval' 'unsafe-inline'; style-src * 'unsafe-inline'; media-src *; img-src * data: content: blob:;">
  <meta name="format-detection" content="telephone=no">
  <meta name="msapplication-tap-highlight" content="no">
  <meta name="apple-mobile-web-app-capable" content="yes" />
  <meta name="viewport" content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width">

  <!-- jQuery (necessary for Bootstrap's JavaScript plugins) -->
  <script src="https://code.jquery.com/jquery-1.12.4.min.js" integrity="sha384-nvAa0+6Qg9clwYCGGPpDQLVpLNn0fRaROjHqs13t4Ggj3Ez50XnGQqc/r8MhnRDZ" crossorigin="anonymous"></script>
  <!-- Latest compiled and minified CSS -->
  <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/3.4.1/css/bootstrap.min.css" integrity="sha384-HSMxcRTRxnN+Bdg0JdbxYKrThecOKuH5zCYotlSAcp1+c8xmyTe9GYg1l9a69psu" crossorigin="anonymous">
  <!-- Optional theme -->
  <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/3.4.1/css/bootstrap-theme.min.css" integrity="sha384-6pzBo3FDv/PJ8r2KRkGHifhEocL+1X2rVCTTkUfGk7/0pbek5mMa1upzvWbrUbOZ" crossorigin="anonymous">
  <!-- Latest compiled and minified JavaScript -->
  <script src="https://stackpath.bootstrapcdn.com/bootstrap/3.4.1/js/bootstrap.min.js" integrity="sha384-aJ21OjlMXNL5UyIl/XNwTMqvzeRMZH2w8c5cRVpzpU8Y5bApTppSuUkhZXN0VxHd" crossorigin="anonymous"></script>

  <link rel="stylesheet" href="css/start.css">
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/spinkit/2.0.1/spinkit.min.css" />
  <script src="js/methods_bootstrap.js"></script>
  <script src="js/components_bootstrap.js"></script>
  <script src="js/components_utils.js"></script>
  <script src="js/vue_utils.js"></script>
  <script src="js/gql_utils.js"></script>

  <script src="js/remoteconsole.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/vconsole/dist/vconsole.min.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/vue@2.x/dist/vue.min.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/vuex@3.x/dist/vuex.min.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/vue-router@3.x/dist/vue-router.min.js"></script>

  <script src="https://cdn.jsdelivr.net/npm/js-cookie@2/src/js.cookie.min.js"></script>
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/toastr.js/latest/toastr.min.css">
  <script src="https://cdnjs.cloudflare.com/ajax/libs/toastr.js/latest/toastr.min.js"></script>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/dat-gui/0.7.7/dat.gui.min.js"></script>

  <title>PM2 Console</title>
</head>
<body>
<!--
    <div id="loader-background">
      <div class="sk-plane sk-center"></div>
    </div>
-->
    <div id="top" class="container">
      <h1>PM2 - Process Management</h1>
      <br>
      <button class="btn btn-default" v-on:click="proc_list_update()">リスト更新</button>
      <table class="table table-striped">
          <thead>
              <tr><th>id</th><th>name</th><th>status</th><th>memory</th><th>cpu</th><th>pm_uptime</th><th>restart_time</th><th>unstable_restarts</th><th>ログ</th><th>再起動</th><th>停止</th></tr>
          </thead>
          <tbody>
              <tr v-for="(process, index) in proc_list">
                  <td>{{process.pm_id}}</td>
                  <td><a v-on:click="proc_describe(process.pm_id)">{{process.name}}</a></td><td>{{process.pm2_env.status}}</td><td>{{Math.ceil(process.monit.memory/1000)}} KB</td><td>{{process.monit.cpu}} %</td>
                  <td>{{new Date(process.pm2_env.pm_uptime).toLocaleString()}}</td><td>{{process.pm2_env.restart_time}}</td><td>{{process.pm2_env.unstable_restarts}}</td>
                  <td><button class="btn btn-sm btn-default" v-on:click="start_proc_view_log(process.pm_id, process.name)">ログ</button></td>
                  <td><button class="btn btn-sm btn-default" v-on:click="proc_restart(process.pm_id)">再起動</button></td>
                  <td><button class="btn btn-sm btn-default" v-on:click="proc_stop(process.pm_id)">停止</button></td>
              </tr>
          </tbody>
      </table>


      <div class="modal fade" id="proc-log">
        <div class="modal-dialog modal-lg">
            <div class="modal-content">
                <div class="modal-header">
                    <h4 class="modal-title">{{log_pm2_name}}</h4>
                </div>
                <div class="modal-body">
                    <div class="form-inline">
                        <button class="btn btn-default" v-on:click="proc_view_log">View</button>
                        <label>type</label> <select class="form-control" v-model="log_type">
                            <option value="out">out</option>
                            <option value="error">error</option>
                        </select>
                        <label>order</label> <select class="form-control" v-model="log_order">
                            <option value="head">head</option>
                            <option value="tail">tail</option>
                        </select>
                        <label>num</label> <input type="number" class="form-control" v-model.number="log_num">
                        <button class="btn btn-default pull-right" v-on:click="proc_get_log">File</button>
                    </div>
                    <br>

                    <textarea class="form-control" rows="20" readonly>{{log_content}}</textarea>
                </div>
                <div class="modal-footer">
                    <button class="btn btn-default btn-sm" v-on:click="dialog_close('#proc-log')">閉じる</button>
                </div>
            </div>
        </div>
    </div>

      <div class="modal fade" id="proc-detail">
        <div class="modal-dialog modal-lg">
            <div class="modal-content" v-if="proc_detail">
                <div class="modal-header">
                    <h4 class="modal-title"><label>name</label> {{proc_detail.name}}</h4>
                </div>
                <div class="modal-body">

                    <span class="form-group form-inline">
                        <label>status</label> {{proc_detail.pm2_env.status}}
                    </span><br>
                    <span class="form-group form-inline">
                        <label>pid</label> {{proc_detail.pid}}
                    </span><br>
                    <span class="form-group form-inline">
                        <label>pm_id</label> {{proc_detail.pm_id}}
                    </span><br>
                    <span class="form-group form-inline">
                        <label>memory</label> {{proc_detail.monit.memory}}
                    </span><br>
                    <span class="form-group form-inline">
                        <label>cpu</label> {{proc_detail.monit.cpu}}
                    </span><br>
                    <span class="form-group">
                        <label>pm_cwd</label> {{proc_detail.pm2_env.pm_cwd}}
                    </span><br>
                    <span class="form-group">
                        <label>pm_exec_path</label> {{proc_detail.pm2_env.pm_exec_path}}
                    </span><br>
                    <span class="form-group">
                        <label>exec_interpreter</label> {{proc_detail.pm2_env.exec_interpreter}}
                    </span><br>
                    <span class="form-group">
                        <label>exec_mode</label> {{proc_detail.pm2_env.exec_mode}}
                    </span><br>
                    <span class="form-group">
                        <label>node_version</label> {{proc_detail.pm2_env.node_version}}
                    </span><br>
                    <span class="form-group">
                        <label>uername</label> {{proc_detail.pm2_env.username}}
                    </span><br>
                    <span class="form-group">
                        <label>pm_uptime</label> {{new Date(proc_detail.pm2_env.pm_uptime).toLocaleString()}}
                    </span><br>
                    <span class="form-group form-inline">
                        <label>unstable_restarts</label> {{proc_detail.pm2_env.unstable_restarts}}
                    </span><br>
                    <span class="form-group form-inline">
                        <label>restart_time</label> {{proc_detail.pm2_env.restart_time}}
                    </span><br>
                    <span class="form-group form-inline">
                        <label>instances</label> {{proc_detail.pm2_env.instances}}
                    </span><br>
                    <div class="panel panel-default">
                        <div class="panel-heading">
                            <h3 class="panel-title">
                                <a href="#detail_env" data-toggle="collapse">
                                    env
                                </a>
                            </h3>
                        </div>
                        <div id="detail_env" class="panel-coppapse collapse">
                            <div class="panel-body">
                                <table class="table table-striped">
                                    <thead>
                                        <tr><th>name</th><th>value</th></tr>
                                    </thead>
                                    <tbody>
                                        <tr v-for="(item, key) in proc_detail.pm2_env.env">
                                            <td>{{key}}</td><td>{{item}}</td>
                                        </tr>
                                    </tbody>
                                </table>
                            </div>
                        </div>
                    </div>
                </div>
                <div class="modal-footer">
                    <button class="btn btn-default btn-sm" v-on:click="dialog_close('#proc-detail')">閉じる</button>
                </div>
            </div>
        </div>
    </div>

        <router-view></router-view>
      
        <!-- for progress-dialog -->
        <progress-dialog v-bind:title="progress_title"></progress-dialog>
    </div>

    <script src="js/store.js"></script>
    <script src="js/router.js"></script>
    <script src="js/start.js"></script>
</body>
```

# セットアップ

以下からZIPでダウンロードしてきます。

https://github.com/poruruba/pm2_console

あとは、以下のように実行します。
pm2_consoleがプロセス管理用のアプリ、public_sitesが管理対象のアプリです。後者はなんでもよいです。

```
> unzip pm2_console-main.zip
> cd pm2_console-main
> cd public_sites
> npm install
> cd ..
> cd pm2_console
> npm install
> sudo systemctl start pm2-xxxx
> sudo systemctl enable pm2-xxxx
```

以上