以前、以下の投稿で、PM2の環境設定と、扱いやすくするためのGUIであるpm2-guiの環境設定をしました。
だけど、まだまだかゆいところに届かず、設定変更するたびに、ドキュメントをあさりまわることが続いていました。
ということで、いっそのこと、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を使うのが初めてだったので、これを覚えることがモチベーションでした。
出来上がり画面はこんな感じです。
ちょっと注意です。
まず、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ページがあります。
アプリ定義を見てみましょう。
参考までに、以下がアプリ定義の登録画面です。
name:自由に定義できる名前です。
script:起動させたいnode.jsファイルです。
cwd: 起動するときのカレントディレクトリを指定します。
url:任意指定です。もしこのアプリを起動した後にブラウザからアクセスできるURLがあればそれを指定します。
cron:任意指定です。アプリを常時起動ではなく、決まった時間に定期起動したい場合に指定します。cron定義と同じです。例えば、10分ごとに起動したい場合は、「*/10 * * * *」と指定します。
autorestart:自動再起動させるかどうかを指定します。基本的にはtrueを指定します。cron指定を使う場合には、次の起動まで停止させたいので、falseにします。
Swagger定義ファイルの定義
該当部分を抜粋します。大したことはないので、細かく見る必要はないです。
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サーバを立ち上げます。以下が参考になります。
追加で、以下の2つのnpmモジュールを使っています。
- express-session
- pm2
以下のようにセットアップしておきます。
npm install --save express-session
npm install --save pm2
まずは、ソースまるごとを載せておきます。
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 : '削除しました'}));
}
};
毎度のヘルパーです。
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の適当な場所に以下を追加しておいてください。
・・・
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
'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を駆使しています。
<!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を駆使しています。
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に指定したパスワードを入力しましょう。
無事にログインできましたでしょうか?
上半分のテーブルには、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が増加していくので気づくと思います。
以上です。