LoginSignup
8
7

More than 3 years have passed since last update.

PM2をもっともっと快適に!

Last updated at Posted at 2018-12-24

以前、以下の投稿で、PM2の環境設定と、扱いやすくするためのGUIであるpm2-guiの環境設定をしました。

pm2でNode.js実行環境を整備する

だけど、まだまだかゆいところに届かず、設定変更するたびに、ドキュメントをあさりまわることが続いていました。

ということで、いっそのこと、PM2を扱うためのGUIを作ってしまおう!という野望のもと、pm2-guiに代わる、かゆいところに手が届き、自己満足するPM2管理のためのWebページを作りました。
中身は、メジャーな技術の組み合わせなので、よく読めば、さらにカスタマイズして気持ちのすっきりするものもできると思いますので、ぜひご参考にして下さい。

<やりたいこと>

  • 稼働中のPM2プロセスが一覧表示できる。さらに、再起動・停止・削除ができる。
  • Web上で、新しいPM2プロセス定義を作成することができる。さらに、PM2プロセスとして起動できる。定義は編集・削除できる。
  • PM2プロセスを常時起動ではなく、CRON起動させる。

ちなみに、今回は、node.jsのプロセス限定です。

<使った技術>

  • pm2
  • swagger-node
  • express-session
  • Vue
  • Bootstrap

特に、pm2のAPIとexpress-sessionを使うのが初めてだったので、これを覚えることがモチベーションでした。

出来上がり画面はこんな感じです。

image.png

ちょっと注意です。
まず、PM2のセットアップは完了している前提です。
それから、今回、PM2プロセス定義を追加したり、起動したりできますが、永続的ではありません。例えば、PCを再起動したり、PM2を再起動したりすると、追加したPM2プロセス定義は停止した状態になります。もし、永続的に起動させる場合は、PM2のセットアップ時に指定してください。もちろん、再度このWebページから起動はかけられます。
(もちろん、カスタマイズして作りこめば、永続起動も可能だと思います。)

基本的な考え方

今回作成するのは、PM2 Process Managementです。管理するのは2つあります。

・プロセスリスト
  PM2が監視しているプロセスであり、PM2 APIを使って、プロセスのリストを取得したり、プロセスを再起動したりします。
・アプリリスト
  PM2では管理していない未稼働アプリのリストです。内部的にはJSONファイルで配列管理しています。

これら2つのリストを管理するためのRESTfulサーバを立ち上げます。

/pm2-proc-list
 PM2プロセスのリストを取得します。
/pm2-proc-restart
 PM2プロセスを起動または再起動します。
/pm2-proc-stop
 PM2プロセスを停止します。
/pm2-proc-delete
 PM2プロセスを削除します。削除はされますが、アプリリストから起動させればまたPM2プロセスリストに加わります。
/pm2-proc-describe
 PM2プロセスの詳細な状態を取得します。
/pm2-app-list
 アプリのリストを取得します。PM2プロセスとして起動しているものも含め、管理しているアプリのすべてを取得します。
/pm2-app-start
 アプリをPM2プロセスとして起動します。
/pm2-app-append
 アプリ定義を追加します。
/pm2-app-modify
 アプリ定義を修正します。
/pm2-app-delete
 アプリ定義を削除します。
/pm2-login
 PM2のプロセスを誰でも彼でもいじられては困るので、一応簡単なパスワード認証を入れています。本番ではさらにIPアドレス制限などを加えるのがよいかと思います。詳細は後で説明します。

そして、それらを駆使して画面表示を行うフロントエンドのWebページがあります。

アプリ定義を見てみましょう。
参考までに、以下がアプリ定義の登録画面です。

image.png

name:自由に定義できる名前です。
script:起動させたいnode.jsファイルです。
cwd: 起動するときのカレントディレクトリを指定します。
url:任意指定です。もしこのアプリを起動した後にブラウザからアクセスできるURLがあればそれを指定します。
cron:任意指定です。アプリを常時起動ではなく、決まった時間に定期起動したい場合に指定します。cron定義と同じです。例えば、10分ごとに起動したい場合は、「*/10 * * * *」と指定します。
autorestart:自動再起動させるかどうかを指定します。基本的にはtrueを指定します。cron指定を使う場合には、次の起動まで停止させたいので、falseにします。

Swagger定義ファイルの定義

該当部分を抜粋します。大したことはないので、細かく見る必要はないです。

swagger.yaml
paths:
  /pm2-proc-list:
    post:
      x-swagger-router-controller: routing
      operationId: pm2-proc-list
      responses:
        200:
          description: Success
          schema:
            type: object

  /pm2-app-list:
    post:
      x-swagger-router-controller: routing
      operationId: pm2-app-list
      responses:
        200:
          description: Success
          schema:
            type: object

  /pm2-proc-restart:
    post:
      x-swagger-router-controller: routing
      operationId: pm2-proc-restart
      parameters:
        - in: body
          name: Pm2ProcRestart
          required: true
          schema:
            $ref: '#/definitions/Pm2ProcRestartRequest'
      responses:
        200:
          description: Success
          schema:
            type: object

  /pm2-proc-stop:
    post:
      x-swagger-router-controller: routing
      operationId: pm2-proc-stop
      parameters:
        - in: body
          name: Pm2ProcStop
          required: true
          schema:
            $ref: '#/definitions/Pm2ProcStopRequest'
      responses:
        200:
          description: Success
          schema:
            type: object

  /pm2-proc-delete:
    post:
      x-swagger-router-controller: routing
      operationId: pm2-proc-delete
      parameters:
        - in: body
          name: Pm2ProcDelete
          required: true
          schema:
            $ref: '#/definitions/Pm2ProcDeleteRequest'
      responses:
        200:
          description: Success
          schema:
            type: object

  /pm2-proc-describe:
    post:
      x-swagger-router-controller: routing
      operationId: pm2-proc-describe
      parameters:
        - in: body
          name: Pm2ProcDescribe
          required: true
          schema:
            $ref: '#/definitions/Pm2ProcDescribeRequest'
      responses:
        200:
          description: Success
          schema:
            type: object

  /pm2-app-start:
    post:
      x-swagger-router-controller: routing
      operationId: pm2-app-start
      parameters:
        - in: body
          name: Pm2AppStart
          required: true
          schema:
            $ref: '#/definitions/Pm2AppStartRequest'
      responses:
        200:
          description: Success
          schema:
            type: object

  /pm2-app-append:
    post:
      x-swagger-router-controller: routing
      operationId: pm2-app-append
      parameters:
        - in: body
          name: Pm2AppAppend
          required: true
          schema:
            $ref: '#/definitions/Pm2AppAppendRequest'
      responses:
        200:
          description: Success
          schema:
            type: object

  /pm2-app-delete:
    post:
      x-swagger-router-controller: routing
      operationId: pm2-app-delete
      parameters:
        - in: body
          name: Pm2AppDelete
          required: true
          schema:
            $ref: '#/definitions/Pm2AppDeleteRequest'
      responses:
        200:
          description: Success
          schema:
            type: object

  /pm2-app-modify:
    post:
      x-swagger-router-controller: routing
      operationId: pm2-app-modify
      parameters:
        - in: body
          name: Pm2AppModify
          required: true
          schema:
            $ref: '#/definitions/Pm2AppModifyRequest'
      responses:
        200:
          description: Success
          schema:
            type: object

  /pm2-login:
    post:
      x-swagger-router-controller: routing
      operationId: pm2-login
      parameters:
        - in: body
          name: Pm2Login
          required: true
          schema:
            $ref: '#/definitions/Pm2LoginRequest'
      responses:
        200:
          description: Success
          schema:
            type: object

definitions:
  Pm2ProcRestartRequest:
    type: object
    required:
    - pm2_id
    properties:
      pm2_id:
        type: number

  Pm2ProcStopRequest:
    type: object
    required:
    - pm2_id
    properties:
      pm2_id:
        type: number

  Pm2ProcDeleteRequest:
    type: object
    required:
    - pm2_id
    properties:
      pm2_id:
        type: number

  Pm2ProcDescribeRequest:
    type: object
    required:
    - pm2_id
    properties:
      pm2_id:
        type: number

  Pm2AppStartRequest:
    type: object
    required:
    - name
    properties:
      name:
        type: string

  Pm2AppInfo:
    type: object
    required:
      - name
      - script
    properties:
      name:
        type: string
      script:
        type: string
      url:
        type: string
      cwd:
        type: string
      cron:
        type: string
      autorestart:
        type: boolean

  Pm2AppAppendRequest:
    type: object
    required:
    - app
    properties:
      app:
        $ref: '#/definitions/Pm2AppInfo'

  Pm2AppModifyRequest:
    type: object
    required:
    - app
    properties:
      app:
        $ref: '#/definitions/Pm2AppInfo'

  Pm2AppDeleteRequest:
    type: object
    required:
    - name
    properties:
      name:
        type: string

  Pm2LoginRequest:
    type: object
    required:
    - password
    properties:
      password:
        type: string

RESTfulサーバの実装

毎度のRESTfulサーバを立ち上げます。以下が参考になります。

SwaggerでRESTful環境を構築する

追加で、以下の2つのnpmモジュールを使っています。

  • express-session
  • pm2

以下のようにセットアップしておきます。

npm install --save express-session
npm install --save pm2

まずは、ソースまるごとを載せておきます。

api/controllers/pm2/index.js
const Response = require('../../helpers/response');
var pm2 = require('pm2');
const fs = require('fs');

const APP_FILEPATH = process.env.PM2_APP_PATH || './data/app.json';
const PM2_PASSWORD = process.env.PM2_PASSWORD || 'password';

try {
    fs.statSync(APP_FILEPATH);
} catch (error) {
    fs.writeFileSync(APP_FILEPATH, JSON.stringify([]), 'utf8');
}

exports.handler = async (event, context, callback) => {
    var req = context.original_req;

    if( event.path == '/pm2-login' ){
        var body = JSON.parse(event.body);
        if( !req.session || !req.session.verify ){
            if( body.password != PM2_PASSWORD ){
                var response = new Response({ verify: false, message : 'パスワードが合っていません。'});
                response.statusCode = 401;
                return callback( null, response );
            }
        }

        req.session.verify = true;

        var response = new Response({ verify: true });
        return callback( null, response );
    }

    if( !req.session || !req.session.verify ){
        var response = new Response({ verify: false, message : 'ログインしていません。'});
        response.statusCode = 401;
        return callback( null, response );
    }

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

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

                    callback(null, new Response({ list: processDescriptionList }));
                    pm2.disconnect();
                    resolve();
                })
            });
        })
    }else if( event.path == "/pm2-proc-restart" ){
        var body = JSON.parse(event.body);
        var pm2_id = body.pm2_id;

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

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

                    callback(null, new Response({ message: '再起動しました。' }));
                    pm2.disconnect();
                    resolve();
                })
            });
        });
    }else if( event.path == "/pm2-proc-stop" ){
        var body = JSON.parse(event.body);
        var pm2_id = body.pm2_id;

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

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

                    callback(null, new Response({ message: '停止しました。' }));
                    pm2.disconnect();
                    resolve();
                })
            });
        });
    }else if( event.path == "/pm2-proc-delete" ){
        var body = JSON.parse(event.body);
        var pm2_id = body.pm2_id;

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

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

                    callback(null, new Response({ message: '削除しました。' }));
                    pm2.disconnect();
                    resolve();
                })
            });
        });
    }else if( event.path == "/pm2-proc-describe" ){
        var body = JSON.parse(event.body);
        var pm2_id = body.pm2_id;

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

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

                    console.log(processDescription);

                    callback(null, new Response({ desc: processDescription[0] }));
                    pm2.disconnect();
                    resolve();
                })
            });
        });
    }else if( event.path == '/pm2-app-list' ){
        var list = JSON.parse(fs.readFileSync(APP_FILEPATH, 'utf8'));

        callback(null, new Response({ list: list }));
    }else if( event.path == "/pm2-app-start" ){
        var body = JSON.parse(event.body);
        var name = body.name;

        var list = JSON.parse(fs.readFileSync(APP_FILEPATH, 'utf8'));
        var index;
        for( index = 0 ; index < list.length ; index++ ){
            if( list[index].name == name ){
                break;
            }
        }
        if( index >= list.length )
            return callback(null, new Response({ message : 'そのAppは登録されていません。'}));

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

                pm2.start(list[index], (err) =>{
                    if( err )
                        return reject(err);

                    callback(null, new Response({ message : '開始しました'}));
                    pm2.disconnect();
                    resolve();
                })
            });
        });
    }else if( event.path == "/pm2-app-append" ){
        var body = JSON.parse(event.body);
        var app = body.app;

        var list = JSON.parse(fs.readFileSync(APP_FILEPATH, 'utf8'));
        for( var i = 0 ; i < list.length ; i++ ){
            if( list[i].name == app.name ){
                return callback(null, new Response({ message : 'すでに同名で登録されています。'}));
            }
        }
        list.push(app);

        fs.writeFileSync(APP_FILEPATH, JSON.stringify(list), 'utf8');

        callback(null, new Response({ message : '登録しました'}));
    }else if( event.path == "/pm2-app-modify" ){
        var body = JSON.parse(event.body);
        var app = body.app;

        var list = JSON.parse(fs.readFileSync(APP_FILEPATH, 'utf8'));
        var index;
        for( index = 0 ; index < list.length ; index++ ){
            if( list[index].name == app.name ){
                break;
            }
        }
        if( index >= list.length )
            return callback(null, new Response({ message : 'そのAppは登録されていません。'}));

        list[index] = app;

        fs.writeFileSync(APP_FILEPATH, JSON.stringify(list), 'utf8');

        callback(null, new Response({ message : '編集しました'}));
    }else if( event.path == "/pm2-app-delete" ){
        var body = JSON.parse(event.body);
        var name = body.name;

        var list = JSON.parse(fs.readFileSync(APP_FILEPATH, 'utf8'));
        var index;
        for( index = 0 ; index < list.length ; index++ ){
            if( list[index].name == name ){
                break;
            }
        }
        if( index >= list.length )
            return callback(null, new Response({ message : 'そのAppは登録されていません。'}));

        list.splice(index, 1);

        fs.writeFileSync(APP_FILEPATH, JSON.stringify(list), 'utf8');

        callback(null, new Response({ message : '削除しました'}));
    }
};

毎度のヘルパーです。

response.js
class Response{
    constructor(context){
        this.statusCode = 200;
        this.headers = {'Access-Control-Allow-Origin' : '*'};
        if( context )
            this.set_body(context);
        else
            this.body = "{}";
    }

    set_error(error){
        this.body = JSON.stringify({"err": error});
        return this; 
    }

    set_body(content){
        this.body = JSON.stringify(content);        
        return this; 
    }

    get_body(){
        return JSON.parse(this.body);
    }
}

module.exports = Response;

順に補足します。

環境に合わせて以下の2つを変更します。

 const APP_FILEPATH = process.env.PM2_APP_PATH || './data/app.json';
 const PM2_PASSWORD = process.env.PM2_PASSWORD || 'password';

APP_FILEPATHは、アプリ定義を保存するJSONファイルです。データベースに入れるべきですが、自分しか使わないので、この方が手っ取り早いです。

PM2_PASSWORDは、本ページにログインするためのパスワードを指定します。推測されにくいフレーズを指定してください。

まず最初に気になるのが、以下の2つの箇所でしょうか。

① if( event.path == '/pm2-login' ){
② if( !req.session || !req.session.verify ){

これは、アクセスしようとしてきている人がパスワードを知っている正規の人かどうかを確認するためのロジック部分です。express-sessionを使っています。

① if( event.path == '/pm2-login' ){

クライアント側は、このエンドポイントにパスワードを指定して呼び出し、RESTfulサーバ側はパスワードを照合し、合致すれば、サーバ側に認証情報を保持します。
今回は単純に、パスワードの完全一致確認としました。サーバ側認証情報も単純に、verify=true としているだけです。req.session に保持すると、サーバ側に保持されるそうです。認証OKの場合には、verify=trueとして認証OKの結果を返します。
ただし、パスワード照合を行うのは、サーバ側に認証情報が保持されていない場合だけで、保持されていれば、認証OKの結果を返します。

② if( !req.session || !req.session.verify ){

/pm2-login以外のエントリポイントは、すべてのこの条件分岐がゲートとなっています。認証された状態すなわちサーバ側に認証情報がある状態でないと、エントリポイントの呼び出しはエラーとしています。

以上が、パスワード認証でのアクセス制限になります。
今回のページの構成は、SPA(Single Page Application)を採用しており、HTMLページ自体は誰でも取得できます。ですが、中身のデータを取得するには、RESTfulによるPOST呼び出しで取得するので、このPOST呼び出しを制限しているわけです。

express-sessionを使うには、ちょっと準備が必要でして、app.jsの適当な場所に以下を追加しておいてください。

app.js
・・・
var session = require('express-session');
app.use(session({
    secret: "secret key",
    resave: false,
    saveUninitialized: true,
    cookie: { secure: true }
}));
・・・

(参考)
https://github.com/expressjs/session#readme

あとは、単純な処理の繰り返しです。
pm2のAPIの呼び出しが不明瞭な場合は、以下を参照してください。

(参考) PM2 - PM2 API
http://pm2.keymetrics.io/docs/usage/pm2-api/

毎度ですが、Swagger-nodeをLambdaっぽく作っています。
以下の変換(routing.js)を間に入れています。

 swagger-node → controllers/routing.js → controllers/pm2/index.js

routing.js
'use strict';

/* 関数を以下に追加する */
const func_table = {
  "pm2-login" : require('./pm2').handler,

  "pm2-proc-list" : require('./pm2').handler,
  "pm2-proc-restart" : require('./pm2').handler,
  "pm2-proc-stop" : require('./pm2').handler,
  "pm2-proc-delete" : require('./pm2').handler,
  "pm2-proc-describe" : require('./pm2').handler,
  "pm2-app-start" : require('./pm2').handler,
  "pm2-app-modify" : require('./pm2').handler,
  "pm2-app-append" : require('./pm2').handler,
  "pm2-app-delete" : require('./pm2').handler,
  "pm2-app-list" : require('./pm2').handler
};
/* ここまで */

/* 必要に応じて、バイナリレスポンスのContent-Typeを以下に追加する */
const binary_table = [
//  'application/octet-stream',
];
/* ここまで */

var exports_list = {};
for( var operationId in func_table ){
  exports_list[operationId] = routing;
}

module.exports = exports_list;

function routing(req, res) {
//  console.log(req);

  var operationId = req.swagger.operation.operationId;
  console.log('[' + operationId + ' calling]');

  try{
    var event;
    var func;
    if( func_table.hasOwnProperty(operationId) ){
      event = {
        headers: req.headers,
        body: JSON.stringify(req.body),
        path: req.swagger.apiPath,
        httpMethod: req.method,
        queryStringParameters: req.query,
        requestContext: ( req.requestContext ) ? req.requestContext : {}
      };

      if( event.headers['content-type'] )
        event.headers['Content-Type'] = event.headers['content-type'];
      if( event.headers['authorization'] )
        event.headers['Authorization'] = event.headers['authorization'];
      if( event.headers['user-agent'] )
        event.headers['User-Agent'] = event.headers['user-agent'];

      event.Host = req.hostname;

      func = func_table[operationId];
    }else{
      console.log('can not found operationId: ' + operationId);
      return_error(res, new Error('can not found operationId'));
      return;
    }
    res.returned = false;

//  console.log(event);

    var context = {
      succeed: (msg)=>{
        console.log('succeed called');
        return_response(res, msg);
      },
      fail: (error) => {
        console.log('failed called');
        return_error(res, error);
      },
      original_req: req
    };

    var task = func(event, context, (error, response) =>{
      console.log('callback called');
      if( error )
        return_error(res, error);
      else
        return_response(res, response);
    });
    if( task === undefined ){
      console.log('return undefined');
    }else if( task instanceof Promise ){
      task.then(ret =>{
        if( ret ){
          console.log('promise is called');
          return_response(res, ret);
        }else{
          console.log('return undefined');
        }
      });
    }else{
      console.log('return called');
      return_response(res, task);
    }
  }catch(err){
    console.log('error throwed: ' + err);
    return_error(res, err);
  }
}

function return_error(res, err){
  if( res.returned )
    return;
  else
    res.returned = true;

  res.status(500);
  res.json({ errorMessage: err.toString() });
}

function return_response(res, ret){
  if( res.returned )
    return;
  else
    res.returned = true;

  if( ret.statusCode )
    res.status(ret.statusCode);
  for( var key in ret.headers )
    res.set(key, ret.headers[key]);

//  console.log(ret.body);

  if (!res.get('Content-Type'))
    res.type('application/json');

  if( binary_table.indexOf(res.get('Content-Type')) >= 0 ){
    var bin = new Buffer(ret.body, 'base64')
    res.send(bin);
  }else{
    if( ret.body || ret.body == "")
      res.send(ret.body);
    else
      res.send("{}");
  }
}

フロントエンドの作成

まずは、HTMLページです。Bootstrapを駆使しています。

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://ajax.googleapis.com/ajax/libs/jquery/1.12.4/jquery.min.js"></script>
  <!-- Latest compiled and minified CSS -->
  <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css" integrity="sha384-BVYiiSIFeK1dGmJRAkycuHAHRg32OmUcww7on3RYdg4Va+PmSTsz/K68vbdEjh4u" crossorigin="anonymous">
  <!-- Optional theme -->
  <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap-theme.min.css" integrity="sha384-rHyoN1iRsVXV4nD0JutlnGaslCJuC7uwjduW9SVrLvRYooPp2bWYgmgJQIXwl/Sp" crossorigin="anonymous">
  <!-- Latest compiled and minified JavaScript -->
  <script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/js/bootstrap.min.js" integrity="sha384-Tc5IQib027qvyjSMfHjOMaLkfuWVxZxUPnCJA7l2mCWNIpG9mGCD8wGNIcPD7Txa" crossorigin="anonymous"></script>

  <title>PM2 - Process Management</title>

  <script src="https://unpkg.com/vue"></script>
</head>
<body>
    <div id="top" class="container">
        <h1>PM2 - Process Management</h1>
        <br>
        <button class="btn btn-default" v-on:click="proc_list_update()">ProcList更新</button>
        <table class="table table-striped">
            <thead>
                <tr><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 process_list">
                    <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="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>
                    <td><button class="btn btn-sm btn-default" v-on:click="proc_delete(process.pm_id)">削除</button></td>
                </tr>
            </tbody>
        </table>

        <button class="btn btn-default" v-on:click="app_list_update()">AppList更新</button> <button class="btn btn-primary" v-on:click="app_append_dialog()">App追加</button>
        <table class="table table-striped">
            <thead>
                <tr><th>name</th><th>url</th><th>script</th><th>cwd</th><th>編集</th><th>削除</th><th>開始</th></tr>
            </thead>
            <tbody>
                <tr v-for="(app, index) in app_list">
                    <td><a v-on:click="app_describe(index)">{{app.name}}</a></td><td><a v-bind:href="app.url">{{app.url}}</td></a><td>{{app.script}}</td><td>{{app.cwd}}</td>
                    <td><button class="btn btn-sm btn-default" v-on:click="app_modify_dialog(index)">編集</button></td>
                    <td><button class="btn btn-sm btn-default" v-on:click="app_delete(index)">削除</button></td>
                    <td><button class="btn btn-sm btn-default" v-on:click="app_start(index)">開始</button></td>
                </tr>
            </tbody>
        </table>


        <div class="modal fade" id="proc-detail">
            <div class="modal-dialog">
                <div class="modal-content" v-if="proc_detail">
                    <div class="modal-header">
                        <h4 class="modal-title"><label>name</label> {{proc_detail.name}}</button></h4>
                    </div>
                    <div class="modal-body">                                
                        <div class="form-group form-inline">
                            <label>status</label> {{proc_detail.pm2_env.status}}
                        </div>
                        <div class="form-group form-inline">
                            <label>pid</label> {{proc_detail.pid}}
                        </div>
                        <div class="form-group form-inline">
                            <label>pm_id</label> {{proc_detail.pm_id}}
                        </div>
                        <div class="form-group form-inline">
                            <label>memory</label> {{proc_detail.monit.memory}}
                        </div>
                        <div class="form-group form-inline">
                            <label>cpu</label> {{proc_detail.monit.cpu}}
                        </div>
                        <div class="form-group">
                            <label>pm_cwd</label> {{proc_detail.pm2_env.pm_cwd}}
                        </div>
                        <div class="form-group">
                            <label>pm_exec_path</label> {{proc_detail.pm2_env.pm_exec_path}}
                        </div>
                        <div class="form-group">
                            <label>exec_interpreter</label> {{proc_detail.pm2_env.exec_interpreter}}
                        </div>
                        <div class="form-group">
                            <label>pm_uptime</label> {{new Date(proc_detail.pm2_env.pm_uptime).toLocaleString()}}
                        </div>
                        <div class="form-group form-inline">
                            <label>unstable_restarts</label> {{proc_detail.pm2_env.unstable_restarts}}
                        </div>
                        <div class="form-group form-inline">
                            <label>restart_time</label> {{proc_detail.pm2_env.restart_time}}
                        </div>
                        <div class="form-group form-inline">
                            <label>instances</label> {{proc_detail.pm2_env.instances}}
                        </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>

        <div class="modal fade" id="app-append">
            <div class="modal-dialog">
                <div class="modal-content" v-if="app_detail">
                    <div class="modal-header">
                        <h4 class="modal-title">{{app_detail.title}}</button></h4>
                    </div>
                    <div class="modal-body">                                
                        <div class="form-group">
                            <label>name</label>
                            <input type="text" class="form-control" v-model="app_detail.name">
                        </div>
                        <div class="form-group">
                            <label>script</label>
                            <input type="text" class="form-control" v-model="app_detail.script">
                        </div>
                        <div class="form-group">
                            <label>cwd</label>
                            <input type="text" class="form-control" v-model="app_detail.cwd">
                        </div>
                        <div class="form-group">
                            <label>url</label>
                            <input type="text" class="form-control" v-model="app_detail.url">
                        </div>
                        <div class="form-group">
                            <label>cron</label>
                            <input type="text" class="form-control" v-model="app_detail.cron">
                        </div>
                        <div class="form-group form-inline">
                            <label>autorestart</label>
                            <input type="checkbox" class="form-control" v-model="app_detail.autorestart">
                            <p>cronを指定するときはfalseにしてください。</p>
                        </div>
                    </div>
                    <div class="modal-footer">
                        <button class="btn btn-primary" v-on:click="app_append()">登録実行</button> 
                        <button class="btn btn-default btn-sm" v-on:click="dialog_close('#app-append')">閉じる</button>
                    </div>
                </div>
            </div>
        </div>

        <div class="modal fade" id="app-modify">
            <div class="modal-dialog">
                <div class="modal-content" v-if="app_detail">
                    <div class="modal-header">
                        <h4 class="modal-title">{{app_detail.title}}</button></h4>
                    </div>
                    <div class="modal-body">                                
                        <div class="form-group form-inline">
                            <label>name</label> {{app_detail.name}}
                        </div>
                        <div class="form-group">
                            <label>script</label>
                            <input type="text" class="form-control" v-model="app_detail.script">
                        </div>
                        <div class="form-group">
                            <label>cwd</label>
                            <input type="text" class="form-control" v-model="app_detail.cwd">
                        </div>
                        <div class="form-group">
                            <label>url</label>
                            <input type="text" class="form-control" v-model="app_detail.url">
                        </div>
                        <div class="form-group">
                            <label>cron</label>
                            <input type="text" class="form-control" v-model="app_detail.cron">
                        </div>
                        <div class="form-group form-inline">
                            <label>autorestart</label>
                            <input type="checkbox" class="form-control" v-model="app_detail.autorestart">
                            <p>cronを指定するときはfalseにしてください。</p>
                        </div>
                    </div>
                    <div class="modal-footer">
                        <button class="btn btn-primary" v-on:click="app_modify()">編集実行</button> 
                        <button class="btn btn-default btn-sm" v-on:click="dialog_close('#app-modify')">閉じる</button>
                    </div>
                </div>
            </div>
        </div>

        <div class="modal fade" id="app-detail">
            <div class="modal-dialog">
                <div class="modal-content" v-if="app_detail">
                    <div class="modal-header">
                        <h4 class="modal-title"><label>name</label> {{app_detail.name}}</button></h4>
                    </div>
                    <div class="modal-body">                                
                        <div class="form-group form-inline">
                            <label>url</label> {{app_detail.url}}
                        </div>
                        <div class="form-group form-inline">
                            <label>script</label> {{app_detail.script}}
                        </div>
                        <div class="form-group form-inline">
                            <label>cwd</label> {{app_detail.cwd}}
                        </div>
                        <div class="form-group form-inline" v-if="app_detail.cron">
                            <label>cron</label> {{app_detail.cron}}
                        </div>
                        <div class="form-group form-inline">
                            <label>autorestart</label> {{app_detail.autorestart}}
                        </div>
                    </div>
                    <div class="modal-footer">
                        <button class="btn btn-default btn-sm" v-on:click="dialog_close('#app-detail')">閉じる</button>
                    </div>
                </div>
            </div>
        </div>

        <div class="modal fade" id="login">
            <div class="modal-dialog">
                <div class="modal-content">
                    <div class="modal-header">
                        <h4 class="modal-title">ログイン</button></h4>
                    </div>
                    <div class="modal-body">
                        <p>パスワードを入力してください。</p>
                        <div class="form-group">
                            <label>password</label>
                            <input type="password" class="form-control" v-model="password" v-on:keyup.enter="login_call()">
                        </div>
                    </div>
                    <div class="modal-footer">
                        <button class="btn btn-primary" v-on:click="login_call()">ログイン</button> 
                    </div>
                </div>
            </div>
        </div>

   </div>

    <script src="js/start.js"></script>
</body>

次は、Javascript部分です。Vueを駆使しています。

start.js
const PM2_URL_BASE = 【RESTfulサーバのURL】;

var vue_options = {
    el: "#top",
    data: {
        process_list: [],
        app_list: [],
        proc_detail: null,
        app_detail: null,
        password: ''
    },
    computed: {
    },
    methods: {
        proc_list_update: function(){
            return do_post(PM2_URL_BASE + '/pm2-proc-list')
            .then(json =>{
                this.process_list = json.list;
            })
            .catch(error =>{
                alert(error);
            });
        },
        app_list_update: function(){
            do_post(PM2_URL_BASE + '/pm2-app-list')
            .then(json =>{
                this.app_list = json.list;
            })
            .catch(error =>{
                alert(error);
            });
        },
        proc_restart: function(pm2_id){
            do_post(PM2_URL_BASE + '/pm2-proc-restart', { pm2_id: pm2_id })
            .then(json =>{
                alert(json.message);
                this.proc_list_update();
            })
            .catch(error =>{
                alert(error);
            });
        },
        proc_stop: function(pm2_id){
            do_post(PM2_URL_BASE + '/pm2-proc-stop', { pm2_id: pm2_id })
            .then(json =>{
                alert(json.message);
                this.proc_list_update();
            })
            .catch(error =>{
                alert(error);
            });
        },
        proc_delete: function(pm2_id){
            if(!confirm('本当に削除しますか?'))
                return;

            do_post(PM2_URL_BASE + '/pm2-proc-delete', { pm2_id: pm2_id })
            .then(json =>{
                alert(json.message);
                this.proc_list_update();
            })
            .catch(error =>{
                alert(error);
            });
        },
        proc_describe: function(pm2_id){
            do_post(PM2_URL_BASE + '/pm2-proc-describe', { pm2_id: pm2_id })
            .then(json =>{
                this.proc_detail = json.desc;
                this.dialog_open('#proc-detail');
            })
            .catch(error =>{
                alert(error);
            });
        },
        app_describe: function(index){
            this.app_detail = this.app_list[index];
            this.dialog_open('#app-detail');
        },
        app_start: function(index){
            do_post(PM2_URL_BASE + '/pm2-app-start', { name: this.app_list[index].name })
            .then(json =>{
                alert(json.message);
                this.proc_list_update();
            })
            .catch(error =>{
                alert(error);
            });
        },
        app_append_dialog: function(){
            this.app_detail = {
                title: '新規作成',
                autorestart: true
            };
            this.dialog_open('#app-append');
        },
        app_append: function(){
            if( this.app_detail.cron && this.app_detail.cron.trim() != "" ){
                this.app_detail.cron = this.app_detail.cron.trim();
            }else{
                this.app_detail.cron = undefined;
            }

            do_post(PM2_URL_BASE + '/pm2-app-append', { app: this.app_detail })
            .then(json =>{
                alert(json.message);
                this.dialog_close('#app-append');
                this.app_list_update();
            })
            .catch(error =>{
                alert(error);
            });
        },
        app_modify_dialog: function(index){
            this.app_detail = this.app_list[index];
            this.app_detail.title = this.app_detail.name;
            this.dialog_open('#app-modify');
        },
        app_modify: function(){
            if( this.app_detail.cron && this.app_detail.cron.trim() != "" ){
                this.app_detail.cron = this.app_detail.cron.trim();
            }else{
                this.app_detail.cron = undefined;
            }

            do_post(PM2_URL_BASE + '/pm2-app-modify', { app: this.app_detail })
            .then(json =>{
                alert(json.message);
                this.dialog_close('#app-modify');
                this.app_list_update();
            })
            .catch(error =>{
                alert(error);
            });
        },
        app_delete: function(index){
            if(!confirm('本当に削除しますか?'))
                return;

            do_post(PM2_URL_BASE + '/pm2-app-delete', { name: this.app_list[index].name })
            .then(json =>{
                alert(json.message);
                this.app_list_update();
            })
            .catch(error =>{
                alert(error);
            });
        },
        login_call: function(){
            do_post(PM2_URL_BASE + '/pm2-login', { password: this.password } )
            .then(json =>{
                this.password = '';
                if( json.verify ){
                    this.dialog_close('#login');

                    this.proc_list_update()
                    .then(() =>{
                        this.app_list_update();
                    });
                }else{
                    alert(json.message);
                }
            })
            .catch(error =>{
                alert(error);
            });
        },

        dialog_open: function(target){
            $(target).modal({backdrop:'static', keyboard: false});
        },
        dialog_close: function(target){
            $(target).modal('hide');
        }
    },
    created: function(){
    },
    mounted: function(){
        do_post(PM2_URL_BASE + '/pm2-login' )
        .then(json =>{
            if( json.verify ){
                this.proc_list_update()
                .then(() =>{
                    this.app_list_update();
                });
            }else{
                this.dialog_open('#login');
            }
        });
    }
};
var vue = new Vue( vue_options );

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

    return fetch(url, {
        method : 'POST',
        body : JSON.stringify(body),
        headers: headers
    })
    .then((response) => {
        return response.json();
    });
}

まず、環境に合わせて以下の指定が必要です。

【RESTfulサーバのURL】:先ほど立ち上げたRESTfulサーバのURLです。

特徴的なのは以下の部分でしょうか。

    mounted: function(){
        do_post(PM2_URL_BASE + '/pm2-login' )
        .then(json =>{
            if( json.verify ){
                this.proc_list_update()
                .then(() =>{
                    this.app_list_update();
                });
            }else{
                this.dialog_open('#login');
            }
        });
    }

ページのロードが完了したら、まずはサーバにログインを試みます。ここではパスワードを指定しません。サーバ側では、もし認証情報を保持していたらverify=trueとして返ってきますので、PM2プロセスリスト取得とアプリリスト取得を行います。
もし、verify=falseの場合にはまだログインしていないので、ログインダイアログを表示します。

ログイン呼び出しは、login_call の部分です。ログインが成功しないと、ログインダイアログを閉じません。ログインが成功すると、ログインダイアログを閉じて、PM2プロセスリスト取得とアプリリスト取得を行います。

早速、Webページ(index.html)にアクセスしてみましょう。
まずはログインダイアログが表示されます。index.jsに指定したパスワードを入力しましょう。

image.png

無事にログインできましたでしょうか?

image.png

上半分のテーブルには、PM2が管理しているプロセスのリストが一覧表示されています。

  • name:自身でつけた名前
  • status:“online”, “stopping”, “stopped”, “launching”, “errored”, or “one-launch-status”のいづれか
  • memory:メモリ使用量
  • cpu:CPU占有率
  • pm_updtime:いつから起動されているか
  • restart_time:再起動回数
  • unstable_restarts:不安定な再起動の回数

nameのリンクをクリックすると、詳細な情報がダイアログで表示されます。いい感じです。

下半分のテーブルには、アプリ定義の一覧が表示されます。まだ何も作成していないかと思いますので、「App追加」ボタンを押下すると、新規に定義を追加することができます。nameのリンクをクリックすると、定義した情報がダイアログで表示されます。
特に私が重宝しているのは、cronの部分です。
定期的にMQTTでPublishしたり、状態監視したりするのに、cron定義を作成していたのですが、非常に面倒でした。それが、Webページから定義できるのは楽ちんです。

urlを定義しておけば、あれは、どのポートのどのアドレスに立ち上げたんだっけ?てなことも防げます。

定義した後に、「開始」ボタンを押下すれば、PM2プロセスの管理配下で監視してくれます。もし、定義が間違っていたら、restart_timeやunstable_restartsが増加していくので気づくと思います。

以上です。

8
7
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
7