diff --git a/.autod.conf.js b/.autod.conf.js
new file mode 100644
index 0000000..7c5ea4e
--- /dev/null
+++ b/.autod.conf.js
@@ -0,0 +1,30 @@
+'use strict';
+
+module.exports = {
+ write: true,
+ prefix: '^',
+ plugin: 'autod-egg',
+ test: [
+ 'test',
+ 'benchmark',
+ ],
+ dep: [
+ 'egg',
+ 'egg-scripts',
+ ],
+ devdep: [
+ 'egg-ci',
+ 'egg-bin',
+ 'egg-mock',
+ 'autod',
+ 'autod-egg',
+ 'eslint',
+ 'eslint-config-egg',
+ 'webstorm-disable-index',
+ ],
+ exclude: [
+ './test/fixtures',
+ './dist',
+ ],
+};
+
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..3be460f
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,15 @@
+node_modules
+test
+run
+docker
+logs
+.idea
+.npmignore
+.travis.yml
+.gitignore
+.git
+.eslintrc
+.eslintignore
+.autod.conf.js
+*.md
+*.yml
diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..9d08a1a
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,9 @@
+root = true
+
+[*]
+charset = utf-8
+indent_style = space
+indent_size = 2
+end_of_line = lf
+insert_final_newline = true
+trim_trailing_whitespace = true
diff --git a/.eslintignore b/.eslintignore
new file mode 100644
index 0000000..4ebc8ae
--- /dev/null
+++ b/.eslintignore
@@ -0,0 +1 @@
+coverage
diff --git a/.eslintrc b/.eslintrc
new file mode 100644
index 0000000..1156d47
--- /dev/null
+++ b/.eslintrc
@@ -0,0 +1,8 @@
+{
+ "extends": "eslint-config-egg",
+ "parserOptions": {
+ "ecmaFeatures": {
+ "experimentalObjectRestSpread": true
+ }
+ }
+}
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..43ccc33
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,14 @@
+logs/
+npm-debug.log
+yarn-error.log
+node_modules/
+package-lock.json
+yarn.lock
+coverage/
+.nyc_output
+.idea/
+.vscode/
+run/
+.DS_Store
+*.sw*
+*.un~
diff --git a/.sequelizerc b/.sequelizerc
new file mode 100644
index 0000000..366a254
--- /dev/null
+++ b/.sequelizerc
@@ -0,0 +1,9 @@
+'use strict';
+
+const path = require('path');
+
+module.exports = {
+ config: path.join(__dirname, 'database/config.js'),
+ 'migrations-path': path.join(__dirname, 'database/migrations'),
+ 'seeders-path': path.join(__dirname, 'database/seeders'),
+};
diff --git a/.travis.yml b/.travis.yml
new file mode 100644
index 0000000..b20f8dc
--- /dev/null
+++ b/.travis.yml
@@ -0,0 +1,23 @@
+sudo: false
+
+language: node_js
+
+services:
+ - docker
+
+node_js:
+ - '8'
+
+before_install:
+ - docker pull macacajs/reliable-mysql
+ - docker run --rm --name reliable-mysql -p 13306:3306 -d macacajs/reliable-mysql
+ - docker ps -a
+
+install:
+ - npm i npminstall && npminstall
+
+script:
+ - MYSQL_PORT=13306 npm run ci
+
+after_script:
+ - npminstall codecov && codecov
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..7143dbe
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,60 @@
+FROM centos:centos7
+
+RUN yum -y install curl \
+ && mv /etc/yum.repos.d/CentOS-Base.repo /etc/yum.repos.d/CentOS-Base.repo.backup \
+ && curl http://mirrors.163.com/.help/CentOS7-Base-163.repo -o /etc/yum.repos.d/CentOS7-Base-163.repo \
+ && yum clean all \
+ && yum makecache \
+ && yum install -y make \
+ bzip2 \
+ gcc-c++ \
+ ca-certificates \
+ xorg-x11-server-Xvfb \
+ gtk2 \
+ vim \
+ git \
+ gtk2-devel \
+ libXScrnSaver \
+ GConf2 \
+ libXtst.i686 \
+ alsa-lib-devel \
+ libXScrnSaver* \
+ epel-release \
+ libappindicator-gtk3 \
+ libnss3.so \
+ glibc-common \
+ xorg-x11-fonts-Type1 \
+ wqy-microhei-fonts \
+ wqy-zenhei-fonts \
+ thai-scalable-garuda-fonts \
+ cjkuni-ukai-fonts \
+ cjkuni-uming-fonts
+
+# Variable Layer: Node.js etc.
+ENV NODE_VERSION=8.12.0 \
+ NODE_REGISTRY=https://npm.taobao.org/mirrors/node \
+ CHROMEDRIVER_CDNURL=http://npm.taobao.org/mirrors/chromedriver/ \
+ ELECTRON_MIRROR=https://npm.taobao.org/mirrors/electron/ \
+ DISPLAY=':99.0' \
+ NODE_IN_DOCKER=1
+
+RUN curl -SLO "$NODE_REGISTRY/v$NODE_VERSION/node-v$NODE_VERSION-linux-x64.tar.gz" \
+ && tar -xzf "node-v$NODE_VERSION-linux-x64.tar.gz" -C /usr/local --strip-components=1 \
+ && rm "node-v$NODE_VERSION-linux-x64.tar.gz"
+
+COPY . /root/reliable-web
+
+WORKDIR /root/reliable-web
+
+ENV MYSQL_HOST=mysql-host
+
+RUN npm install --production --verbose && ln -s /root/logs .
+
+HEALTHCHECK --interval=10s --retries=6 \
+ CMD wget -O /dev/null localhost:9900 || echo 1
+
+ENTRYPOINT ["./entrypoint.sh"]
+
+EXPOSE 9900
+
+CMD ["npm", "start"]
\ No newline at end of file
diff --git a/LICENCE b/LICENCE
new file mode 100644
index 0000000..3b24011
--- /dev/null
+++ b/LICENCE
@@ -0,0 +1,22 @@
+MIT LICENSE
+
+Copyright (c) 2018 Alibaba Group Holding Limited and other contributors.
+
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of this software and associated documentation files (the
+"Software"), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so, subject to
+the following conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..a2d99c0
--- /dev/null
+++ b/README.md
@@ -0,0 +1,44 @@
+# Reliable
+
+[中文版](README.zh-CN.md)
+
+
+
+
+
+
+
+---
+
+[![build status][travis-image]][travis-url]
+[![Test coverage][codecov-image]][codecov-url]
+[![node version][node-image]][node-url]
+[![docker pull][docker-pull-image]][docker-url]
+[![docker layers][docker-layers-image]][docker-url]
+[![docker-size][docker-size-image]][docker-url]
+
+[travis-image]: https://img.shields.io/travis/macacajs/reliable/master.svg?style=flat-square&logo=travis
+[travis-url]: https://travis-ci.org/macacajs/reliable
+[codecov-image]: https://img.shields.io/codecov/c/github/macacajs/reliable/master.svg?style=flat-square
+[codecov-url]: https://codecov.io/gh/macacajs/reliable
+[node-image]: https://img.shields.io/badge/node.js-%3E=_8-green.svg?style=flat-square
+[node-url]: http://nodejs.org/download/
+[docker-pull-image]: https://img.shields.io/docker/pulls/macacajs/reliable-web.svg?style=flat-square&logo=dockbit
+[docker-layers-image]: https://img.shields.io/microbadger/layers/macacajs/reliable-web.svg?style=flat-square&logo=dockbit
+[docker-size-image]: https://img.shields.io/microbadger/image-size/macacajs/reliable-web.svg?style=flat-square&logo=dockbit
+[docker-url]: https://hub.docker.com/r/macacajs/reliable-web/
+
+> Testing management suite with continuous delivery support.
+
+## Docs
+
+- [CLI Usage](//github.com/macacajs/reliable-cli)
+- [Development](./docker/reliable-web#development)
+
+## License
+
+The MIT License (MIT)
diff --git a/README.zh-CN.md b/README.zh-CN.md
new file mode 100644
index 0000000..3d3e0f2
--- /dev/null
+++ b/README.zh-CN.md
@@ -0,0 +1,10 @@
+# Reliable
+
+---
+
+> 持续交付测试套件
+
+## 文档
+
+- [命令行客户端](//github.com/macacajs/reliable-cli)
+- [开发 Reliable](./docker/reliable-web#development)
diff --git a/app/common/error/index.js b/app/common/error/index.js
new file mode 100644
index 0000000..64e37e6
--- /dev/null
+++ b/app/common/error/index.js
@@ -0,0 +1,22 @@
+'use strict';
+
+// key: error code
+// value: error details
+// value.message: default error message
+module.exports = new Map([
+ [
+ 'ERR_RELIABLE_INTERNAL_SERVER_ERROR', {
+ message: 'Internal server error.',
+ },
+ ],
+ [
+ 'ERR_RELIABLE_INVALID_PARAM_ERROR', {
+ message: 'Invalid parameters.',
+ },
+ ],
+ [
+ 'ERR_RELIABLE_BUILD_RECORD_NOT_FOUND', {
+ message: 'Build record not found.',
+ },
+ ]
+]);
diff --git a/app/common/snsAuthorize/dingtalkAuth.js b/app/common/snsAuthorize/dingtalkAuth.js
new file mode 100644
index 0000000..1497f59
--- /dev/null
+++ b/app/common/snsAuthorize/dingtalkAuth.js
@@ -0,0 +1,99 @@
+'use strict';
+
+const querystring = require('querystring');
+const debug = require('debug')('reliable:common:dingtalkAuth');
+
+module.exports = class DingtalkAuth {
+ constructor({ ctx, appid, appsecret }) {
+ this.ctx = ctx;
+ this.appid = appid;
+ this.appsecret = appsecret;
+ }
+
+ async getAuthData({ tmpAuthCode }) {
+ const accessTokenInfo = await this._getAccessToken();
+ debug(accessTokenInfo);
+ const persistentCodeInfo = await this._getPersistentCode({
+ accessToken: accessTokenInfo.access_token,
+ tmpAuthCode,
+ });
+ debug(persistentCodeInfo);
+ const snsTokenInfo = await this._getSnsToken({
+ accessToken: accessTokenInfo.access_token,
+ openid: persistentCodeInfo.openid,
+ persistentCode: persistentCodeInfo.persistent_code,
+ });
+ debug(snsTokenInfo);
+ const userInfo = await this._getUserInfo({
+ snsToken: snsTokenInfo.sns_token,
+ });
+ debug(userInfo);
+ const {
+ nick,
+ unionid,
+ openid,
+ } = userInfo.user_info || {};
+ return {
+ nick,
+ unionid,
+ openid,
+ };
+ }
+
+ async _getAccessToken() {
+ const query = querystring.stringify({
+ appid: this.appid,
+ appsecret: this.appsecret,
+ });
+ debug(query);
+ const requestAccessToken = await this.ctx.curl(`https://oapi.dingtalk.com/sns/gettoken?${query}`, {
+ dataType: 'json',
+ });
+ return requestAccessToken.data;
+ }
+
+ async _getPersistentCode({ accessToken, tmpAuthCode }) {
+ const query = querystring.stringify({
+ access_token: accessToken,
+ });
+ debug(query);
+ const requestPersistentCode = await this.ctx.curl(`https://oapi.dingtalk.com/sns/get_persistent_code?${query}`, {
+ method: 'POST',
+ dataType: 'json',
+ contentType: 'json',
+ data: {
+ tmp_auth_code: tmpAuthCode,
+ },
+ });
+ return requestPersistentCode.data;
+ }
+
+ async _getSnsToken({ accessToken, openid, persistentCode }) {
+ const query = querystring.stringify({
+ access_token: accessToken,
+ });
+ debug(query);
+ const requestSnsToken = await this.ctx.curl(`https://oapi.dingtalk.com/sns/get_sns_token?${query}`, {
+ method: 'POST',
+ dataType: 'json',
+ contentType: 'json',
+ data: {
+ openid,
+ persistent_code: persistentCode,
+ },
+ });
+ return requestSnsToken.data;
+ }
+
+ async _getUserInfo({ snsToken }) {
+ const query = querystring.stringify({
+ sns_token: snsToken,
+ });
+ debug(query);
+ const requestUserInfo = await this.ctx.curl(`https://oapi.dingtalk.com/sns/getuserinfo?${query}`, {
+ dataType: 'json',
+ contentType: 'json',
+ });
+ return requestUserInfo.data;
+ }
+};
diff --git a/app/constants/build.js b/app/constants/build.js
new file mode 100644
index 0000000..d13fbce
--- /dev/null
+++ b/app/constants/build.js
@@ -0,0 +1,6 @@
+'use strict';
+
+module.exports = {
+ BUILD_STATE_INIT: 'INIT',
+ BUILD_STATE_SUCCESS: 'SUCCESS',
+};
diff --git a/app/controller/app.js b/app/controller/app.js
new file mode 100644
index 0000000..22c5221
--- /dev/null
+++ b/app/controller/app.js
@@ -0,0 +1,89 @@
+'use strict';
+
+const {
+ Controller,
+} = require('egg');
+const debug = require('debug')('reliable:controller:app');
+
+const DEFAULT_BRANCH_QUERY_DAYS_RANGE = 30;
+
+class AppController extends Controller {
+ async show() {
+ const ctx = this.ctx;
+ ctx.validate({
+ bucketTag: { type: 'string' },
+ type: { type: 'string' },
+ }, ctx.query);
+ debug(ctx.query);
+
+ const Op = ctx.app.Sequelize.Op;
+ const branches = await ctx.model.Build.findAll({
+ where: {
+ createdAt: {
+ [Op.gte]: ctx.moment().subtract(DEFAULT_BRANCH_QUERY_DAYS_RANGE, 'days').toDate(),
+ },
+ },
+ attributes: [ 'gitBranch', 'uniqId', 'createdAt' ],
+ order: [[ 'createdAt', 'DESC' ]],
+ });
+ if (!branches.length) {
+ return;
+ }
+ const uniqBranchMap = branches.reduce((map, branch) => {
+ if (!(map.has(branch.gitBranch))) map.set(branch.gitBranch, branch.uniqId);
+ return map;
+ }, new Map());
+ const buildUniqIds = Array.from(uniqBranchMap.values());
+
+ const buildList = await ctx.model.Build.findAll({
+ where: {
+ uniqId: {
+ [Op.in]: buildUniqIds,
+ },
+ },
+ order: [
+ [
+ 'createdAt',
+ 'DESC',
+ ],
+ ],
+ });
+
+ if (!buildList.length) {
+ return;
+ }
+
+ const latestBuild = buildList[0];
+
+ const builds = [];
+
+ for (const build of buildList) {
+ const data = build.get({ plain: true });
+ let size;
+ const packages = ctx.safeGet(data, 'data.packages') || [];
+ if (packages.length) {
+ const pkg = packages.find(i => i.type === type);
+ size = pkg && pkg.size;
+ }
+ const appBuildData = {
+ uniqId: data.uniqId,
+ version: ctx.safeGet(data, 'data.packages[0].version'),
+ size,
+ gitBranch: data.gitBranch,
+ gitCommitInfo: ctx.safeGet(data, 'data.gitCommitInfo'),
+ testInfo: ctx.safeGet(data, 'data.testInfo'),
+ extendInfo: data.extendInfo || {},
+ state: data.state,
+ createdAt: data.createdAt,
+ };
+ builds.push(appBuildData);
+ }
+
+ ctx.success({
+ gitRepo: ctx.safeGet(latestBuild, 'data.gitCommitInfo.gitUrl'),
+ builds,
+ });
+ }
+}
+
+module.exports = AppController;
diff --git a/app/controller/build.js b/app/controller/build.js
new file mode 100644
index 0000000..4ee0312
--- /dev/null
+++ b/app/controller/build.js
@@ -0,0 +1,150 @@
+'use strict';
+
+const {
+ Controller,
+} = require('egg');
+
+const UPDATE_FIELDS = [
+ 'extendInfo',
+];
+
+class BuildController extends Controller {
+ async show() {
+ const ctx = this.ctx;
+ const uniqId = ctx.params.uniqId;
+ const res = await ctx.service.build.queryBuildByUniqId({
+ uniqId,
+ });
+ ctx.success(res);
+ }
+
+ async query() {
+ const ctx = this.ctx;
+ const page = Number(ctx.query.page) || 1;
+ const num = Number(ctx.query.num) || ctx.app.config.modelQueryConfig.pagination.num;
+
+ const { jobName, buildNumber } = ctx.query;
+
+ let res;
+ if (jobName) {
+ if (buildNumber) {
+ res = await ctx.service.build.queryByJobNameAndBuildNumber({
+ jobName,
+ buildNumber,
+ });
+ } else {
+ res = await ctx.service.build.queryByJobName({
+ jobName,
+ page, num,
+ });
+ }
+ } else {
+ res = await ctx.service.build.queryAllBuilds({ page, num });
+ }
+
+ if (res.success === false) {
+ ctx.fail(res.code);
+ return;
+ }
+ ctx.body = res;
+ }
+
+ async queryLatestByJobNameAndGitBranch() {
+ const jobName = this.ctx.params.jobName;
+ const gitBranch = this.ctx.params.gitBranch;
+ const result = await this.ctx.model.Build.findAll({
+ limit: 5,
+ where: {
+ jobName,
+ gitBranch,
+ },
+ order: [
+ [
+ 'createdAt',
+ 'DESC',
+ ],
+ ],
+ });
+ this.ctx.success({
+ result,
+ });
+ }
+
+ async update() {
+ const ctx = this.ctx;
+ const uniqId = ctx.params.uniqId;
+
+ const requestData = ctx.request.body;
+ const payload = {};
+ for (const key of UPDATE_FIELDS) {
+ if (!(key in requestData)) continue;
+ payload[key] = requestData[key];
+ }
+ const queryRes = await ctx.service.build.queryBuildByUniqId({
+ uniqId,
+ });
+ if (!queryRes) {
+ ctx.fail('ERR_RELIABLE_BUILD_RECORD_NOT_FOUND');
+ return;
+ }
+ if (!Object.keys(payload).length) {
+ ctx.fail('ERR_RELIABLE_INVALID_PARAM_ERROR');
+ return;
+ }
+ const res = await ctx.service.build.updateBuild({
+ uniqId,
+ payload,
+ });
+ ctx.success(res);
+ }
+
+ async patch() {
+ const ctx = this.ctx;
+ const uniqId = ctx.params.uniqId;
+
+ const requestData = ctx.request.body;
+ const currentData = await ctx.service.build.queryBuildByUniqId({
+ uniqId,
+ });
+ if (!currentData) {
+ ctx.fail('ERR_RELIABLE_BUILD_RECORD_NOT_FOUND');
+ return;
+ }
+
+ const payload = {};
+ for (const key of UPDATE_FIELDS) {
+ if (!(key in requestData)) continue;
+ const currentValue = currentData[key];
+ if (!currentValue) {
+ payload[key] = requestData[key];
+ continue;
+ }
+ if (Array.isArray(currentValue)) {
+ payload[key] = [
+ ...currentValue,
+ ...requestData[key],
+ ];
+ continue;
+ }
+ if (typeof currentValue === 'object') {
+ payload[key] = Object.assign({},
+ currentValue,
+ requestData[key]
+ );
+ continue;
+ }
+ payload[key] = requestData[key];
+ }
+ if (!Object.keys(payload).length) {
+ ctx.fail('ERR_RELIABLE_INVALID_PARAM_ERROR');
+ return;
+ }
+ const res = await ctx.service.build.updateBuild({
+ uniqId,
+ payload,
+ });
+ ctx.success(res);
+ }
+}
+
+module.exports = BuildController;
diff --git a/app/controller/config.js b/app/controller/config.js
new file mode 100644
index 0000000..806fabe
--- /dev/null
+++ b/app/controller/config.js
@@ -0,0 +1,34 @@
+'use strict';
+
+const {
+ Controller,
+} = require('egg');
+
+class ConfigController extends Controller {
+ async show() {
+ const result = await this.ctx.model.Config.findOne();
+ this.ctx.success(result ? result.data : {});
+ }
+
+ async update() {
+ const oldResult = await this.ctx.model.Config.findOne();
+
+ if (!oldResult) {
+ await this.ctx.model.Config.create({
+ data: this.ctx.request.body,
+ });
+ } else {
+ const uniqId = oldResult.uniqId;
+ await this.ctx.model.Config.update({
+ data: Object.assign(oldResult.data, this.ctx.request.body),
+ }, {
+ where: {
+ uniqId,
+ },
+ });
+ }
+ this.ctx.success();
+ }
+}
+
+module.exports = ConfigController;
diff --git a/app/controller/delegate.js b/app/controller/delegate.js
new file mode 100644
index 0000000..c1b0ff3
--- /dev/null
+++ b/app/controller/delegate.js
@@ -0,0 +1,28 @@
+'use strict';
+
+const {
+ Controller,
+} = require('egg');
+
+class DelegateController extends Controller {
+
+ async message() {
+ const ctx = this.ctx;
+ const {
+ webhook,
+ text,
+ title,
+ } = ctx.request.body;
+
+ await ctx.helper.sendMarkdown({
+ webhook,
+ title: title || 'title',
+ text,
+ });
+
+ ctx.success({});
+ }
+
+}
+
+module.exports = DelegateController;
diff --git a/app/controller/gw.js b/app/controller/gw.js
new file mode 100644
index 0000000..5159a59
--- /dev/null
+++ b/app/controller/gw.js
@@ -0,0 +1,81 @@
+'use strict';
+
+const {
+ Controller,
+} = require('egg');
+const debug = require('debug')('reliable:controller:gw');
+
+const {
+ BUILD_STATE_INIT,
+ BUILD_STATE_SUCCESS,
+} = require('../constants/build');
+
+class GwController extends Controller {
+ async index() {
+ const ctx = this.ctx;
+ const { state = BUILD_STATE_SUCCESS, ...data } = ctx.request.body;
+ // TODO remove
+ const jobName = ctx.safeGet(data, 'environment.ci.JOB_NAME')
+ || ctx.safeGet(data, 'environment.gitlab_ci.JOB_NAME');
+ const buildNumber = ctx.safeGet(data, 'environment.ci.BUILD_NUMBER')
+ || ctx.safeGet(data, 'environment.gitlab_ci.BUILD_NUMBER');
+ if (!jobName || !buildNumber) {
+ ctx.fail('ERR_RELIABLE_INVALID_PARAM_ERROR', 'environment.ci.JOB_NAME and environment.ci.BUILD_NUMBER are required.');
+ return;
+ }
+ const gitBranch = ctx.safeGet(data, 'gitCommitInfo.gitBranch');
+ if (!gitBranch) {
+ ctx.fail('ERR_RELIABLE_INVALID_PARAM_ERROR', 'gitCommitInfo.gitBranch is required.');
+ return;
+ }
+ debug('jobName %s, buildNumber %s', jobName, buildNumber);
+ await ctx.model.JobName.findOrCreate({
+ where: {
+ jobName,
+ },
+ defaults: {
+ jobName,
+ },
+ });
+
+ let upsertResult = {};
+
+ const build = await ctx.model.Build.findOne({
+ where: {
+ buildNumber,
+ jobName,
+ },
+ });
+
+ if (build) {
+ debug('update build to finished', build.uniqId);
+ upsertResult = await ctx.service.build.finishBuild({
+ uniqId: build.uniqId,
+ payload: {
+ buildNumber,
+ jobName,
+ gitBranch,
+ data,
+ state,
+ finishedAt: Date.now(),
+ },
+ });
+ } else {
+ debug('insert new build');
+ upsertResult = await ctx.model.Build.create({
+ buildNumber,
+ jobName,
+ gitBranch,
+ data,
+ state,
+ });
+ }
+
+ if (state !== BUILD_STATE_INIT) {
+ await ctx.service.webhook.pushBuildNotification(data);
+ }
+ ctx.success(upsertResult);
+ }
+}
+
+module.exports = GwController;
diff --git a/app/controller/home.js b/app/controller/home.js
new file mode 100644
index 0000000..38744a3
--- /dev/null
+++ b/app/controller/home.js
@@ -0,0 +1,29 @@
+'use strict';
+
+const {
+ Controller,
+} = require('egg');
+
+class HomeController extends Controller {
+ async index() {
+ const ctx = this.ctx;
+ const user = ctx.session.user;
+ const { appid, callbackUrl } = ctx.app.config.authorize.dingtalkAuth;
+ ctx.body = await this.app.render({
+ dingtalkAuth: {
+ appid,
+ callbackUrl,
+ },
+ user,
+ }, {
+ title: 'Reliable Platform',
+ pageId: 'home',
+ SERVER_ADDRESS: this.config.reliableView.serverUrl,
+ STATIC_ADDRESS: this.config.reliableView.staticUrl,
+ assetsUrl: this.config.reliableView.assetsUrl,
+ version: this.app.config.pkg.version,
+ });
+ }
+}
+
+module.exports = HomeController;
diff --git a/app/controller/insight.js b/app/controller/insight.js
new file mode 100644
index 0000000..675c35b
--- /dev/null
+++ b/app/controller/insight.js
@@ -0,0 +1,164 @@
+'use strict';
+
+const {
+ Controller,
+} = require('egg');
+
+class InsightController extends Controller {
+ _fixedNumber(x) {
+ return Number(Number.parseFloat(x).toFixed(2));
+ }
+
+ _getCommitUrl(record) {
+ const ctx = this.ctx;
+ const gitUrl = ctx.safeGet(record, 'data.gitCommitInfo.gitUrl');
+ const shortHash = ctx.safeGet(record, 'data.gitCommitInfo.shortHash');
+ return (gitUrl && shortHash) ? `${gitUrl}/commit/${shortHash}` : null;
+ }
+
+ _normalizeCoverageUrl(record) {
+ const ctx = this.ctx;
+ const path = ctx.safeGet(record, 'data.testInfo.coverageHtmlReporterPath');
+ return this._normalizeResourceUrl(record.data, path);
+ }
+ _normalizeReporterUrl(record) {
+ const ctx = this.ctx;
+ const path = ctx.safeGet(record, 'data.testInfo.testHtmlReporterPath');
+ return this._normalizeResourceUrl(record.data, path);
+ }
+ _normalizeResourceUrl(data, target) {
+ if (!target) {
+ return '';
+ }
+ if (target.startsWith('http://') || target.startsWith('https://')) {
+ return target;
+ }
+ return;
+ }
+
+ async ci() {
+ const ctx = this.ctx;
+ const { startDate = '', endDate = '', allBranches = 'Y' } = ctx.query;
+ const allJobName = await ctx.model.JobName.findAll({
+ attributes: [
+ 'jobName',
+ ],
+ }).map(i => i.jobName);
+
+ const Op = ctx.app.Sequelize.Op;
+ const result = await Promise.all(allJobName.map(async jobName => {
+ const findOptions = {
+ where: {
+ jobName,
+ state: 'SUCCESS',
+ },
+ attributes: [
+ 'jobName',
+ 'buildNumber',
+ 'gitBranch',
+ 'data',
+ 'createdAt',
+ 'finishedAt',
+ ],
+ limit: 100,
+ order: [[ 'createdAt', 'DESC' ]],
+ };
+ if (allBranches === 'N') {
+ findOptions.where.gitBranch = 'master';
+ }
+ if (startDate && endDate) {
+ findOptions.where.createdAt = {
+ [Op.between]: [
+ ctx.moment(startDate).toDate(),
+ ctx.moment(endDate).toDate(),
+ ],
+ };
+ }
+ const res = await ctx.model.Build.findAll(findOptions);
+ if (res.length === 0) return null;
+
+ const lastCommit = {
+ committer: ctx.safeGet(res, '[0].data.gitCommitInfo.committer.name'),
+ shortHash: ctx.safeGet(res, '[0].data.gitCommitInfo.shortHash'),
+ commitUrl: this._getCommitUrl(res[0]),
+ };
+
+ let linePercentCount = 0;
+ let passTimeSumCount = 0;
+ let durationCount = 0;
+
+ const linePercentList = [];
+ const passPercentList = [];
+ const durationList = [];
+
+ const linePercentSum = res.reduce((value, i) => {
+ if (i.data.testInfo.linePercent && !Number.isNaN(i.data.testInfo.linePercent)) {
+ linePercentCount++;
+ linePercentList.push({
+ commitUrl: this._getCommitUrl(i),
+ shortHash: ctx.safeGet(i, 'data.gitCommitInfo.shortHash'),
+ coverageUrl: this._normalizeCoverageUrl(i),
+ linePercent: ctx.safeGet(i, 'data.testInfo.linePercent'),
+ createdAt: ctx.moment(i.createdAt).fromNow(),
+ });
+ return Number.parseFloat(i.data.testInfo.linePercent) + value;
+ }
+ return value;
+ }, 0);
+ const passTimesSum = res.reduce((value, i) => {
+ if (i.data.testInfo.passPercent && !Number.isNaN(i.data.testInfo.passPercent)) {
+ passTimeSumCount++;
+ passPercentList.push({
+ commitUrl: this._getCommitUrl(i),
+ shortHash: ctx.safeGet(i, 'data.gitCommitInfo.shortHash'),
+ reporterUrl: this._normalizeReporterUrl(i),
+ passPercent: ctx.safeGet(i, 'data.testInfo.passPercent'),
+ createdAt: ctx.moment(i.createdAt).fromNow(),
+ });
+ if (Number.parseFloat(i.data.testInfo.passPercent) === 100) {
+ return 1 + value;
+ }
+ }
+ return value;
+
+ }, 0);
+ const durationSum = res.reduce((value, i) => {
+ if (i.finishedAt - i.createdAt > 0) {
+ durationCount++;
+ durationList.push({
+ commitUrl: this._getCommitUrl(i),
+ shortHash: ctx.safeGet(i, 'data.gitCommitInfo.shortHash'),
+ duration: ctx.moment.duration(i.finishedAt - i.createdAt).humanize(),
+ createdAt: ctx.moment(i.createdAt).fromNow(),
+ });
+ return i.finishedAt - i.createdAt + value;
+ }
+ return value;
+ }, 0);
+
+ const linePercent = linePercentCount > 0 ? this._fixedNumber(linePercentSum / linePercentCount) : linePercentCount;
+ const passPercent = passTimeSumCount > 0 ? this._fixedNumber(passTimesSum / passTimeSumCount * 100) : passTimeSumCount;
+ const humanizeDuration = durationCount > 0
+ ? ctx.moment.duration(durationSum / durationCount).humanize()
+ : durationCount;
+
+ return {
+ jobName,
+ linePercent,
+ passPercent,
+ lastCommit,
+ humanizeDuration,
+ linePercentList,
+ passPercentList,
+ durationList,
+ };
+ }));
+
+ this.ctx.body = {
+ success: true,
+ data: result.filter(i => i !== null),
+ };
+ }
+}
+
+module.exports = InsightController;
diff --git a/app/controller/snsAuthorize.js b/app/controller/snsAuthorize.js
new file mode 100644
index 0000000..8b1c363
--- /dev/null
+++ b/app/controller/snsAuthorize.js
@@ -0,0 +1,38 @@
+'use strict';
+
+const {
+ Controller,
+} = require('egg');
+
+const debug = require('debug')('reliable:controller:snsAuthorize');
+const DingtalkAuth = require('../common/snsAuthorize/dingtalkAuth');
+
+class snsAuthorizeController extends Controller {
+ async signOut() {
+ const ctx = this.ctx;
+ debug(ctx.session);
+ ctx.session = null;
+ ctx.redirect('/');
+ }
+
+ async dingtalkCallback() {
+ const ctx = this.ctx;
+ const { appid, appsecret } = ctx.app.config.authorize.dingtalkAuth;
+ this.dingtalkAuth = new DingtalkAuth({
+ ctx,
+ appid,
+ appsecret,
+ });
+ const { code: tmpAuthCode } = ctx.query;
+ const userInfo = await this.dingtalkAuth.getAuthData({ tmpAuthCode });
+ debug(userInfo);
+ if (!userInfo.openid) {
+ ctx.redirect('back');
+ return;
+ }
+ ctx.session.user = userInfo;
+ ctx.redirect('back');
+ }
+}
+
+module.exports = snsAuthorizeController;
diff --git a/app/extend/application.js b/app/extend/application.js
new file mode 100644
index 0000000..2c89d45
--- /dev/null
+++ b/app/extend/application.js
@@ -0,0 +1,9 @@
+'use strict';
+
+const render = require('../../view/lib/render');
+
+module.exports = {
+ render (context, options) {
+ return render(context, options);
+ },
+};
\ No newline at end of file
diff --git a/app/extend/helper.js b/app/extend/helper.js
new file mode 100644
index 0000000..460b105
--- /dev/null
+++ b/app/extend/helper.js
@@ -0,0 +1,88 @@
+'use strict';
+
+const debug = require('debug')('reliable:reliable-dingtalk');
+const ChatBot = require('dingtalk-robot-sender');
+
+const sendMarkdown = async (options) => {
+ debug(options);
+ const robot = new ChatBot({
+ webhook: options.webhook.url,
+ });
+ if (options.isRawMarkdown) {
+ await robot.markdown(options.title, options.text);
+ return;
+ }
+ await robot.markdown(options.title, options.text.join('\n\n'));
+};
+
+module.exports = {
+ sendMarkdown,
+ sendDingTalk: async function ({
+ webhook,
+ data,
+ staticServerUrl,
+ reliableServerUrl,
+ }) {
+ const {
+ gitCommitInfo = {},
+ environment = {},
+ testInfo = {},
+ packages = [],
+ } = data;
+
+ const { ci } = environment;
+
+ const title = `[Reliable] **${ci.JOB_NAME}** build passed.`;
+
+ // message body
+ let text = [];
+ const gitUrl = `${gitCommitInfo.gitUrl}`;
+ text.push(`### Repository ${ci.JOB_NAME} build passed`);
+ text.push('');
+ text.push(`#### Platform: ${environment.platform}`);
+ text.push('#### Commit');
+ text.push(`[${gitCommitInfo.shortHash}](${gitUrl}/commit/${gitCommitInfo.hash}): ${gitCommitInfo.subject}`);
+ text.push(`> committer:[@${gitCommitInfo.committer.name}]() author:[@${gitCommitInfo.author.name}]()`);
+
+ // test report info
+
+ text.push('#### Test report');
+ const staticUrl = `${staticServerUrl}/jenkins/${ci.JOB_NAME}/${ci.BUILD_NUMBER}/`;
+ const buildStaticPath = path => /^https?:\/\//.test(path) ? path : staticUrl + path;
+ const passLogUrl = buildStaticPath(testInfo.testHtmlReporterPath);
+
+ if (testInfo && testInfo.tests) {
+ text.push(`> Test Cases: [(${testInfo.passes}/${testInfo.tests}) ${testInfo.passPercent}% passed](${passLogUrl})`);
+ }
+
+ const covUrl = buildStaticPath(testInfo.coverageHtmlReporterPath);
+
+ if (testInfo && testInfo.linePercent) {
+ text.push(`> Line Coverage: [${testInfo.linePercent}%](${covUrl})`);
+ }
+
+ // release
+ text.push('#### Release');
+ const pkgText = packages.map(i => i && `> [${i.type}-${i.version}](${buildStaticPath(i.path)})`);
+
+ if (pkgText.length) {
+ text = text.concat(pkgText);
+ } else {
+ text.push('> none');
+ }
+
+ if (ci.BUILD_NUMBER) {
+ text.push(`[> See details on reliable-web](${reliableServerUrl}/buildinfo?jobName=${ci.JOB_NAME}&buildNumber=${ci.BUILD_NUMBER})`);
+ }
+
+ try {
+ await sendMarkdown({
+ webhook,
+ title,
+ text,
+ });
+ } catch (e) {
+ console.log(e.stack);
+ }
+ }
+};
diff --git a/app/middleware/authorize.js b/app/middleware/authorize.js
new file mode 100644
index 0000000..f1cec6b
--- /dev/null
+++ b/app/middleware/authorize.js
@@ -0,0 +1,29 @@
+'use strict';
+
+const debug = require('debug')('reliable:middleware:authorize');
+
+module.exports = () => {
+ return async function authorize(ctx, next) {
+ const host = ctx.host;
+ if (host.startsWith('127.0.0.1')) {
+ await next();
+ return;
+ }
+ const user = ctx.session.user;
+ const path = ctx.path;
+ debug('path %s user %o', path, user);
+ const authUrl = '/snsAuthorize/auth';
+ if (!user) {
+ if (ctx.acceptJSON) {
+ ctx.status = 401;
+ ctx.body = {
+ url: authUrl,
+ };
+ return;
+ }
+ ctx.redirect(authUrl);
+ return;
+ }
+ await next();
+ };
+};
diff --git a/app/middleware/cors.js b/app/middleware/cors.js
new file mode 100644
index 0000000..6e22f01
--- /dev/null
+++ b/app/middleware/cors.js
@@ -0,0 +1,23 @@
+'use strict';
+
+module.exports = () => {
+ return async function cors(ctx, next) {
+ const origin = ctx.get('origin');
+ if (!origin) {
+ return await next();
+ }
+
+ ctx.set('Access-Control-Allow-Origin', origin);
+ ctx.set('Access-Control-Allow-Credentials', 'true');
+
+ if (ctx.method !== 'OPTIONS') {
+ return await next();
+ }
+
+ // preflight OPTIONS request
+ ctx.set('Access-Control-Allow-Headers', ctx.get('Access-Control-Request-Headers'));
+ ctx.set('Access-Control-Allow-Methods', 'GET,HEAD,PUT,POST,DELETE,PATCH');
+ ctx.set('Access-Control-Max-Age', 10 * 60 * 1000);
+ ctx.status = 204;
+ };
+};
diff --git a/app/middleware/error_handler.js b/app/middleware/error_handler.js
new file mode 100644
index 0000000..82a0867
--- /dev/null
+++ b/app/middleware/error_handler.js
@@ -0,0 +1,24 @@
+'use strict';
+
+module.exports = () => {
+ return async function errorHandler(ctx, next) {
+ try {
+ await next();
+ } catch (e) {
+
+ ctx.logger.error(e);
+
+ let message = e.status === 500 && ctx.app.config.env === 'prod'
+ ? 'Internal Server Error'
+ : e.message;
+
+ if (e.code === 'invalid_param') {
+ message += `, ${e.errors.map(e => `${e.field}: ${e.message}`).join(', ')}`;
+ ctx.fail('ERR_RELIABLE_INVALID_PARAM_ERROR', message);
+ return;
+ }
+
+ ctx.fail('ERR_RELIABLE_INTERNAL_SERVER_ERROR', message);
+ }
+ };
+};
diff --git a/app/middleware/host_redirect.js b/app/middleware/host_redirect.js
new file mode 100644
index 0000000..f616e0a
--- /dev/null
+++ b/app/middleware/host_redirect.js
@@ -0,0 +1,23 @@
+'use strict';
+
+const debug = require('debug')('reliable:middleware:hostRedirect');
+
+module.exports = options => {
+ return async function hostRedirect(ctx, next) {
+ const defaultHost = options.defaultHost;
+ debug(defaultHost);
+ if (!defaultHost) {
+ await next();
+ return;
+ }
+ if (ctx.host !== defaultHost) {
+ ctx.logger.info('[reliable-middleware-hostRedirect] protocol %s host %s defaultHost %s',
+ ctx.protocol, ctx.host, defaultHost);
+ // use http by default, more flexiable with options
+ // ctx.redirect(`${ctx.protocol}://${defaultHost}`);
+ ctx.redirect(`http://${defaultHost}`);
+ return;
+ }
+ await next();
+ };
+};
diff --git a/app/middleware/inject.js b/app/middleware/inject.js
new file mode 100644
index 0000000..99a9e17
--- /dev/null
+++ b/app/middleware/inject.js
@@ -0,0 +1,38 @@
+'use strict';
+
+const moment = require('moment');
+const crypto = require('crypto');
+const get = require('lodash.get');
+const errors = require('../common/error');
+const defaultErrorCode = 'ERR_RELIABLE_INTERNAL_SERVER_ERROR';
+
+module.exports = () => {
+ return async function inject(ctx, next) {
+ ctx.moment = moment;
+ ctx.safeGet = get;
+ ctx.toMd5 = string => {
+ return crypto.createHash('md5')
+ .update(string, 'utf8')
+ .digest('hex');
+ };
+ ctx.success = (data = {}) => {
+ ctx.body = {
+ success: true,
+ data,
+ };
+ };
+
+ ctx.fail = (errorCode = defaultErrorCode, message = '') => {
+ if (!errors.has(errorCode)) {
+ errorCode = defaultErrorCode;
+ }
+ const defaultMessage = errors.get(errorCode).message;
+ ctx.body = {
+ success: false,
+ errorCode,
+ message: message || defaultMessage,
+ };
+ };
+ await next();
+ };
+};
diff --git a/app/middleware/open_api_authorize.js b/app/middleware/open_api_authorize.js
new file mode 100644
index 0000000..3afca2a
--- /dev/null
+++ b/app/middleware/open_api_authorize.js
@@ -0,0 +1,18 @@
+'use strict';
+
+const debug = require('debug')('reliable:middleware:openApiAuthorize');
+
+module.exports = () => {
+ return async function openApiAuthorize(ctx, next) {
+ const origin = ctx.get('origin');
+ const path = ctx.path;
+ debug('path %s origin %s', path, origin);
+ ctx.logger.info('[reliable-middleware-openApiAuthorize] path %s origin %s', path, origin);
+ if (!origin) {
+ await next();
+ return;
+ }
+ await next();
+ return;
+ };
+};
diff --git a/app/model/build.js b/app/model/build.js
new file mode 100644
index 0000000..af7d066
--- /dev/null
+++ b/app/model/build.js
@@ -0,0 +1,43 @@
+'use strict';
+
+module.exports = app => {
+ const {
+ STRING,
+ UUID,
+ UUIDV4,
+ JSON,
+ ENUM,
+ DATE,
+ } = app.Sequelize;
+
+ const Build = app.model.define('build', {
+ jobName: {
+ type: STRING,
+ },
+ buildNumber: {
+ type: STRING,
+ },
+ gitBranch: {
+ type: STRING,
+ },
+ data: JSON,
+ uniqId: {
+ type: UUID,
+ defaultValue: UUIDV4,
+ primaryKey: true,
+ },
+ extendInfo: {
+ type: JSON,
+ defaultValue: {},
+ },
+ state: {
+ type: ENUM,
+ values: [ 'INIT', 'SUCCESS', 'FAIL' ],
+ },
+ finishedAt: {
+ type: DATE,
+ },
+ }, { });
+
+ return Build;
+};
diff --git a/app/model/config.js b/app/model/config.js
new file mode 100644
index 0000000..2ada281
--- /dev/null
+++ b/app/model/config.js
@@ -0,0 +1,20 @@
+'use strict';
+
+module.exports = app => {
+ const {
+ UUID,
+ UUIDV4,
+ JSON,
+ } = app.Sequelize;
+
+ const Config = app.model.define('config', {
+ data: JSON,
+ uniqId: {
+ type: UUID,
+ defaultValue: UUIDV4,
+ primaryKey: true,
+ },
+ }, { });
+
+ return Config;
+};
diff --git a/app/model/jobName.js b/app/model/jobName.js
new file mode 100644
index 0000000..a141e84
--- /dev/null
+++ b/app/model/jobName.js
@@ -0,0 +1,24 @@
+'use strict';
+
+module.exports = app => {
+ const {
+ STRING,
+ UUID,
+ UUIDV4,
+ } = app.Sequelize;
+
+ const JobName = app.model.define('jobName', {
+ jobName: {
+ type: STRING,
+ allowNull: false,
+ unique: true,
+ },
+ uniqId: {
+ type: UUID,
+ defaultValue: UUIDV4,
+ primaryKey: true,
+ },
+ }, { });
+
+ return JobName;
+};
diff --git a/app/router.js b/app/router.js
new file mode 100644
index 0000000..087390e
--- /dev/null
+++ b/app/router.js
@@ -0,0 +1,42 @@
+'use strict';
+
+/**
+ * @param {Egg.Application} app - egg application
+ */
+module.exports = app => {
+ const {
+ router,
+ controller,
+ } = app;
+
+ router.post('/api/gw', controller.gw.index);
+
+ // latestBuild
+ router.get('/api/latestBuild/:jobName/:gitBranch+', controller.build.queryLatestByJobNameAndGitBranch);
+
+ // insight
+ router.get('/api/insight/ci', controller.insight.ci);
+
+ // build
+ router.get('/api/build', controller.build.query);
+ router.get('/api/build/:uniqId', controller.build.show);
+ router.put('/api/build/:uniqId', controller.build.update);
+ router.patch('/api/build/:uniqId', controller.build.patch);
+
+ // config
+ router.get('/api/config', controller.config.show);
+ router.post('/api/config', controller.config.update);
+
+ // apps
+ router.get('/api/app/:appId', controller.app.show);
+
+ // delegate
+ router.post('/api/delegate/message', controller.delegate.message);
+
+ // oauth login callback
+ router.get('/snsAuthorize/callback/dingtalk', controller.snsAuthorize.dingtalkCallback);
+ router.get('/snsAuthorize/signout', controller.snsAuthorize.signOut);
+
+ // home page
+ router.get('*', controller.home.index);
+};
diff --git a/app/service/build.js b/app/service/build.js
new file mode 100644
index 0000000..0840784
--- /dev/null
+++ b/app/service/build.js
@@ -0,0 +1,138 @@
+'use strict';
+
+const Service = require('egg').Service;
+const debug = require('debug')('reliable:service:build');
+
+module.exports = class BuildService extends Service {
+ async queryAllBuilds({ page, num }) {
+ const ctx = this.ctx;
+ const allJobName = await ctx.model.JobName.findAll({
+ attributes: [
+ 'jobName',
+ ],
+ }).map(i => i.jobName);
+
+ debug({
+ limit: num,
+ offset: (page - 1) * num,
+ order: [
+ [
+ 'createdAt',
+ 'DESC',
+ ],
+ ],
+ });
+
+ const total = await ctx.model.Build.count();
+
+ const result = await ctx.model.Build.findAll({
+ limit: num,
+ offset: (page - 1) * num,
+ order: [
+ [
+ 'createdAt',
+ 'DESC',
+ ],
+ ],
+ });
+
+ return {
+ success: true,
+ message: '',
+ data: {
+ allJobName,
+ total,
+ page,
+ result,
+ },
+ };
+ }
+
+ async queryByJobName({ page, num, jobName }) {
+ const ctx = this.ctx;
+ const allJobName = await ctx.model.JobName.findAll({
+ attributes: [
+ 'jobName',
+ ],
+ }).map(i => i.jobName);
+ const total = await ctx.model.Build.count();
+ const result = await ctx.model.Build.findAll({
+ limit: num,
+ offset: (page - 1) * num,
+ order: [
+ [
+ 'createdAt',
+ 'DESC',
+ ],
+ ],
+ where: {
+ jobName,
+ },
+ });
+ return {
+ success: true,
+ message: '',
+ data: {
+ allJobName,
+ total,
+ page,
+ result,
+ },
+ };
+ }
+
+ async queryByJobNameAndBuildNumber({ jobName, buildNumber }) {
+ const ctx = this.ctx;
+ const build = await ctx.model.Build.findOne({
+ where: {
+ jobName,
+ buildNumber,
+ },
+ });
+ if (!build) {
+ return {
+ success: false,
+ code: 'ERR_RELIABLE_BUILD_RECORD_NOT_FOUND',
+ };
+ }
+
+ const result = build.get({ plain: true });
+ return {
+ success: true,
+ message: '',
+ data: result,
+ };
+ }
+
+ async queryBuildByUniqId({ uniqId }) {
+ return await this.ctx.model.Build.findOne({
+ where: {
+ uniqId,
+ },
+ }
+ );
+ }
+
+ async updateBuild({ uniqId, payload }) {
+ return await this.ctx.model.Build.update(
+ payload,
+ {
+ where: {
+ uniqId,
+ },
+ }
+ );
+ }
+
+ async finishBuild({ uniqId, payload }) {
+ return await this.ctx.model.Build.update(
+ payload,
+ {
+ where: {
+ uniqId,
+ },
+ }
+ );
+ }
+};
+
diff --git a/app/service/webhook.js b/app/service/webhook.js
new file mode 100644
index 0000000..18fca19
--- /dev/null
+++ b/app/service/webhook.js
@@ -0,0 +1,34 @@
+'use strict';
+
+const {
+ Service,
+} = require('egg');
+
+/* istanbul ignore next */
+module.exports = class WebHookService extends Service {
+
+ async pushBuildNotification(data = {}) {
+ const ctx = this.ctx;
+ // get all webhooks
+ const globalConfig = await this.ctx.model.Config.findOne();
+
+ if (!globalConfig || !globalConfig.data || !globalConfig.data.webhooks) {
+ return;
+ }
+ const {
+ webhooks,
+ } = globalConfig.data;
+ try {
+ await Promise.all(webhooks
+ .filter(webhook => webhook.tag === 'build')
+ .map(webhook => ctx.helper.sendDingTalk({
+ webhook,
+ data,
+ staticServerUrl: `http:${this.config.reliableView.staticUrl}`,
+ reliableServerUrl: `http:${this.config.reliableView.reliableHost}`,
+ })));
+ } catch (e) {
+ ctx.logger.error(e);
+ }
+ }
+};
diff --git a/config/config.default.js b/config/config.default.js
new file mode 100644
index 0000000..c77cafa
--- /dev/null
+++ b/config/config.default.js
@@ -0,0 +1,100 @@
+'use strict';
+
+const dbConfig = require('../database/config');
+
+module.exports = appInfo => {
+ const config = exports = {};
+
+ config.siteFile = {
+ '/favicon.ico': 'https://macacajs.github.io/assets/favicon.ico',
+ };
+
+ // use for cookie sign key, should change to your own and keep security
+ config.keys = process.env.RELIABLE_SECRET_KEY || appInfo.name + '_1528180445670_8068';
+
+ // add your config here
+ config.middleware = [
+ 'hostRedirect',
+ 'openApiAuthorize',
+ 'authorize',
+ 'inject',
+ 'cors',
+ 'errorHandler',
+ ];
+
+ config.hostRedirect = {
+ enable: !!process.env.RELIABLE_DEFAULT_HOST,
+ defaultHost: process.env.RELIABLE_DEFAULT_HOST,
+ ignore: [
+ /^\/api\//,
+ ],
+ };
+
+ config.authorize = {
+ enable: process.env.RELIABLE_ENABLE_AUTHORIZE === 'Y',
+ ignore: [
+ '/snsAuthorize/callback/dingtalk',
+ '/snsAuthorize/auth',
+ '/snsAuthorize/signout',
+
+ '/api/gw',
+ '/api/latestBuild/:id',
+ '/api/app/:id',
+ '/api/build/:id',
+ ],
+ dingtalkAuth: {
+ appid: process.env.RELIABLE_AUTH_DINGTALK_APPID,
+ appsecret: process.env.RELIABLE_AUTH_DINGTALK_APPSECRET,
+ callbackUrl: '/snsAuthorize/callback/dingtalk',
+ },
+ };
+
+ config.openApiAuthorize = {
+ enable: process.env.RELIABLE_ENABLE_OPENAPI_AUTHORIZE === 'Y',
+ match: [
+ '/api/gw',
+ '/api/latestBuild/:id',
+ '/api/app/:id',
+ '/api/build/:id',
+ ],
+ };
+
+ config.session = {
+ maxAge: 48 * 3600 * 1000, // 48 hours
+ renew: true, // keep session
+ };
+
+ config.modelQueryConfig = {
+ pagination: {
+ // default num
+ num: 10,
+ },
+ };
+
+ config.errorHandler = {
+ match: '/api',
+ };
+
+ const reliableHost = process.env.RELIABLE_HOST || '127.0.0.1';
+ const staticHost = process.env.STATIC_HOST || reliableHost;
+
+ config.reliableView = {
+ serverUrl: '',
+ reliableHost,
+ assetsUrl: `//${reliableHost}:8080`,
+ staticUrl: `//${staticHost}:9920`,
+ };
+
+ config.security = {
+ csrf: {
+ enable: false,
+ },
+ methodnoallow: {
+ enable: false,
+ },
+ };
+
+ config.sequelize = dbConfig.development;
+
+ return config;
+};
diff --git a/config/config.local.js b/config/config.local.js
new file mode 100644
index 0000000..c9e8293
--- /dev/null
+++ b/config/config.local.js
@@ -0,0 +1,25 @@
+'use strict';
+
+module.exports = () => {
+ const config = exports = {};
+
+ config.reliableView = {
+ // assetsUrl: '//127.0.0.1:8080',
+ };
+ config.authorize = {
+ enable: false,
+ dingtalkAuth: {
+ appid: '',
+ appsecret: '',
+ },
+ };
+ config.openApiAuthorize = {
+ enable: true,
+ };
+ config.hostRedirect = {
+ enable: false,
+ defaultHost: '',
+ };
+ return config;
+};
+
diff --git a/config/config.prod.js b/config/config.prod.js
new file mode 100644
index 0000000..b4e0b87
--- /dev/null
+++ b/config/config.prod.js
@@ -0,0 +1,4 @@
+'use strict';
+
+const dbConfig = require('../database/config');
+exports.sequelize = dbConfig.production;
diff --git a/config/config.unittest.js b/config/config.unittest.js
new file mode 100644
index 0000000..19b97cb
--- /dev/null
+++ b/config/config.unittest.js
@@ -0,0 +1,11 @@
+'use strict';
+
+const dbConfig = require('../database/config');
+
+module.exports = appInfo => {
+ const config = exports = {};
+
+ config.sequelize = dbConfig.test;
+ config.keys = appInfo.name + '_unittest_key';
+ return config;
+};
diff --git a/config/plugin.js b/config/plugin.js
new file mode 100644
index 0000000..a1d3f9a
--- /dev/null
+++ b/config/plugin.js
@@ -0,0 +1,11 @@
+'use strict';
+
+exports.sequelize = {
+ enable: true,
+ package: 'egg-sequelize',
+};
+
+exports.validate = {
+ enable: true,
+ package: 'egg-validate',
+};
diff --git a/database/config.js b/database/config.js
new file mode 100644
index 0000000..2259bc4
--- /dev/null
+++ b/database/config.js
@@ -0,0 +1,25 @@
+'use strict';
+
+const defaultConfig = {
+ username: 'root',
+ password: 'reliable',
+ database: 'reliable_development',
+ host: process.env.MYSQL_HOST || '127.0.0.1',
+ port: process.env.MYSQL_PORT || '3306',
+ operatorsAliases: false,
+ dialect: 'mysql',
+ define: {
+ underscored: false,
+ },
+};
+
+module.exports = {
+ development: defaultConfig,
+ test: Object.assign({}, defaultConfig, {
+ database: 'reliable_unittest',
+ }),
+ production: Object.assign({}, defaultConfig, {
+ defaultConfig,
+ database: 'reliable',
+ }),
+};
diff --git a/database/migrations/20180801094816-init.js b/database/migrations/20180801094816-init.js
new file mode 100644
index 0000000..1b835a0
--- /dev/null
+++ b/database/migrations/20180801094816-init.js
@@ -0,0 +1,98 @@
+'use strict';
+
+module.exports = {
+ up: async (queryInterface, Sequelize) => {
+ const {
+ STRING,
+ UUID,
+ UUIDV4,
+ JSON,
+ INTEGER,
+ DATE,
+ } = Sequelize;
+
+ await queryInterface.createTable('jobNames', {
+ id: {
+ type: INTEGER,
+ autoIncrement: true,
+ primaryKey: true,
+ },
+ jobName: {
+ type: STRING,
+ primaryKey: true,
+ },
+ uniqId: {
+ type: UUID,
+ defaultValue: UUIDV4,
+ primaryKey: true,
+ },
+ created_at: {
+ type: DATE,
+ allowNull: false,
+ },
+ updated_at: {
+ type: DATE,
+ allowNull: false,
+ },
+ });
+
+ await queryInterface.createTable('builds', {
+ id: {
+ type: INTEGER,
+ autoIncrement: true,
+ primaryKey: true,
+ },
+ jobName: {
+ type: STRING,
+ },
+ buildNumber: {
+ type: STRING,
+ },
+ gitBranch: {
+ type: STRING,
+ },
+ data: JSON,
+ uniqId: {
+ type: UUID,
+ defaultValue: UUIDV4,
+ primaryKey: true,
+ },
+ created_at: {
+ type: DATE,
+ allowNull: false,
+ },
+ updated_at: {
+ type: DATE,
+ allowNull: false,
+ },
+ });
+
+ await queryInterface.createTable('configs', {
+ id: {
+ type: INTEGER,
+ autoIncrement: true,
+ primaryKey: true,
+ },
+ data: JSON,
+ uniqId: {
+ type: UUID,
+ defaultValue: UUIDV4,
+ primaryKey: true,
+ },
+ created_at: {
+ type: DATE,
+ allowNull: false,
+ },
+ updated_at: {
+ type: DATE,
+ allowNull: false,
+ },
+ });
+ },
+
+ down: async queryInterface => {
+ await queryInterface.dropTable('jobNames');
+ await queryInterface.dropTable('builds');
+ await queryInterface.dropTable('configs');
+ },
+};
diff --git a/database/migrations/20180805093448-change-init-table.js b/database/migrations/20180805093448-change-init-table.js
new file mode 100644
index 0000000..af3ea48
--- /dev/null
+++ b/database/migrations/20180805093448-change-init-table.js
@@ -0,0 +1,55 @@
+'use strict';
+
+module.exports = {
+ up: async queryInterface => {
+ await queryInterface.removeColumn('jobNames', 'id');
+ await queryInterface.removeColumn('builds', 'id');
+ await queryInterface.removeColumn('configs', 'id');
+
+ await queryInterface.renameColumn('jobNames', 'created_at', 'createdAt');
+ await queryInterface.renameColumn('jobNames', 'updated_at', 'updatedAt');
+ await queryInterface.renameColumn('builds', 'created_at', 'createdAt');
+ await queryInterface.renameColumn('builds', 'updated_at', 'updatedAt');
+ await queryInterface.renameColumn('configs', 'created_at', 'createdAt');
+ await queryInterface.renameColumn('configs', 'updated_at', 'updatedAt');
+
+ const res = await queryInterface.showConstraint('jobNames');
+ for (const item of res) {
+ try {
+ await queryInterface.removeConstraint('jobNames', item.constraintName);
+ } catch (e) { /* */ }
+ }
+ await queryInterface.addConstraint('jobNames', [ 'uniqId' ], {
+ type: 'primary key',
+ });
+ await queryInterface.addConstraint('jobNames', [ 'jobName' ], {
+ type: 'unique',
+ });
+ },
+
+ down: async (queryInterface, Sequelize) => {
+ const dataType = {
+ type: Sequelize.INTEGER,
+ };
+ await queryInterface.addColumn('jobNames', 'id', dataType);
+ await queryInterface.addColumn('builds', 'id', dataType);
+ await queryInterface.addColumn('configs', 'id', dataType);
+
+ await queryInterface.renameColumn('jobNames', 'createdAt', 'created_at');
+ await queryInterface.renameColumn('jobNames', 'updatedAt', 'updated_at');
+ await queryInterface.renameColumn('builds', 'createdAt', 'created_at');
+ await queryInterface.renameColumn('builds', 'updatedAt', 'updated_at');
+ await queryInterface.renameColumn('configs', 'createdAt', 'created_at');
+ await queryInterface.renameColumn('configs', 'updatedAt', 'updated_at');
+
+ const res = await queryInterface.showConstraint('jobNames');
+ for (const item of res) {
+ try {
+ await queryInterface.removeConstraint('jobNames', item.constraintName);
+ } catch (e) { /**/ }
+ }
+ await queryInterface.addConstraint('jobNames', [ 'uniqId' ], {
+ type: 'primary key',
+ });
+ },
+};
diff --git a/database/migrations/20180915131836-build-add-extendInfo.js b/database/migrations/20180915131836-build-add-extendInfo.js
new file mode 100644
index 0000000..d4e89dc
--- /dev/null
+++ b/database/migrations/20180915131836-build-add-extendInfo.js
@@ -0,0 +1,16 @@
+'use strict';
+
+module.exports = {
+ up: async (queryInterface, Sequelize) => {
+ const {
+ JSON,
+ } = Sequelize;
+ await queryInterface.addColumn('builds', 'extendInfo', {
+ type: JSON,
+ });
+ },
+
+ down: async queryInterface => {
+ await queryInterface.removeColumn('builds', 'extendInfo');
+ },
+};
diff --git a/database/migrations/20181114033427-build-add-state.js b/database/migrations/20181114033427-build-add-state.js
new file mode 100644
index 0000000..c454d21
--- /dev/null
+++ b/database/migrations/20181114033427-build-add-state.js
@@ -0,0 +1,18 @@
+'use strict';
+
+module.exports = {
+ up: async (queryInterface, Sequelize) => {
+ const {
+ ENUM,
+ } = Sequelize;
+ await queryInterface.addColumn('builds', 'state', {
+ type: ENUM,
+ values: [ 'INIT', 'SUCCESS', 'FAIL' ],
+ defaultValue: 'SUCCESS',
+ });
+ },
+
+ down: async queryInterface => {
+ await queryInterface.removeColumn('builds', 'state');
+ },
+};
diff --git a/database/migrations/20181130024204-build-add-finishedAt.js b/database/migrations/20181130024204-build-add-finishedAt.js
new file mode 100644
index 0000000..a4babcc
--- /dev/null
+++ b/database/migrations/20181130024204-build-add-finishedAt.js
@@ -0,0 +1,16 @@
+'use strict';
+
+module.exports = {
+ up: async (queryInterface, Sequelize) => {
+ const {
+ DATE,
+ } = Sequelize;
+ await queryInterface.addColumn('builds', 'finishedAt', {
+ type: DATE,
+ });
+ },
+
+ down: async queryInterface => {
+ await queryInterface.removeColumn('builds', 'finishedAt');
+ },
+};
diff --git a/database/seeders/20180815163737-add-seed-to-table-jobNames.js b/database/seeders/20180815163737-add-seed-to-table-jobNames.js
new file mode 100644
index 0000000..9636ba6
--- /dev/null
+++ b/database/seeders/20180815163737-add-seed-to-table-jobNames.js
@@ -0,0 +1,29 @@
+'use strict';
+
+const uuidv4 = require('uuid/v4');
+
+module.exports = {
+ up: async queryInterface => {
+ await queryInterface.bulkInsert('jobNames', [
+ 'foo',
+ 'baz',
+ 'qux',
+ 'bar',
+ ].map(
+ item => {
+ console.log('item')
+ return {
+ jobName: item,
+ uniqId: uuidv4(),
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ };
+ }
+ ));
+ console.log('finish up')
+ },
+
+ down: async queryInterface => {
+ await queryInterface.bulkDelete('jobNames');
+ },
+};
diff --git a/database/seeders/20180815172240-add-seed-to-table-builds.js b/database/seeders/20180815172240-add-seed-to-table-builds.js
new file mode 100644
index 0000000..4528334
--- /dev/null
+++ b/database/seeders/20180815172240-add-seed-to-table-builds.js
@@ -0,0 +1,41 @@
+'use strict';
+
+const build = require('../../test/fixtures/post-gw.json');
+const data = JSON.stringify(build);
+
+module.exports = {
+ up: async queryInterface => {
+ let baseId = 1000;
+ const uidPrefix = '00000000-0000-0000-0000-00000000';
+ const insertData = [];
+ for (let i = 0; i < 5; i++) {
+ insertData.push({
+ jobName: 'foo',
+ buildNumber: '1074395',
+ gitBranch: 'master',
+ data,
+ uniqId: uidPrefix + baseId,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ finishedAt: new Date(Date.now() + 60 * 1000),
+ });
+ baseId++;
+ insertData.push({
+ jobName: 'bar',
+ buildNumber: '1074395',
+ gitBranch: 'master',
+ data,
+ uniqId: uidPrefix + baseId,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ finishedAt: new Date(Date.now() + 60 * 1000),
+ });
+ baseId++;
+ }
+ await queryInterface.bulkInsert('builds', insertData);
+ },
+
+ down: async queryInterface => {
+ await queryInterface.bulkDelete('builds');
+ },
+};
diff --git a/database/seeders/20180816055801-add-seed-to-table-configs.js b/database/seeders/20180816055801-add-seed-to-table-configs.js
new file mode 100644
index 0000000..36d1147
--- /dev/null
+++ b/database/seeders/20180816055801-add-seed-to-table-configs.js
@@ -0,0 +1,20 @@
+'use strict';
+
+const uuidv4 = require('uuid/v4');
+const config = require('../../test/fixtures/config-data.json');
+const data = JSON.stringify(config);
+
+module.exports = {
+ up: async queryInterface => {
+ await queryInterface.bulkInsert('configs', [{
+ data,
+ uniqId: uuidv4(),
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ }]);
+ },
+
+ down: async queryInterface => {
+ await queryInterface.bulkDelete('configs');
+ },
+};
diff --git a/docker/README.md b/docker/README.md
new file mode 100644
index 0000000..085f670
--- /dev/null
+++ b/docker/README.md
@@ -0,0 +1,3 @@
+# Dockerfiles
+
+---
diff --git a/docker/reliable-mysql/.dockerignore b/docker/reliable-mysql/.dockerignore
new file mode 100644
index 0000000..dd44972
--- /dev/null
+++ b/docker/reliable-mysql/.dockerignore
@@ -0,0 +1 @@
+*.md
diff --git a/docker/reliable-mysql/Dockerfile b/docker/reliable-mysql/Dockerfile
new file mode 100644
index 0000000..fa0821d
--- /dev/null
+++ b/docker/reliable-mysql/Dockerfile
@@ -0,0 +1,5 @@
+FROM mysql:5
+
+ENV MYSQL_ROOT_PASSWORD=reliable
+
+COPY docker-entrypoint-initdb.d /docker-entrypoint-initdb.d
diff --git a/docker/reliable-mysql/README.md b/docker/reliable-mysql/README.md
new file mode 100644
index 0000000..0a459aa
--- /dev/null
+++ b/docker/reliable-mysql/README.md
@@ -0,0 +1,45 @@
+# reliable-mysql
+
+---
+
+[![docker pull][docker-pull-image]][docker-url]
+[![docker pull][docker-size-image]][docker-url]
+[![docker pull][docker-layers-image]][docker-url]
+
+[docker-pull-image]: https://img.shields.io/docker/pulls/macacajs/reliable-mysql.svg?style=flat-square&logo=dockbit
+[docker-size-image]: https://img.shields.io/microbadger/image-size/macacajs/reliable-mysql.svg?style=flat-square&logo=dockbit
+[docker-layers-image]: https://img.shields.io/microbadger/layers/macacajs/reliable-mysql.svg?style=flat-square&logo=dockbit
+[docker-url]: https://hub.docker.com/r/macacajs/reliable-mysql/
+
+## production
+
+```bash
+$ docker run --name reliable-mysql \
+ -v $HOME/reliable_home/mysql_data:/var/lib/mysql \
+ -d macacajs/reliable-mysql
+```
+
+---
+
+Just for developer
+
+## build image
+
+```bash
+$ cd docker/reliable-mysql
+$ docker build --pull -t macacajs/reliable-mysql .
+$ docker push macacajs/reliable-mysql
+```
+
+## development
+
+```bash
+# start
+$ docker run --rm --name reliable-mysql \
+ -p 3306:3306 \
+ -v $HOME/reliable_home/mysql_data:/var/lib/mysql \
+ macacajs/reliable-mysql
+
+# stop
+$ docker stop reliable-mysql
+```
diff --git a/docker/reliable-mysql/docker-entrypoint-initdb.d/init.sql b/docker/reliable-mysql/docker-entrypoint-initdb.d/init.sql
new file mode 100644
index 0000000..781a2f7
--- /dev/null
+++ b/docker/reliable-mysql/docker-entrypoint-initdb.d/init.sql
@@ -0,0 +1,8 @@
+CREATE DATABASE IF NOT EXISTS reliable_development
+ CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+
+CREATE DATABASE IF NOT EXISTS reliable_unittest
+ CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+
+CREATE DATABASE IF NOT EXISTS reliable
+ CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
diff --git a/docker/reliable-web/README.md b/docker/reliable-web/README.md
new file mode 100644
index 0000000..a65fdc9
--- /dev/null
+++ b/docker/reliable-web/README.md
@@ -0,0 +1,101 @@
+# reliable-web
+
+---
+
+[![docker pull][docker-pull-image]][docker-url]
+[![docker pull][docker-size-image]][docker-url]
+[![docker pull][docker-layers-image]][docker-url]
+
+[docker-pull-image]: https://img.shields.io/docker/pulls/macacajs/reliable-web.svg?style=flat-square&logo=dockbit
+[docker-size-image]: https://img.shields.io/microbadger/image-size/macacajs/reliable-web.svg?style=flat-square&logo=dockbit
+[docker-layers-image]: https://img.shields.io/microbadger/layers/macacajs/reliable-web.svg?style=flat-square&logo=dockbit
+[docker-url]: https://hub.docker.com/r/macacajs/reliable-web/
+
+## production
+
+should launch `reliable-mysql` service first.
+
+[available environment variable](./#environment-variable)
+
+```bash
+$ docker run --rm --name reliable-web \
+ -p 9900:9900 \
+ -e RELIABLE_HOST=127.0.0.1 \
+ --link reliable-mysql:mysql-host \
+ macacajs/reliable-web
+```
+
+run as a service
+
+```bash
+$ docker run --name reliable-web \
+ -p 9900:9900 \
+ -e RELIABLE_HOST=127.0.0.1 \
+ --link reliable-mysql:mysql-host \
+ -d macacajs/reliable-web
+```
+
+open http://127.0.0.1:9900
+
+if you want another hostname, please replace the `127.0.0.1`
+
+---
+
+Just for developer
+
+## build image
+
+```bash
+$ cd ${PROJECT_ROOT_PATH}
+$ docker build --no-cache --pull -t macacajs/reliable-web .
+$ docker push macacajs/reliable-web
+```
+
+## development
+
+start mysql:
+
+```bash
+# start
+$ docker run --rm --name reliable-mysql \
+ -p 3306:3306 \
+ -v $HOME/reliable_home/mysql_data:/var/lib/mysql \
+ -d macacajs/reliable-mysql
+
+# stop
+$ docker stop reliable-mysql
+```
+
+start server:
+
+```bash
+npm run dev
+```
+
+insert seed data:
+
+```bash
+npm run db:seed:all
+```
+
+remove seed data:
+
+```bash
+npm run db:seed:undo:all
+```
+
+## test
+
+```bash
+npm test # test migration then test web server
+npm run test-local # only test web server
+npm run cov # test and output test coverage
+```
+
+## environment-variable
+
+variable name | description | default value
+--- | --- | ---
+MYSQL_HOST | mysql server ip | 127.0.0.1
+MYSQL_PORT | mysql port | 3306
+RELIABLE_HOST | used for notification message template | 127.0.0.1
diff --git a/docs/integrate-with-gitlab-ci.md b/docs/integrate-with-gitlab-ci.md
new file mode 100644
index 0000000..c8074fb
--- /dev/null
+++ b/docs/integrate-with-gitlab-ci.md
@@ -0,0 +1,3 @@
+# Integrate With Gitlab CI
+
+---
diff --git a/docs/integrate-with-gitlab-ci.zh-CN.md b/docs/integrate-with-gitlab-ci.zh-CN.md
new file mode 100644
index 0000000..9147f58
--- /dev/null
+++ b/docs/integrate-with-gitlab-ci.zh-CN.md
@@ -0,0 +1,3 @@
+# Gitlab CI 集成文档
+
+---
diff --git a/docs/integrate-with-jenkins.md b/docs/integrate-with-jenkins.md
new file mode 100644
index 0000000..8e65ddc
--- /dev/null
+++ b/docs/integrate-with-jenkins.md
@@ -0,0 +1,55 @@
+Integrate With Jenkins
+
+---
+
+## Reliable home path
+
+```bash
+$ mkdir $HOME/reliable_home
+```
+
+reliable_home requires the following sub-directories to be created.
+
+```
+.
+├── static Static HTTP server root folder, containing build artifacts, reports, and archived files.
+├── mysql_data Mysql Database. Can be backed up easily.
+├── jenkins_home Jenkins root folder, containing configuration files and plugins.
+├── jenkins_tmp Jenkins temporary folder.
+└── jenkins.war Jenkins war package. Can execute programs.
+```
+
+## Reliable Jenkins Deployment
+
+- In `$HOME/reliable_home` directory, create jenkins_home, jenkins_tmp
+- Download official [War package](http://mirrors.jenkins.io/) to $HOME/reliable_home directory
+- jenkins service launches at port 9910
+
+```bash
+$ java -Dfile.encoding=UTF-8 \
+ -XX:PermSize=256m \
+ -XX:MaxPermSize=512m \
+ -Xms256m \
+ -Xmx512m \
+ -DJENKINS_HOME=$HOME/reliable_home/jenkins_home \
+ -Djava.io.tmpdir=$HOME/reliable_home/jenkins_tmp \
+ -jar $HOME/reliable_home/jenkins.war \
+ --httpPort=9910
+```
+
+Change `$HOME/reliable_home/jenkins_home/config.xml` useSecurity to false, and restart the Jenkins.
+
+```xml
+false
+```
+
+0. input the `initialAdminPassword` and next.
+0. select `Install suggested plugins` and wait for Jenkins plugins installation ready.
+
+---
+
+## Build Tasks Sample
+
+- [jenkins-android.md](./jenkins-android.md)
+- [jenkins-ios.md](./jenkins-ios.md)
+- [jenkins-web.md](./jenkins-web.md)
\ No newline at end of file
diff --git a/docs/integrate-with-jenkins.zh-CN.md b/docs/integrate-with-jenkins.zh-CN.md
new file mode 100644
index 0000000..2c1f272
--- /dev/null
+++ b/docs/integrate-with-jenkins.zh-CN.md
@@ -0,0 +1,57 @@
+# Jenkins 集成文档
+
+---
+
+## Reliable home 目录
+
+在配置前需要创建 `reliable_home` 目录。
+
+```bash
+$ mkdir $HOME/reliable_home
+```
+
+`reliable_home` 包含以下子目录。
+
+```
+.
+├── static 提供静态资源下载服务
+├── mysql_data Mysql 数据库存档
+├── jenkins_home Jenkins home 目录
+├── jenkins_tmp Jenkins 临时文件目录
+└── jenkins.war Jenkins war 包执行文件
+```
+
+## 部署 Jenkins
+
+- 在 `$HOME/reliable_home` 目录创建 `jenkins_home` 和 `jenkins_tmp`
+- 下载 [War 包](http://mirrors.jenkins.io/) 到 `$HOME/reliable_home`
+- Jenkins 服务启动在 9910 端口
+
+```bash
+$ java -Dfile.encoding=UTF-8 \
+ -XX:PermSize=256m \
+ -XX:MaxPermSize=512m \
+ -Xms256m \
+ -Xmx512m \
+ -DJENKINS_HOME=$HOME/reliable_home/jenkins_home \
+ -Djava.io.tmpdir=$HOME/reliable_home/jenkins_tmp \
+ -jar $HOME/reliable_home/jenkins.war \
+ --httpPort=9910
+```
+
+修改 `$HOME/reliable_home/jenkins_home/config.xml` 文件中的配置字段 `useSecurity` 为 false,并且重启 Jenkins 服务。
+
+```xml
+false
+```
+
+0. 输入 `initialAdminPassword` 管理员密码
+0. 选择 `Install suggested plugins` 推荐模式安装 Jenkins 插件。
+
+---
+
+## 构建任务配置
+
+- [jenkins-android.zh-CN.md](./jenkins-android.zh-CN.md)
+- [jenkins-ios.zh-CN.md](./jenkins-ios.zh-CN.md)
+- [jenkins-web.zh-CN.md](./jenkins-web.zh-CN.md)
diff --git a/docs/jenkins-android.md b/docs/jenkins-android.md
new file mode 100644
index 0000000..9cc6aa0
--- /dev/null
+++ b/docs/jenkins-android.md
@@ -0,0 +1,123 @@
+# Jenkins Android Task
+
+---
+
+## Dependencies
+
+### Environment
+
+Should create `gradle_cache` directory in reliable_home for gradle tool.
+
+```bash
+$ mkdir $HOME/reliable_home/gradle_cache
+```
+
+### Docker
+
+Just like reliable-web, we recommend to build Android with Docker.
+
+## Sample Project
+
+[android-app-bootstrap](//github.com/app-bootstrap/android-app-bootstrap)
+
+## Quick Start
+
+### Step1 - Create New
+
+Create a new item named `android-app-bootstrap`, and select the `Freestyle project` mode.
+
+
+
+
+
+### Step2 - SCM Config
+
+
+
+
+
+Please input the `android-app-bootstrap` git url, and set the clone depth to `1`, branch to `master` is ok.
+
+```
+https://github.com/app-bootstrap/android-app-bootstrap.git
+```
+
+### Step3 - Build Scripts Config
+
+
+
+
+
+**Noted**
+
+- please confirm jenkins delete the workspace before build to avoid the old middle-file problem.
+
+
+
+
+
+We provide the Android build docker like `macacajs/macaca-android-build-docker`, so you can set the feild content like this:
+
+```
+docker stop $JOB_NAME || true && docker rm -f $JOB_NAME || true
+
+docker run --rm \
+ --name $JOB_NAME \
+ -e JOB_NAME \
+ -e BUILD_NUMBER \
+ -e reliable_SERVER_URL=http://192.168.0.102:9900 \
+ -v $WORKSPACE:/root/src \
+ -v $HOME/reliable_home/static:/static \
+ -v $HOME/reliable_home/gradle_cache:/root/.gradle \
+ macacajs/macaca-android-build-docker
+```
+
+**Noted**
+
+- you can also build android out off the docker container, system shell command is ok.
+- please confirm the `reliable_SERVER_URL` has the correct address just like the IPV4 or some domain name which can be visited from the docker container, otherwise you will meet the problem below.
+
+```
+error: TypeError: Cannot read property 'server' of undefined
+ at _.postToGW (/root/src/node_modules/reliable-cli/lib/helper.js:31:66)
+ at ReportCommand.pushToWebhook (/root/src/node_modules/reliable-cli/lib/report-command.js:130:18)
+ at ReportCommand._run (/root/src/node_modules/reliable-cli/lib/report-command.js:70:35)
+ at
+ at process._tickCallback (internal/process/next_tick.js:188:7)
+npm ERR! code ELIFECYCLE
+npm ERR! errno 1
+npm ERR! android-app-bootstrap@1.0.8 reliable: `reliable report -c ./reliable.config.js`
+npm ERR! Exit status 1
+```
+
+### Step4 - Build Now
+
+
+
+
+
+After the building ready, you can get the final result from reliable-web.
+
+
+
+
+
+We cat get the `debug` and `relese` package of the `android-app-bootstrap`.
+
+
+
+
+
+Scan the QRCode, you can download and install it with your device.
+
+
+
+
+
+You can also get other extra build infomation. If you want more, please tweak the [reliable-cli#configuration](//github.com/macacajs/reliable-cli#configuration) file.
+
+### Step5 - Test Reporter
+
+reliable support the Unit and E2E test reporter, coverage based on Macaca is supported.
+
+coming soon
diff --git a/docs/jenkins-android.zh-CN.md b/docs/jenkins-android.zh-CN.md
new file mode 100644
index 0000000..9f5a48f
--- /dev/null
+++ b/docs/jenkins-android.zh-CN.md
@@ -0,0 +1,123 @@
+# Jenkins Android 任务配置
+
+---
+
+## 依赖准备
+
+### 环境依赖
+
+在 `reliable_home` 创建 `gradle_cache` 目录用于 Gradle 工具的缓存。
+
+```bash
+$ mkdir $HOME/reliable_home/gradle_cache
+```
+
+### Docker 部署
+
+就像 reliable-web 一样,Reliable 环境配置倾向于容器化,推荐你使用 Android Docker 容器运行任务。
+
+## 示例工程
+
+[android-app-bootstrap](//github.com/app-bootstrap/android-app-bootstrap)
+
+## 快速上手
+
+### 第1步 - 创建任务
+
+创建一个项目名为 `android-app-bootstrap`,并且选择自由风格模式。
+
+
+
+
+
+### 第2步 - SCM 配置
+
+
+
+
+
+输入项目的 git 地址,并且选择克隆深度为 1,分支为 `master`。
+
+```
+https://github.com/app-bootstrap/android-app-bootstrap.git
+```
+
+### 第3步 - 构建脚本配置
+
+
+
+
+
+**注意**
+
+- 请确保勾选构建前删除运行空间,以排除老的中间文件造成的问题。
+
+
+
+
+
+我们提供 Android 构建 Docker 镜像 `macacajs/macaca-android-build-docker`,你可以设置如下:
+
+```
+docker stop $JOB_NAME || true && docker rm -f $JOB_NAME || true
+
+docker run --rm \
+ --name $JOB_NAME \
+ -e JOB_NAME \
+ -e BUILD_NUMBER \
+ -e RELIABLE_SERVER_URL=http://192.168.0.102:9900 \
+ -v $WORKSPACE:/root/src \
+ -v $HOME/reliable_home/static:/static \
+ -v $HOME/reliable_home/gradle_cache:/root/.gradle \
+ macacajs/macaca-android-build-docker
+```
+
+**注意**
+
+- 也可以不使用容器而使用系统命令行直接进行构建。
+- 请确认 `RELIABLE_SERVER_URL` 已经正确配置,可以是一个 IPV4 或者某个 url,否则会遇到如下问题:
+
+```
+error: TypeError: Cannot read property 'server' of undefined
+ at _.postToGW (/root/src/node_modules/reliable-cli/lib/helper.js:31:66)
+ at ReportCommand.pushToWebhook (/root/src/node_modules/reliable-cli/lib/report-command.js:130:18)
+ at ReportCommand._run (/root/src/node_modules/reliable-cli/lib/report-command.js:70:35)
+ at
+ at process._tickCallback (internal/process/next_tick.js:188:7)
+npm ERR! code ELIFECYCLE
+npm ERR! errno 1
+npm ERR! android-app-bootstrap@1.0.8 reliable: `reliable report -c ./reliable.config.js`
+npm ERR! Exit status 1
+```
+
+### 第4步 - 理解构建
+
+
+
+
+
+构建结束后,你可以在 reliable web 平台获得构建结果。
+
+
+
+
+
+比如我们能够获得 `android-app-bootstrap` 的 `debug` 类型包和 `release` 类型包。
+
+
+
+
+
+Reliable 平台也支持扫码下载安装等实用功能。
+
+
+
+
+
+我们也可以获得项目配置,版本等额外信息。如果需要更多上报信息可以参考上报脚本文档 [reliable-cli#configuration](//github.com/macacajs/reliable-cli#configuration)。
+
+### 第5步 - 自动化测试
+
+Reliable 无缝集成 Macaca 自动化测试工具,支持通过率报告,端到端链路刻画,覆盖率等质量覆盖方案。
+
+配置敬请期待!
diff --git a/docs/jenkins-ios.md b/docs/jenkins-ios.md
new file mode 100644
index 0000000..f78fc83
--- /dev/null
+++ b/docs/jenkins-ios.md
@@ -0,0 +1,108 @@
+# Jenkins iOS Task
+
+---
+
+## Dependencies
+
+### Environment
+
+Please install reliable-ios automation utils with following command.
+
+```bash
+$ curl -fsSL https://github.com/macacajs/reliable-ios/files/2114440/Makefile.txt -o Makefile && make init
+```
+
+## Sample Project
+
+There are two sample projects, one for publish app and the other for publish private cocoapod pod frameworks:
+
+- [ios-app-bootstrap - publish iOS App](//github.com/app-bootstrap/ios-app-bootstrap)
+- [publish cocoapod frameworks](//github.com/macacajs/reliable-ios/tree/master/Example)
+
+## Quick Start
+
+### Step1 - Create New
+
+Create a new item named `ios-app-bootstrap`, and select the `Freestyle project` mode.
+
+
+
+
+
+### Step2 - SCM Config
+
+
+
+
+
+Please input the `ios-app-bootstrap` git url, and set the clone depth to `1`, branch to `master` is ok.
+
+```
+https://github.com/app-bootstrap/ios-app-bootstrap.git
+```
+
+### Step3 - Build Scripts Config
+
+
+
+
+
+```
+RELIABLE_SERVER_URL=http://127.0.0.1:9900 RELIABLE_IOS=true ./ci.sh
+```
+
+**Noted**
+
+- To release the app and sign, you may need to configure your developer certificate in Jenkins.
+- please confirm the RELIABLE_SERVER_URL has the correct address just like the IPV4 or some domain name which can be visited from the docker container, otherwise you will meet the problem below.
+
+```
+error: TypeError: Cannot read property 'server' of undefined
+ at _.postToGW (/root/src/node_modules/reliable-cli/lib/helper.js:31:66)
+ at ReportCommand.pushToWebhook (/root/src/node_modules/reliable-cli/lib/report-command.js:130:18)
+ at ReportCommand._run (/root/src/node_modules/reliable-cli/lib/report-command.js:70:35)
+ at
+ at process._tickCallback (internal/process/next_tick.js:188:7)
+npm ERR! code ELIFECYCLE
+npm ERR! errno 1
+npm ERR! ios-app-bootstrap@1.0.11 reliable: `reliable report -c ./reliable.config.js`
+npm ERR! Exit status 1
+```
+
+### Step4 - Build Now
+
+
+
+
+
+After the building ready, you can get the final result from reliable-web.
+
+
+
+
+
+We cat get the `debug` package of the `ios-app-bootstrap`.
+
+
+
+
+
+Scan the QRCode, you can download and install it with your device.
+
+
+
+
+
+You can also get other extra build infomation. If you want more, please tweak the [reliable-cli#configuration](//github.com/macacajs/reliable-cli#configuration) file.
+
+### Step5 - Test Reporter
+
+Reliable support the Unit and E2E test reporter, coverage based on Macaca is supported.
+
+
+
+
+
+
+
+
diff --git a/docs/jenkins-ios.zh-CN.md b/docs/jenkins-ios.zh-CN.md
new file mode 100644
index 0000000..e70ef22
--- /dev/null
+++ b/docs/jenkins-ios.zh-CN.md
@@ -0,0 +1,108 @@
+# Jenkins iOS 任务配置
+
+---
+
+## 依赖准备
+
+### 环境依赖
+
+请安装 reliable-ios 自动化脚本工具集。
+
+```bash
+$ curl -fsSL https://github.com/macacajs/reliable-ios/files/2114440/Makefile.txt -o Makefile && make init
+```
+
+## 示例项目
+
+这里有两个示例工程,一个典型的 iOS 工程,另一个是发布私有 cocoapod pod 框架:
+
+- [ios-app-bootstrap - publish iOS App](//github.com/app-bootstrap/ios-app-bootstrap)
+- [publish cocoapod frameworks](//github.com/macacajs/reliable-ios/tree/master/Example)
+
+## 快速上手
+
+### 第1步 - 创建任务
+
+创建一个项目名为 `ios-app-bootstrap`,并且选择自由风格模式。
+
+
+
+
+
+### 第2步 - SCM 配置
+
+
+
+
+
+输入项目的 git 地址,并且选择克隆深度为 1,分支为 `master`。
+
+```
+https://github.com/app-bootstrap/ios-app-bootstrap.git
+```
+
+### 第3步 - 构建脚本配置
+
+
+
+
+
+```
+RELIABLE_SERVER_URL=http://127.0.0.1:9900 RELIABLE_IOS=true ./ci.sh
+```
+
+**注意**
+
+- 为了能够直接扫码安装应用,请在 Jenkins 配置开发者证书签名。
+- 请确认 `RELIABLE_SERVER_URL` 已经正确配置,可以是一个 IPV4 或者某个 url,否则会遇到如下问题:
+
+```
+error: TypeError: Cannot read property 'server' of undefined
+ at _.postToGW (/root/src/node_modules/reliable-cli/lib/helper.js:31:66)
+ at ReportCommand.pushToWebhook (/root/src/node_modules/reliable-cli/lib/report-command.js:130:18)
+ at ReportCommand._run (/root/src/node_modules/reliable-cli/lib/report-command.js:70:35)
+ at
+ at process._tickCallback (internal/process/next_tick.js:188:7)
+npm ERR! code ELIFECYCLE
+npm ERR! errno 1
+npm ERR! ios-app-bootstrap@1.0.11 reliable: `reliable report -c ./reliable.config.js`
+npm ERR! Exit status 1
+```
+
+### 第4步 - 立即构建
+
+
+
+
+
+构建结束后,你可以在 reliable web 平台获得构建结果。
+
+
+
+
+
+比如我们能够获得 `ios-app-bootstrap` 的 `debug` 类型包。
+
+
+
+
+
+Reliable 平台也支持扫码下载安装等实用功能。
+
+
+
+
+
+我们也可以获得项目配置,版本等额外信息。如果需要更多上报信息可以参考上报脚本文档 [reliable-cli#configuration](//github.com/macacajs/reliable-cli#configuration)。
+
+### 第5步 - 自动化测试
+
+Reliable 无缝集成 Macaca 自动化测试工具,支持通过率报告,端到端链路刻画,覆盖率等质量覆盖方案。
+
+
+
+
+
+
+
+
diff --git a/docs/jenkins-web.md b/docs/jenkins-web.md
new file mode 100644
index 0000000..6f389d3
--- /dev/null
+++ b/docs/jenkins-web.md
@@ -0,0 +1,118 @@
+# Jenkins Web Task
+
+---
+
+## Dependencies
+
+### Docker
+
+Just like reliable-web, we recommend to build web with Docker.
+
+## Sample Project
+
+- [web-app-bootstrap](//github.com/app-bootstrap/web-app-bootstrap)
+
+## Quick Start
+
+### Step1 - Create New
+
+Create a new item named `web-app-bootstrap`, and select the `Freestyle project` mode.
+
+
+
+
+
+### Step2 - SCM Config
+
+
+
+
+
+Please input the `web-app-bootstrap` git url, and set the clone depth to `1`, branch to `master` is ok.
+
+```
+https://github.com/app-bootstrap/web-app-bootstrap.git
+```
+
+### Step3 - Build Scripts Config
+
+**Noted**
+
+- please confirm jenkins delete the workspace before build to avoid the old middle-file problem.
+
+
+
+
+
+We provide the webpack build docker like `macacajs/macaca-electron-docker`, so you can set the feild content like this:
+
+```
+docker stop $JOB_NAME || true && docker rm $JOB_NAME || true
+
+docker run --rm \
+ --name $JOB_NAME \
+ -e JOB_NAME \
+ -e BUILD_NUMBER \
+ -e RELIABLE_SERVER_URL=http://129.168.1.102:9900 \
+ -v $HOME/reliable_home/static:/static \
+ -v $WORKSPACE:/root/src \
+ macacajs/macaca-electron-docker \
+ bash -c "cd /root/src && npm run ci"
+```
+
+**Noted**
+
+- please confirm the `RELIABLE_SERVER_URL` has the correct address just like the IPV4 or some domain name which can be visited from the docker container, otherwise you will meet the problem below.
+
+```
+error: TypeError: Cannot read property 'server' of undefined
+ at _.postToGW (/root/src/node_modules/reliable-cli/lib/helper.js:31:66)
+ at ReportCommand.pushToWebhook (/root/src/node_modules/reliable-cli/lib/report-command.js:130:18)
+ at ReportCommand._run (/root/src/node_modules/reliable-cli/lib/report-command.js:70:35)
+ at
+ at process._tickCallback (internal/process/next_tick.js:188:7)
+npm ERR! code ELIFECYCLE
+npm ERR! errno 1
+npm ERR! web-app-bootstrap@1.0.8 reliable: `reliable report -c ./reliable.config.js`
+npm ERR! Exit status 1
+```
+
+### Step4 - Build Now
+
+After the building ready, you can get the final result from reliable-web.
+
+
+
+
+
+We cat get the build results of the `web-app-bootstrap`.
+
+
+
+
+
+You can also get other extra build infomation. If you want more, please tweak the [reliable-cli#configuration](//github.com/macacajs/reliable-cli#configuration) file.
+
+### Step5 - Test Reporter
+
+Reliable support the Unit and E2E test reporter, coverage based on Macaca is supported.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/jenkins-web.zh-CN.md b/docs/jenkins-web.zh-CN.md
new file mode 100644
index 0000000..01cbba6
--- /dev/null
+++ b/docs/jenkins-web.zh-CN.md
@@ -0,0 +1,118 @@
+# Jenkins Web 任务配置
+
+---
+
+## 依赖准备
+
+### Docker 部署
+
+就像 reliable-web 一样,Reliable 环境配置倾向于容器化,推荐你使用 Android Docker 容器运行任务。
+
+## 示例工程
+
+- [web-app-bootstrap](//github.com/app-bootstrap/web-app-bootstrap)
+
+## 快速上手
+
+### 第1步 - 创建任务
+
+Create a new item named `web-app-bootstrap`, and select the `Freestyle project` mode.
+
+
+
+
+
+### 第2步 - SCM 配置
+
+
+
+
+
+Please input the `web-app-bootstrap` git url, and set the clone depth to `1`, branch to `master` is ok.
+
+```
+https://github.com/app-bootstrap/web-app-bootstrap.git
+```
+
+### 第3步 - 构建脚本配置
+
+**注意**
+
+- 请确保勾选构建前删除运行空间,以排除老的中间文件造成的问题。
+
+
+
+
+
+我们提供 Web 构建 Docker 镜像 `macacajs/macaca-electron-docker`,你可以设置如下:
+
+```
+docker stop $JOB_NAME || true && docker rm $JOB_NAME || true
+
+docker run --rm \
+ --name $JOB_NAME \
+ -e JOB_NAME \
+ -e BUILD_NUMBER \
+ -e RELIABLE_SERVER_URL=http://129.168.1.102:9900 \
+ -v $HOME/reliable_home/static:/static \
+ -v $WORKSPACE:/root/src \
+ macacajs/macaca-electron-docker \
+ bash -c "cd /root/src && npm run ci"
+```
+
+**注意**
+
+- 请确认 `RELIABLE_SERVER_URL` 已经正确配置,可以是一个 IPV4 或者某个 url,否则会遇到如下问题:
+
+```
+error: TypeError: Cannot read property 'server' of undefined
+ at _.postToGW (/root/src/node_modules/reliable-cli/lib/helper.js:31:66)
+ at ReportCommand.pushToWebhook (/root/src/node_modules/reliable-cli/lib/report-command.js:130:18)
+ at ReportCommand._run (/root/src/node_modules/reliable-cli/lib/report-command.js:70:35)
+ at
+ at process._tickCallback (internal/process/next_tick.js:188:7)
+npm ERR! code ELIFECYCLE
+npm ERR! errno 1
+npm ERR! web-app-bootstrap@1.0.8 reliable: `reliable report -c ./reliable.config.js`
+npm ERR! Exit status 1
+```
+
+### 第4步 - 立即构建
+
+构建结束后,你可以在 reliable web 平台获得构建结果。
+
+
+
+
+
+We cat get the build results of the `web-app-bootstrap`.
+
+
+
+
+
+我们也可以获得项目配置,版本等额外信息。如果需要更多上报信息可以参考上报脚本文档 [reliable-cli#configuration](//github.com/macacajs/reliable-cli#configuration)。
+
+### 第5步 - 自动化测试
+
+Reliable 无缝集成 Macaca 自动化测试工具,支持通过率报告,端到端链路刻画,覆盖率等质量覆盖方案。
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/marmot-web-deploy.md b/docs/marmot-web-deploy.md
new file mode 100644
index 0000000..ce63e60
--- /dev/null
+++ b/docs/marmot-web-deploy.md
@@ -0,0 +1,59 @@
+# Reliable Web Deploy
+
+---
+
+## Docker Deploy
+
+### Using [docker-compose](https://docs.docker.com/compose/) (recommended)
+
+## production
+
+```
+# start services
+$ docker-compose -p reliable -f docker-compose.yml up -d
+
+# NOTE: if you meet the problem, maybe the issue caused by the existed service, just run the stop command below.
+
+# stop services
+$ docker-compose -p reliable -f docker-compose.yml down
+```
+
+execute `docker ps`, we can see:
+
+```
+CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
+8b2941c9774a macacajs/reliable-web "./entrypoint.sh npm…" 12 minutes ago Up 12 minutes (healthy) 0.0.0.0:9900->9900/tcp reliable_web_1
+b726a3232cdc macacajs/reliable-mysql "docker-entrypoint.s…" 12 minutes ago Up 12 minutes 3306/tcp reliable_mysql_1
+ffb2ab9f12fb macacajs/reliable-nginx "nginx -g 'daemon of…" 12 minutes ago Up 12 minutes 0.0.0.0:9920->80/tcp reliable_nginx_1
+```
+
+go into the MySQL
+
+```bash
+$ docker exec -it reliable_mysql_1 mysql -uroot -preliable
+mysql> use reliable;
+mysql> show tables;
+mysql> select * from reliable.jobNames;
+```
+
+## development
+
+```
+# start services
+$ docker-compose up
+
+# stop services
+$ docker-compose down
+```
+
+Reliable server is running on `http://127.0.0.1:9900` by default.
+
+Nginx server is running on `http://127.0.0.1:9920` by default.
+
+should edit [docker-compose.yml](../docker-compose.yml) on demand.
+
+### Using [docker](https://docs.docker.com/)
+
+- [reliable-web](../docker/reliable-web/README.md)
+- [reliable-mysql](../docker/reliable-mysql/README.md)
+- [reliable-nginx](../docker/reliable-nginx/README.md)
diff --git a/docs/marmot-web-deploy.zh-CN.md b/docs/marmot-web-deploy.zh-CN.md
new file mode 100644
index 0000000..1617367
--- /dev/null
+++ b/docs/marmot-web-deploy.zh-CN.md
@@ -0,0 +1,59 @@
+# Reliable Web 部署文档
+
+---
+
+## Docker 部署
+
+### 使用 [docker-compose](https://docs.docker.com/compose/) (推荐)
+
+## 生产环境
+
+```bash
+# start services
+$ docker-compose -p reliable -f docker-compose.yml up -d
+
+# NOTE: if you meet the problem, maybe the issue caused by the existed service, just run the stop command below.
+
+# stop services
+$ docker-compose -p reliable -f docker-compose.yml down
+```
+
+执行 `docker ps` 我们能够看到以下容器:
+
+```
+CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
+8b2941c9774a macacajs/reliable-web "./entrypoint.sh npm…" 12 minutes ago Up 12 minutes (healthy) 0.0.0.0:9900->9900/tcp reliable_web_1
+b726a3232cdc macacajs/reliable-mysql "docker-entrypoint.s…" 12 minutes ago Up 12 minutes 3306/tcp reliable_mysql_1
+ffb2ab9f12fb macacajs/reliable-nginx "nginx -g 'daemon of…" 12 minutes ago Up 12 minutes 0.0.0.0:9920->80/tcp reliable_nginx_1
+```
+
+进入 MySQL
+
+```bash
+$ docker exec -it reliable_mysql_1 mysql -uroot -preliable
+mysql> use reliable;
+mysql> show tables;
+mysql> select * from reliable.jobNames;
+```
+
+## 开发环境
+
+```bash
+# start services
+$ docker-compose up
+
+# stop services
+$ docker-compose down
+```
+
+Reliable 服务默认运行在 `http://127.0.0.1:9900`。
+
+Nginx 服务默认运行在 `http://127.0.0.1:9920`。
+
+需要按需修改 [docker-compose.yml](../docker-compose.yml) 配置。
+
+### 其他 [Docker](https://docs.docker.com/) 服务部署
+
+- [reliable-web](../docker/reliable-web/README.md)
+- [reliable-mysql](../docker/reliable-mysql/README.md)
+- [reliable-nginx](../docker/reliable-nginx/README.md)
diff --git a/entrypoint.sh b/entrypoint.sh
new file mode 100755
index 0000000..5fc4448
--- /dev/null
+++ b/entrypoint.sh
@@ -0,0 +1,3 @@
+#!/bin/bash
+
+exec "$@"
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..f3f8799
--- /dev/null
+++ b/package.json
@@ -0,0 +1,63 @@
+{
+ "name": "reliable-web",
+ "version": "1.0.0",
+ "description": "Testing management suite with continuous delivery support.",
+ "private": true,
+ "dependencies": {
+ "dingtalk-robot-sender": "^1.1.1",
+ "egg": "^2.2.1",
+ "egg-scripts": "^2.5.0",
+ "egg-sequelize": "^4.2.0",
+ "egg-validate": "^1.0.0",
+ "lodash.get": "^4.4.2",
+ "moment": "^2.22.2",
+ "sequelize-cli": "^4.1.1"
+ },
+ "devDependencies": {
+ "autod": "^3.0.1",
+ "autod-egg": "^1.0.0",
+ "concurrently": "4.0.1",
+ "cross-env": "^5.2.0",
+ "debug": "^3.1.0",
+ "egg-bin": "^4.3.5",
+ "egg-mock": "^3.14.0",
+ "eslint": "^4.11.0",
+ "eslint-config-egg": "^6.0.0",
+ "git-contributor": "^1.0.8",
+ "sinon": "^6.1.4",
+ "uuid": "^3.3.2"
+ },
+ "engines": {
+ "node": ">=8.9.0"
+ },
+ "scripts": {
+ "start": "cross-env NODE_ENV=production npm run db:migrate && eggctl start --port=9900 --title=egg-server-reliable-web",
+ "stop": "eggctl stop --title=egg-server-reliable-web",
+ "dev:server": "npm run db:migrate && cross-env DEBUG=reliable* egg-bin dev",
+ "dev:view": "cd view && npm run dev",
+ "dev": "concurrently \"npm run dev:view\" \"npm run dev:server\"",
+ "test": "npm run db:prepare:test && npm run lint && npm run test-local",
+ "test-local": "egg-bin test",
+ "test:debug": "cross-env DEBUG=reliable* egg-bin test",
+ "cov": "npm run db:prepare:test && egg-bin cov",
+ "lint": "eslint . --fix",
+ "ci": "npm run lint && npm run cov",
+ "autod": "autod",
+ "build:docker": "docker build --no-cache --pull -t macacajs/reliable-web .",
+ "db:prepare:test": "cross-env NODE_ENV=test npm run db:migrate:undo:all && cross-env NODE_ENV=test npm run db:migrate",
+ "db:status": "sequelize db:migrate:status",
+ "db:migrate": "sequelize db:migrate",
+ "db:migrate:undo": "sequelize db:migrate:undo",
+ "db:migrate:undo:all": "sequelize db:migrate:undo:all",
+ "db:seed": "sequelize db:seed",
+ "db:seed:all": "sequelize db:seed:all",
+ "db:seed:undo:all": "sequelize db:seed:undo:all",
+ "migration:generate": "sequelize migration:generate --name",
+ "seed:generate": "sequelize seed:generate --name"
+ },
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/macacajs/reliable.git"
+ },
+ "license": "MIT"
+}
diff --git a/test/app/controller/app.test.js b/test/app/controller/app.test.js
new file mode 100644
index 0000000..29d5c9e
--- /dev/null
+++ b/test/app/controller/app.test.js
@@ -0,0 +1,232 @@
+'use strict';
+
+const {
+ app,
+ assert,
+} = require('egg-mock/bootstrap');
+
+const { delay } = require('../../util');
+const postData = require('../../fixtures/post-gw.json');
+
+async function insertData(customData = {}) {
+ return await app.httpRequest()
+ .post('/api/gw')
+ .send(Object.assign({}, postData, customData));
+}
+
+describe('test/app/controller/app.test.js', function() {
+
+ let ctx;
+ beforeEach(() => {
+ ctx = app.mockContext();
+ app.mockService('webhook', 'pushBuildNotification', {});
+ });
+
+ it('GET /api/app/:appId with empty deploy result and change build extraInfo', async () => {
+ const appId = 'APP_ONE';
+ await app.model.Config.create({
+ data: {},
+ });
+ // await app.model.Credential.create({
+ // provider: 'ALIYUN_OSS',
+ // bucketTag: 'dev',
+ // region: 'region',
+ // bucket: 'bucket',
+ // namespace: 'namespace',
+ // accessKeyId: 'accessKeyId',
+ // accessKeySecret: 'accessKeySecret',
+ // });
+ await insertData({
+ gitCommitInfo: {
+ gitBranch: 'feat/one',
+ gitUrl: 'http://domain/url/one',
+ },
+ extraInfo: {
+ appId,
+ },
+ environment: {
+ ci: {
+ RUNNER_TYPE: 'GITLAB_CI',
+ JOB_NAME: 'jobName',
+ BUILD_NUMBER: '11',
+ },
+ platform: 'web',
+ },
+ });
+
+ await delay(1000);
+
+ const { body: { data: { uniqId: buildUniqId } } } = await insertData({
+ gitCommitInfo: {
+ gitBranch: 'feat/two',
+ gitUrl: 'http://domain/url/two',
+ },
+ extraInfo: {
+ appId,
+ },
+ environment: {
+ ci: {
+ RUNNER_TYPE: 'GITLAB_CI',
+ JOB_NAME: 'jobName',
+ BUILD_NUMBER: '12',
+ },
+ platform: 'web',
+ },
+ });
+ await ctx.model.JobName.bulkCreate([{
+ jobName: 'android',
+ }, {
+ jobName: 'ios',
+ }]);
+ const { body: updateRes } = await app.httpRequest()
+ .put(`/api/build/${buildUniqId}`)
+ .send({
+ extendInfo: {
+ key: 'value',
+ },
+ });
+ assert.deepStrictEqual(updateRes, {
+ success: true,
+ data: [ 1 ],
+ });
+ const { body: queryResult } = await app.httpRequest()
+ .get(`/api/app/${appId}?bucketTag=dev&type=type1`);
+ assert(queryResult.success);
+ assert(queryResult.data.appId === appId);
+ assert(queryResult.data.gitRepo === 'http://domain/url/two');
+ assert(queryResult.data.builds.length === 2);
+ assert(queryResult.data.builds[0].uniqId.length === 36);
+ assert(queryResult.data.builds[0].version === '1.0.0');
+ assert(queryResult.data.builds[0].gitBranch === 'feat/two');
+ assert(queryResult.data.builds[0].deploy === null);
+ assert(queryResult.data.builds[0].reliableDeployUrl === 'http://127.0.0.1/buildinfo?jobName=jobName&buildNumber=12');
+ assert(queryResult.data.builds[0].state === 'SUCCESS');
+ assert.deepStrictEqual(queryResult.data.builds[0].extendInfo, {
+ key: 'value',
+ });
+ assert.deepStrictEqual(queryResult.data.builds[0].gitCommitInfo, {
+ gitUrl: 'http://domain/url/two',
+ gitBranch: 'feat/two',
+ });
+ assert.deepStrictEqual(queryResult.data.builds[0].testInfo, {
+ tests: 16,
+ passes: 16,
+ linePercent: 95.24,
+ passPercent: 100,
+ testHtmlReporterPath: 'http://host/index.html',
+ coverageHtmlReporterPath: 'http://host/index.html',
+ });
+ assert.deepStrictEqual(queryResult.data.builds[1].extendInfo, {});
+ });
+
+ it('GET /api/app/:appId contains deploy result', async () => {
+ const appId = 'APP_ONE';
+ app.mockService('deployAliyunOss', 'deploy', {
+ success: true,
+ message: '',
+ uploadResult: {
+ other: [{
+ url: 'https://github.com/a.zip',
+ }],
+ },
+ });
+ await app.model.Config.create({
+ data: {},
+ });
+ const { uniqId: credentialUniqId } = await app.model.Credential.create({
+ provider: 'ALIYUN_OSS',
+ bucketTag: 'dev',
+ region: 'region',
+ bucket: 'bucket',
+ namespace: 'namespace',
+ accessKeyId: 'accessKeyId',
+ accessKeySecret: 'accessKeySecret',
+ });
+ const { body: { data: { uniqId: buildUniqId } } } = await insertData({
+ gitCommitInfo: {
+ gitBranch: 'feat/two',
+ gitUrl: 'http://domain/url/two',
+ },
+ extraInfo: {
+ appId,
+ },
+ });
+ await app.httpRequest()
+ .post('/api/deploy')
+ .send({
+ type: 'type1',
+ buildUniqId,
+ credentialSecret: 'accessKeySecret',
+ credentialUniqId,
+ });
+ const { body: queryResult } = await app.httpRequest()
+ .get(`/api/app/${appId}?bucketTag=dev&type=type1`);
+ assert(queryResult.success);
+ assert(queryResult.data.appId === appId);
+ assert(queryResult.data.gitRepo === 'http://domain/url/two');
+ assert(queryResult.data.builds.length === 1);
+ assert(queryResult.data.builds[0].state === 'SUCCESS');
+ assert.deepStrictEqual(queryResult.data.builds[0].extendInfo, {});
+ assert.deepStrictEqual(queryResult.data.builds[0].deploy, {
+ package: {
+ url: 'https://github.com/a.zip',
+ },
+ });
+ });
+
+ it('GET /api/app/:appId contains customDomain deploy result', async () => {
+ const appId = 'APP_ONE';
+ app.mockService('deployAliyunOss', 'deploy', {
+ success: true,
+ message: '',
+ uploadResult: {
+ other: [{
+ url: 'https://github.com/a.zip',
+ }],
+ },
+ });
+ await app.model.Config.create({
+ data: {},
+ });
+ const { uniqId: credentialUniqId } = await app.model.Credential.create({
+ provider: 'ALIYUN_OSS',
+ bucketTag: 'dev',
+ region: 'region',
+ bucket: 'bucket',
+ namespace: 'namespace',
+ accessKeyId: 'accessKeyId',
+ accessKeySecret: 'accessKeySecret',
+ customDomainProtocal: 'https://',
+ customDomain: 'github.io',
+ });
+ const { body: { data: { uniqId: buildUniqId } } } = await insertData({
+ gitCommitInfo: {
+ gitBranch: 'feat/two',
+ gitUrl: 'http://domain/url/two',
+ },
+ extraInfo: {
+ appId,
+ },
+ });
+ await app.httpRequest()
+ .post('/api/deploy')
+ .send({
+ type: 'type1',
+ buildUniqId,
+ credentialSecret: 'accessKeySecret',
+ credentialUniqId,
+ });
+ const { body: queryResult } = await app.httpRequest()
+ .get(`/api/app/${appId}?bucketTag=dev&type=type1`);
+ assert(queryResult.success);
+ assert(queryResult.data.appId === appId);
+ assert(queryResult.data.gitRepo === 'http://domain/url/two');
+ assert(queryResult.data.builds.length === 1);
+ assert.deepStrictEqual(queryResult.data.builds[0].extendInfo, {});
+ assert.deepStrictEqual(queryResult.data.builds[0].deploy, {
+ package: {
+ url: 'https://github.io/a.zip',
+ },
+ });
+ });
+});
diff --git a/test/app/controller/build.test.js b/test/app/controller/build.test.js
new file mode 100644
index 0000000..12ae499
--- /dev/null
+++ b/test/app/controller/build.test.js
@@ -0,0 +1,270 @@
+'use strict';
+
+const {
+ app,
+ assert,
+} = require('egg-mock/bootstrap');
+
+const { delay } = require('../../util');
+const postData = require('../../fixtures/post-gw.json');
+
+async function insertData(customData = {}) {
+ return await app.httpRequest()
+ .post('/api/gw')
+ .send(Object.assign({}, postData, customData));
+}
+
+describe('test/app/controller/build.test.js', function() {
+
+ let ctx;
+ beforeEach(() => {
+ ctx = app.mockContext();
+ app.mockService('webhook', 'pushBuildNotification', {});
+ });
+
+ it('GET /api/build query all builds', async () => {
+ await ctx.model.Build.bulkCreate([{
+ jobName: 'android',
+ buildNumber: '10',
+ data: {},
+ }, {
+ jobName: 'ios',
+ buildNumber: '20',
+ data: {},
+ }]);
+ await ctx.model.JobName.bulkCreate([{
+ jobName: 'android',
+ }, {
+ jobName: 'ios',
+ }]);
+ const { body } = await app.httpRequest()
+ .get('/api/build');
+ assert(body.success === true);
+ assert.deepStrictEqual(body.data.allJobName, [ 'android', 'ios' ]);
+ assert(body.data.total);
+ assert(body.data.page);
+ assert(body.data.result.length === 2);
+ assert(body.data.result[0].jobName);
+ assert(body.data.result[0].buildNumber);
+ assert(body.data.result[0].data);
+ assert(body.data.result[0].uniqId);
+ assert(body.data.result[0].createdAt);
+ assert(body.data.result[1].jobName);
+ assert(body.data.result[1].buildNumber);
+ assert(body.data.result[1].data);
+ assert(body.data.result[1].uniqId);
+ assert(body.data.result[1].createdAt);
+ });
+
+ it('GET /api/build/:uniqId query one build', async () => {
+ const [{ uniqId }] = await ctx.model.Build.bulkCreate([{
+ jobName: 'android',
+ buildNumber: '10',
+ data: {},
+ }, {
+ jobName: 'ios',
+ buildNumber: '20',
+ data: {},
+ }]);
+ await ctx.model.JobName.bulkCreate([{
+ jobName: 'android',
+ }, {
+ jobName: 'ios',
+ }]);
+ const { body } = await app.httpRequest()
+ .get(`/api/build/${uniqId}`);
+ assert(body.success === true);
+ assert(body.data.jobName === 'android');
+ assert(body.data.buildNumber === '10');
+ assert(body.data.data);
+ assert(body.data.uniqId === uniqId);
+ assert(body.data.createdAt);
+ });
+
+ it('GET /api/build/:jobName query by jobName', async () => {
+ await insertData({
+ environment: {
+ ci: {
+ RUNNER_TYPE: 'GITLAB_CI',
+ JOB_NAME: 'android_app',
+ BUILD_NUMBER: '1',
+ },
+ platform: 'android',
+ os: {
+ nodeVersion: 'v1.1.2',
+ platform: 'linux',
+ },
+ },
+ });
+
+ await insertData({
+ environment: {
+ ci: {
+ RUNNER_TYPE: 'GITLAB_CI',
+ JOB_NAME: 'ios_app',
+ BUILD_NUMBER: '1',
+ },
+ platform: 'ios',
+ os: {
+ nodeVersion: 'v1.1.2',
+ platform: 'linux',
+ },
+ },
+ });
+
+ const { body } = await app.httpRequest()
+ .get('/api/build?jobName=ios_app');
+ assert.deepStrictEqual(body.data.allJobName, [ 'android_app', 'ios_app' ]);
+ assert(body.data.total);
+ assert(body.data.page);
+ assert(body.data.result.length === 1);
+ assert(body.data.result[0].jobName === 'ios_app');
+ assert(body.data.result[0].buildNumber === '1');
+ assert(body.data.result[0].data);
+ assert(body.data.result[0].uniqId);
+ assert(body.data.result[0].createdAt);
+ });
+
+ it('GET /api/build/:jobName/:buildNumber query by jobName and buildNumber', async () => {
+ await insertData({
+ environment: {
+ ci: {
+ RUNNER_TYPE: 'GITLAB_CI',
+ JOB_NAME: 'android_app',
+ BUILD_NUMBER: '1',
+ },
+ platform: 'android',
+ os: {
+ nodeVersion: 'v1.1.2',
+ platform: 'linux',
+ },
+ },
+ });
+
+ await insertData({
+ environment: {
+ ci: {
+ RUNNER_TYPE: 'GITLAB_CI',
+ JOB_NAME: 'ios_app',
+ BUILD_NUMBER: '1',
+ },
+ platform: 'ios',
+ os: {
+ nodeVersion: 'v1.1.2',
+ platform: 'linux',
+ },
+ },
+ });
+
+ const { body } = await app.httpRequest()
+ .get('/api/build?jobName=ios_app&buildNumber=1');
+ assert(body.success === true);
+ assert(body.data.jobName === 'ios_app');
+ assert(body.data.buildNumber === '1');
+ assert(typeof body.data.data === 'object');
+ assert(body.data.uniqId);
+ assert(body.data.createdAt);
+ });
+
+ it('GET /api/latestBuild/:jobName/:gitBranch+ query latest build', async () => {
+ await ctx.model.Build.bulkCreate([{
+ jobName: 'ios_app',
+ gitBranch: 'master',
+ buildNumber: '10',
+ data: {
+ environment: {
+ os: {
+ nodeVersion: 'v1.1.2',
+ platform: 'linux',
+ },
+ },
+ },
+ }, {
+ jobName: 'web_app',
+ gitBranch: 'master',
+ buildNumber: '20',
+ data: {
+ environment: {
+ os: {
+ nodeVersion: 'v1.1.2',
+ platform: 'linux',
+ },
+ },
+ },
+ }]);
+ const { body } = await app.httpRequest()
+ .get('/api/latestBuild/web_app/master');
+ assert(body.success === true);
+ assert(body.data.result[0].jobName === 'web_app');
+ assert(body.data.result[0].buildNumber === '20');
+ });
+
+ it('POST /api/build/:uniqId update build extendInfo', async () => {
+ const appId = 'APP_ONE';
+ await app.model.Config.create({
+ data: {},
+ });
+ // await app.model.Credential.create({
+ // provider: 'ALIYUN_OSS',
+ // bucketTag: 'dev',
+ // region: 'region',
+ // bucket: 'bucket',
+ // namespace: 'namespace',
+ // accessKeyId: 'accessKeyId',
+ // accessKeySecret: 'accessKeySecret',
+ // });
+ await insertData({
+ gitCommitInfo: {
+ gitBranch: 'feat/one',
+ gitUrl: 'http://domain/url/one',
+ },
+ extraInfo: {
+ // appId,
+ },
+ environment: {
+ ci: {
+ RUNNER_TYPE: 'GITLAB_CI',
+ JOB_NAME: 'jobName',
+ BUILD_NUMBER: '11',
+ },
+ platform: 'web',
+ },
+ });
+
+ await delay(1000);
+
+ const { body: { data: { uniqId: buildUniqId } } } = await insertData({
+ gitCommitInfo: {
+ gitBranch: 'feat/two',
+ gitUrl: 'http://domain/url/two',
+ },
+ extraInfo: {
+ appId,
+ },
+ environment: {
+ ci: {
+ RUNNER_TYPE: 'GITLAB_CI',
+ JOB_NAME: 'jobName',
+ BUILD_NUMBER: '12',
+ },
+ platform: 'web',
+ },
+ });
+ await ctx.model.JobName.bulkCreate([{
+ jobName: 'android',
+ }, {
+ jobName: 'ios',
+ }]);
+ const { body: updateRes } = await app.httpRequest()
+ .put(`/api/build/${buildUniqId}`)
+ .send({
+ extendInfo: {
+ key: 'value',
+ },
+ });
+ assert.deepStrictEqual(updateRes, {
+ success: true,
+ data: [ 1 ],
+ });
+ });
+});
diff --git a/test/app/controller/config.test.js b/test/app/controller/config.test.js
new file mode 100644
index 0000000..1373256
--- /dev/null
+++ b/test/app/controller/config.test.js
@@ -0,0 +1,62 @@
+'use strict';
+
+const {
+ app,
+ assert,
+} = require('egg-mock/bootstrap');
+
+describe('test/app/controller/config.test.js', () => {
+
+ beforeEach(() => {
+ app.mockService('webhook', 'pushBuildNotification', {});
+ });
+
+ async function assertConfig(app, data) {
+ const res = await app.httpRequest()
+ .get('/api/config');
+ assert.deepStrictEqual(res.body, data);
+ }
+
+ it('POST /api/config create and update', async () => {
+ await assertConfig(app, {
+ success: true,
+ data: {},
+ });
+
+ let res = await app.httpRequest()
+ .post('/api/config')
+ .send({
+ type: 'webhooks',
+ webhooks: [ 'url-1', 'url-2' ],
+ });
+ assert.deepStrictEqual(res.body, {
+ success: true,
+ data: {},
+ });
+ await assertConfig(app, {
+ success: true,
+ data: {
+ type: 'webhooks',
+ webhooks: [ 'url-1', 'url-2' ],
+ },
+ });
+
+ res = await app.httpRequest()
+ .post('/api/config')
+ .send({
+ type: 'webhooks',
+ webhooks: [ 'url-3', 'url-4' ],
+ });
+ assert.deepStrictEqual(res.body, {
+ success: true,
+ data: {},
+ });
+ await assertConfig(app, {
+ success: true,
+ data: {
+ type: 'webhooks',
+ webhooks: [ 'url-3', 'url-4' ],
+ },
+ });
+ });
+});
diff --git a/test/app/controller/gw.test.js b/test/app/controller/gw.test.js
new file mode 100644
index 0000000..1d931ac
--- /dev/null
+++ b/test/app/controller/gw.test.js
@@ -0,0 +1,70 @@
+'use strict';
+
+const {
+ app,
+ assert,
+} = require('egg-mock/bootstrap');
+
+const postData = require('../../fixtures/post-gw.json');
+
+describe('test/app/controller/gw.test.js', () => {
+
+ beforeEach(async () => {
+ app.mockService('webhook', 'pushBuildNotification', {});
+ await app.model.Build.destroy({
+ where: {},
+ });
+ await app.model.JobName.destroy({
+ where: {},
+ });
+ });
+
+ it('POST /api/gw success', async () => {
+ const { header, body } = await app.httpRequest()
+ .post('/api/gw')
+ .send(postData);
+ assert(header['content-type'] === 'application/json; charset=utf-8');
+ assert(body.success);
+ const data = body.data;
+ assert(data.uniqId);
+ assert.deepStrictEqual(data.data, postData);
+ assert(data.buildNumber === '11');
+ assert(data.jobName === 'jobName');
+ });
+
+ it('POST /api/gw with INIT state', async () => {
+ const { header, body } = await app.httpRequest()
+ .post('/api/gw')
+ .send(Object.assign({}, postData, {
+ testInfo: {},
+ extraInfo: {},
+ packages: [],
+ files: [],
+ }));
+ assert(header['content-type'] === 'application/json; charset=utf-8');
+ assert(body.success);
+ const data = body.data;
+ assert(data.uniqId);
+ assert(data.buildNumber === '11');
+ assert.deepStrictEqual(data.data.files, []);
+ assert(data.jobName === 'jobName');
+
+ const { header: updateHeader, body: updateBody } = await app.httpRequest()
+ .post('/api/gw')
+ .send(postData);
+ assert(updateHeader['content-type'] === 'application/json; charset=utf-8');
+ assert.deepStrictEqual(updateBody, {
+ success: true,
+ data: [ 1 ],
+ });
+ });
+
+ it('POST /api/gw error', async () => {
+ const { header, body } = await app.httpRequest()
+ .post('/api/gw')
+ .send(Object.assign({}, postData, { environment: {} }));
+ assert(header['content-type'] === 'application/json; charset=utf-8');
+ assert(body.success === false);
+ assert(body.message === 'environment.ci.JOB_NAME and environment.ci.BUILD_NUMBER are required.');
+ });
+});
diff --git a/test/app/controller/home.test.js b/test/app/controller/home.test.js
new file mode 100644
index 0000000..184cc2d
--- /dev/null
+++ b/test/app/controller/home.test.js
@@ -0,0 +1,18 @@
+'use strict';
+
+const { app, assert } = require('egg-mock/bootstrap');
+
+describe('test/app/controller/home.test.js', () => {
+
+ it('assert keys', () => {
+ const pkg = require('../../../package.json');
+ assert(app.config.keys.startsWith(pkg.name));
+ });
+
+ it('GET /', () => {
+ return app.httpRequest()
+ .get('/')
+ .expect(/Reliable Platform/)
+ .expect(200);
+ });
+});
diff --git a/test/app/controller/insight.test.js b/test/app/controller/insight.test.js
new file mode 100644
index 0000000..771bb3a
--- /dev/null
+++ b/test/app/controller/insight.test.js
@@ -0,0 +1,98 @@
+'use strict';
+
+const {
+ app,
+ assert,
+} = require('egg-mock/bootstrap');
+
+const postData = require('../../fixtures/post-gw.json');
+
+async function insertData(customData = {}) {
+ return await app.httpRequest()
+ .post('/api/gw')
+ .send(Object.assign({}, postData, customData));
+}
+
+describe('test/app/controller/insight.test.js', function() {
+
+ beforeEach(() => {
+ app.mockService('webhook', 'pushBuildNotification', {});
+ });
+
+ it('GET /api/insight/ci query ci data', async () => {
+ await insertData({
+ environment: {
+ ci: {
+ RUNNER_TYPE: 'GITLAB_CI',
+ JOB_NAME: 'android_app',
+ BUILD_NUMBER: '1',
+ },
+ platform: 'android',
+ os: {
+ nodeVersion: 'v1.1.2',
+ platform: 'linux',
+ },
+ },
+ });
+
+ await insertData({
+ environment: {
+ ci: {
+ RUNNER_TYPE: 'GITLAB_CI',
+ JOB_NAME: 'ios_app',
+ BUILD_NUMBER: '1',
+ },
+ platform: 'ios',
+ os: {
+ nodeVersion: 'v1.1.2',
+ platform: 'linux',
+ },
+ },
+ });
+
+ const { body } = await app.httpRequest()
+ .get('/api/insight/ci');
+ assert.deepStrictEqual(body, {
+ success: true,
+ data: [{
+ jobName: 'android_app',
+ linePercent: 95.24,
+ passPercent: 100,
+ lastCommit: { committer: 'user', shortHash: 'ecb4bac', commitUrl: 'http://host/group/repo/commit/ecb4bac' },
+ humanizeDuration: 0,
+ linePercentList: [{
+ commitUrl: 'http://host/group/repo/commit/ecb4bac',
+ shortHash: 'ecb4bac',
+ coverageUrl: 'http://host/index.html',
+ linePercent: 95.24,
+ createdAt: 'a few seconds ago',
+ }],
+ passPercentList: [{
+ commitUrl: 'http://host/group/repo/commit/ecb4bac',
+ shortHash: 'ecb4bac',
+ reporterUrl: 'http://host/index.html',
+ passPercent: 100,
+ createdAt: 'a few seconds ago',
+ }],
+ durationList: [],
+ }, {
+ jobName: 'ios_app',
+ linePercent: 95.24,
+ passPercent: 100,
+ lastCommit: {
+ committer: 'user',
+ shortHash: 'ecb4bac',
+ commitUrl: 'http://host/group/repo/commit/ecb4bac',
+ },
+ humanizeDuration: 0,
+ linePercentList: [{
+ commitUrl: 'http://host/group/repo/commit/ecb4bac', shortHash: 'ecb4bac', coverageUrl: 'http://host/index.html', linePercent: 95.24, createdAt: 'a few seconds ago' },
+ ],
+ passPercentList: [{
+ commitUrl: 'http://host/group/repo/commit/ecb4bac', shortHash: 'ecb4bac', reporterUrl: 'http://host/index.html', passPercent: 100, createdAt: 'a few seconds ago',
+ }],
+ durationList: [],
+ }],
+ });
+ });
+});
diff --git a/test/app/middleware/cors.test.js b/test/app/middleware/cors.test.js
new file mode 100644
index 0000000..c7c4c4a
--- /dev/null
+++ b/test/app/middleware/cors.test.js
@@ -0,0 +1,28 @@
+'use strict';
+
+const {
+ app,
+ assert,
+} = require('egg-mock/bootstrap');
+
+describe('test/app/middleware/cors.test.js', function() {
+
+ it('cors with origin', async () => {
+ const { header, body } = await app.httpRequest()
+ .get('/api/config')
+ .set('origin', 'http://example.com');
+ assert(header['access-control-allow-origin'] === 'http://example.com');
+ assert(header['access-control-allow-credentials'] === 'true');
+ assert(body.success);
+ });
+
+ it('cors ignore OPTIONS request', async () => {
+ const { header } = await app.httpRequest()
+ .options('/api/config')
+ .set('origin', 'http://example.com')
+ .set('Access-Control-Request-Headers', 'x-custom-header');
+ assert(header['access-control-allow-origin'] === 'http://example.com');
+ assert(header['access-control-allow-credentials'] === 'true');
+ assert(header['access-control-allow-headers'] === 'x-custom-header');
+ });
+});
diff --git a/test/fixtures/config-data.json b/test/fixtures/config-data.json
new file mode 100644
index 0000000..9fff414
--- /dev/null
+++ b/test/fixtures/config-data.json
@@ -0,0 +1,9 @@
+{
+ "type": "webhooks",
+ "webhooks": [
+ {
+ "tag": "build",
+ "url": "https://localhost"
+ }
+ ]
+}
diff --git a/test/fixtures/post-gw.json b/test/fixtures/post-gw.json
new file mode 100644
index 0000000..60f0323
--- /dev/null
+++ b/test/fixtures/post-gw.json
@@ -0,0 +1,67 @@
+{
+ "files": [
+ "http://local/readmd.md"
+ ],
+ "packages": [
+ {
+ "path": "http://host/1.zip",
+ "type": "type1",
+ "version": "1.0.0"
+ },
+ {
+ "path": "http://host/2.zip",
+ "type": "type2",
+ "version": "1.0.0"
+ },
+ {
+ "path": "http://host/3.zip",
+ "type": "type3",
+ "version": "1.0.0"
+ }
+ ],
+ "testInfo": {
+ "tests": 16,
+ "passes": 16,
+ "linePercent": 95.24,
+ "passPercent": 100,
+ "testHtmlReporterPath": "http://host/index.html",
+ "coverageHtmlReporterPath": "http://host/index.html"
+ },
+ "extraInfo": {
+ },
+ "environment": {
+ "os": {
+ "platform": "linux",
+ "nodeVersion": "v8.11.4"
+ },
+ "ci": {
+ "RUNNER_TYPE": "GITLAB_CI",
+ "JOB_NAME": "jobName",
+ "BUILD_NUMBER": "11"
+ },
+ "platform": "web"
+ },
+ "gitCommitInfo": {
+ "body": "",
+ "hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+ "author": {
+ "date": "1535946242",
+ "name": "user",
+ "email": "user@domain",
+ "relativeDate": "32 hours ago"
+ },
+ "gitTag": "",
+ "gitUrl": "http://host/group/repo",
+ "subject": "git subject",
+ "committer": {
+ "date": "1536061959",
+ "name": "user",
+ "email": "user@domain",
+ "relativeDate": "5 minutes ago"
+ },
+ "gitBranch": "feat/dev",
+ "gitRemote": "http://host/group/repo.git",
+ "shortHash": "ecb4bac",
+ "sanitizedSubject": "clean-up"
+ }
+}
diff --git a/test/util.js b/test/util.js
new file mode 100644
index 0000000..0d77e89
--- /dev/null
+++ b/test/util.js
@@ -0,0 +1,5 @@
+'use strict';
+
+exports.delay = ms => {
+ return new Promise(resolve => setTimeout(resolve, ms));
+};
diff --git a/view/.babelrc b/view/.babelrc
new file mode 100644
index 0000000..79f0ada
--- /dev/null
+++ b/view/.babelrc
@@ -0,0 +1,26 @@
+{
+ "presets": [
+ "@babel/preset-env",
+ "@babel/preset-react"
+ ],
+ "plugins": [
+ [
+ "import",
+ {
+ "libraryName": "antd",
+ "libraryDirectory": "lib",
+ "style": "css"
+ }
+ ],
+ "@babel/plugin-transform-runtime",
+ "@babel/plugin-proposal-class-properties",
+ "@babel/plugin-proposal-export-default-from"
+ ],
+ "env": {
+ "test": {
+ "plugins": [
+ "istanbul"
+ ]
+ }
+ }
+}
diff --git a/view/.browserslistrc b/view/.browserslistrc
new file mode 100644
index 0000000..0079123
--- /dev/null
+++ b/view/.browserslistrc
@@ -0,0 +1,8 @@
+> 1%
+last 2 versions
+Firefox ESR
+not ie 10
+not ie_mob 10
+
+[development]
+last 1 chrome version
diff --git a/view/.eslintignore b/view/.eslintignore
new file mode 100644
index 0000000..cb32de6
--- /dev/null
+++ b/view/.eslintignore
@@ -0,0 +1,7 @@
+**/.*
+**/node_modules
+**/dist
+**/assets
+**/coverage
+**/reports
+
diff --git a/view/.eslintrc b/view/.eslintrc
new file mode 100644
index 0000000..3a6c019
--- /dev/null
+++ b/view/.eslintrc
@@ -0,0 +1,17 @@
+{
+ "extends": [
+ "eslint-config-antife"
+ ],
+ "plugins": [
+ "react"
+ ],
+ "rules": {
+ "max-len": ["error", {
+ "code": 300
+ }],
+ "semi": [ "error", "always" ],
+ "no-unused-vars": "error",
+ "react/jsx-uses-vars": "error",
+ "react/jsx-uses-react": "error"
+ }
+}
diff --git a/view/.gitignore b/view/.gitignore
new file mode 100644
index 0000000..c6218cd
--- /dev/null
+++ b/view/.gitignore
@@ -0,0 +1,4 @@
+screenshots
+reports
+logs
+coverage
diff --git a/view/.postcssrc.js b/view/.postcssrc.js
new file mode 100644
index 0000000..90d9fff
--- /dev/null
+++ b/view/.postcssrc.js
@@ -0,0 +1,5 @@
+module.exports = {
+ plugins: {
+ autoprefixer: {},
+ },
+}
diff --git a/view/.stylelintrc.js b/view/.stylelintrc.js
new file mode 100644
index 0000000..1aed7b4
--- /dev/null
+++ b/view/.stylelintrc.js
@@ -0,0 +1,65 @@
+'use strict';
+
+module.exports = {
+ rules: {
+ 'at-rule-semicolon-newline-after': 'always',
+ 'block-no-empty': true,
+ 'block-opening-brace-newline-after': 'always',
+ 'block-closing-brace-newline-before': 'always',
+ 'block-opening-brace-space-before': 'always',
+ 'color-hex-length': 'short',
+ 'color-no-invalid-hex': true,
+ 'declaration-block-no-duplicate-properties': [true, { ignore: ['consecutive-duplicates'] }],
+ 'declaration-block-no-shorthand-property-overrides': true,
+ 'declaration-block-trailing-semicolon': 'always',
+ 'function-url-quotes': 'never',
+ indentation: 2,
+ 'max-empty-lines': 2,
+ 'max-line-length': 200,
+ 'max-nesting-depth': 5,
+ 'no-duplicate-selectors': true,
+ 'no-eol-whitespace': true,
+ 'no-missing-end-of-source-newline': true,
+ 'no-unknown-animations': true,
+ 'length-zero-no-unit': true,
+ 'rule-empty-line-before': ['always-multi-line', { ignore: ['after-comment', 'inside-block'] }],
+ 'string-no-newline': true,
+ 'time-min-milliseconds': 100,
+ 'unit-no-unknown': true,
+
+ 'function-linear-gradient-no-nonstandard-direction': true,
+ 'property-no-vendor-prefix': null,
+ 'selector-no-vendor-prefix': null,
+ 'value-no-vendor-prefix': true,
+
+ 'comment-whitespace-inside': 'always',
+ 'declaration-bang-space-after': 'never',
+ 'declaration-bang-space-before': 'always',
+ 'declaration-colon-space-after': 'always',
+ 'declaration-colon-space-before': 'never',
+ 'function-comma-space-after': 'always',
+ 'function-comma-space-before': 'never',
+ 'function-calc-no-unspaced-operator': true,
+ 'function-whitespace-after': 'always',
+ 'media-feature-colon-space-after': 'always',
+ 'media-feature-colon-space-before': 'never',
+ 'media-feature-parentheses-space-inside': 'never',
+ 'media-feature-range-operator-space-after': 'always',
+ 'media-feature-range-operator-space-before': 'always',
+ 'selector-combinator-space-after': 'always',
+ 'selector-combinator-space-before': 'always',
+ 'selector-list-comma-space-after': 'always-single-line',
+ 'selector-list-comma-space-before': 'never',
+ 'value-list-comma-space-after': 'always',
+ 'value-list-comma-space-before': 'never',
+
+ 'at-rule-name-case': 'lower',
+ 'function-name-case': 'lower',
+ 'property-case': 'lower',
+ 'selector-pseudo-class-case': 'lower',
+ 'selector-pseudo-element-case': 'lower',
+ 'selector-type-case': 'lower',
+ 'unit-case': 'lower',
+ },
+}
+
diff --git a/view/index.html b/view/index.html
new file mode 100644
index 0000000..8df44e0
--- /dev/null
+++ b/view/index.html
@@ -0,0 +1,36 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/view/lib/render.js b/view/lib/render.js
new file mode 100644
index 0000000..35e9e00
--- /dev/null
+++ b/view/lib/render.js
@@ -0,0 +1,22 @@
+'use strict';
+
+const fs = require('fs');
+const path = require('path');
+
+const templatePath = path.join(__dirname, '..', 'index.html');
+const template = fs.readFileSync(templatePath, 'utf8');
+
+module.exports = async (context, options = {}) => {
+ const content = template.replace(//, () => {
+ return `
+
+ `;
+ }).replace(//, () => {
+ return `${options.title}`;
+ });
+ return content;
+};
+
diff --git a/view/package.json b/view/package.json
new file mode 100644
index 0000000..f7728ff
--- /dev/null
+++ b/view/package.json
@@ -0,0 +1,87 @@
+{
+ "name": "reliable-view",
+ "version": "",
+ "description": "view layer for Reliable",
+ "files": [
+ "dist/*.js",
+ "dist/*.css",
+ "lib/*.js",
+ "index.html"
+ ],
+ "links": {
+ "issues": "https://github.com/macacajs/reliable/issues?utf8=%E2%9C%93&q=",
+ "document": "https://github.com/macacajs/reliable/tree/master/docs"
+ },
+ "scripts": {
+ "dev": "cross-env NODE_ENV=development webpack-dev-server --mode development",
+ "dev:test": "cross-env NODE_ENV=test webpack-dev-server --mode development",
+ "serve": "npm run dev:test &",
+ "test": "macaca run -d ./test",
+ "lint": "eslint --fix . --ext jsx,js && stylelint --fix assets/**/*.less -s less",
+ "build": "cross-env NODE_ENV=production webpack -p --mode production",
+ "build:report": "npm run build --report",
+ "prepublishOnly": "npm run build",
+ "ci": "npm run lint && npm run build && npm run serve && npm test",
+ "contributor": "git-contributor"
+ },
+ "precommit": [
+ "lint"
+ ],
+ "devDependencies": {
+ "@babel/core": "^7.0.0",
+ "@babel/plugin-proposal-class-properties": "^7.0.0",
+ "@babel/plugin-proposal-export-default-from": "^7.0.0",
+ "@babel/plugin-syntax-dynamic-import": "^7.0.0",
+ "@babel/plugin-transform-runtime": "^7.0.0",
+ "@babel/preset-env": "^7.0.0",
+ "@babel/preset-react": "^7.0.0",
+ "@babel/register": "^7.0.0",
+ "@babel/runtime": "^7.0.0",
+ "antd": "^3.6.1",
+ "autoprefixer": "^9.1.0",
+ "awesome-clipboard": "^2.0.2",
+ "babel-loader": "^8.0.0",
+ "babel-plugin-import": "^1.2.1",
+ "babel-plugin-istanbul": "^5.0.1",
+ "cross-env": "^5.1.1",
+ "css-loader": "^0.28.11",
+ "eslint": "^4.5.0",
+ "eslint-config-antife": "^1.0.2",
+ "eslint-plugin-mocha": "^5.0.0",
+ "eslint-plugin-react": "^7.2.1",
+ "git-contributor": "^1.0.8",
+ "less": "^2.7.2",
+ "less-loader": "^4.1.0",
+ "lodash.get": "^4.4.2",
+ "lodash.uniqby": "^4.7.0",
+ "macaca-cli": "2",
+ "macaca-electron": "2",
+ "macaca-wd": "2",
+ "mini-css-extract-plugin": "^0.4.0",
+ "postcss-loader": "^2.1.6",
+ "pre-commit": "^1.2.2",
+ "qrcode-react": "^0.1.16",
+ "query-string": "^6.2.0",
+ "react": "^16.6.0",
+ "react-code-input": "^3.8.0",
+ "react-dom": "^16.6.0",
+ "react-github-button": "0.1.11",
+ "react-intl": "^2.4.0",
+ "react-load-script": "^0.0.6",
+ "react-router-dom": "^4.2.2",
+ "style-loader": "^0.21.0",
+ "stylelint": "^9.3.0",
+ "url-loader": "^1.1.2",
+ "url-parse": "^1.4.3",
+ "webpack": "^4.10.0",
+ "webpack-bundle-analyzer": "^2.13.1",
+ "webpack-cli": "^3.0.1",
+ "webpack-dev-server": "^3.1.4",
+ "whatwg-fetch": "^2.0.3",
+ "xutil": "1"
+ },
+ "dependencies": {
+ "dayjs": "^1.7.7"
+ },
+ "licenses": "MIT"
+}
diff --git a/view/postcss.config.js b/view/postcss.config.js
new file mode 100644
index 0000000..ccd5722
--- /dev/null
+++ b/view/postcss.config.js
@@ -0,0 +1,8 @@
+'use strict';
+
+module.exports = {
+ plugins: [
+ require('autoprefixer'),
+ ],
+};
+
diff --git a/view/src/components/BuildsTable.js b/view/src/components/BuildsTable.js
new file mode 100644
index 0000000..cf8133a
--- /dev/null
+++ b/view/src/components/BuildsTable.js
@@ -0,0 +1,157 @@
+'use strict';
+
+import React from 'react';
+import dayjs from 'dayjs';
+import { Link } from 'react-router-dom';
+import Clipboard from 'awesome-clipboard';
+import {
+ Icon,
+ Table,
+ Popover,
+ message,
+} from 'antd';
+import { FormattedMessage } from 'react-intl';
+
+import {
+ getUuid,
+ mapBuildDataToColor,
+} from '../util/index';
+
+import './buildsTable.less';
+
+export default class BuildsTable extends React.Component {
+ handleTableChange = (pagination, filters, sorter) => {
+ const pager = { ...this.props.pagination };
+ pager.current = pagination.current;
+ this.props.updatePagination(pager);
+ }
+ columns = [{
+ title: ,
+ dataIndex: 'jobName',
+ render: (text, record) =>
+
+ {text}
+ ,
+ }, {
+ title: ,
+ width: 100,
+ render: (text, record) =>
+ record.buildLogUrl
+ ?
+
+
+ :
+
+ ,
+ }, {
+ title: ,
+ dataIndex: 'buildNumber',
+ width: 160,
+ render: (value, record) => (
+
+
+
+
+
+ {value}
+
+
+ ),
+ }, {
+ title: ,
+ dataIndex: 'platform',
+ }, {
+ title: ,
+ dataIndex: 'buildEndTime',
+ render: (text, record) => (
+
+ {dayjs(text).format('YYYY-MM-DD HH:mm:ss')}
+
+ uniqId: {record.buildUniqId}
+
+ } trigger="hover" placement="top">
+ {
+ Clipboard.write(record.buildUniqId).then(res => {
+ res && message.success('UniqId copied to clipboard.');
+ });
+ }} className="builds-table-uniqId-tip"
+ type="copy" theme="filled" style={{ color: '#2593fc' }}
+ />
+
+
+ ),
+ }, {
+ title: ,
+ dataIndex: 'gitBranch',
+ width: 240,
+ render: (text, record) =>
+
+ {record.gitCommitInfo.gitBranch}
+ ,
+ }, {
+ title: ,
+ dataIndex: 'committer',
+ width: 120,
+ render: (text, record) =>
+
+ {record.gitCommitInfo.committer.name}
+ ,
+ }, {
+ title: ,
+ dataIndex: 'state',
+ width: 90,
+ render: (text, record) => {
+ return record.state === 'INIT' ? 'init' : 'done';
+ },
+ }, {
+ title: ,
+ align: 'center',
+ width: 80,
+ render: (value, record) => {
+ if (record.state === 'INIT') return;
+ return (
+ record.buildNumber
+ ?
+
+
+ : ''
+ );
+ },
+ }
+];
+
+ render() {
+ return (
+
+
record.buildNumber + getUuid()}
+ rowClassName={record => mapBuildDataToColor(record)}
+ dataSource={this.props.data}
+ loading={this.props.loading}
+ pagination={this.props.pagination}
+ onChange={this.handleTableChange}
+ />
+
+ );
+ }
+}
diff --git a/view/src/components/BuildsTabs.js b/view/src/components/BuildsTabs.js
new file mode 100644
index 0000000..e816d5a
--- /dev/null
+++ b/view/src/components/BuildsTabs.js
@@ -0,0 +1,164 @@
+'use strict';
+
+import React from 'react';
+import { Tabs } from 'antd';
+import { withRouter } from 'react-router-dom';
+import { FormattedMessage } from 'react-intl';
+import safeGet from 'lodash.get';
+
+import request from '../util/request';
+import BuildsTable from './BuildsTable';
+import {
+ PAGE_SIZE,
+} from '../constants/index';
+import { logger, queryParse } from '../util/index';
+
+const TabPane = Tabs.TabPane;
+
+class BuildsTabs extends React.Component {
+ state = {
+ data: [],
+ loading: false,
+ jobName: queryParse(location.search).jobName || '',
+ allJobName: [],
+ pagination: {
+ pageSize: PAGE_SIZE,
+ },
+ };
+
+ getBuildsInfo (res) {
+ const result = [];
+ res.forEach(item => {
+ // common fields
+ const jobName = item.jobName;
+ const buildNumber = item.buildNumber;
+ const platform = item.data.environment.platform;
+ const buildEndTime = item.createdAt;
+ const testInfo = item.data.testInfo;
+ const gitCommitInfo = item.data.gitCommitInfo;
+ const buildUniqId = item.uniqId;
+ const hasDeployed = Array.isArray(item.deploys) &&
+ item.deploys.some(deploy => deploy.state === 'SUCCESS');
+ const state = item.state;
+
+ const formattedItem = {
+ buildNumber,
+ jobName,
+ platform,
+ testInfo,
+ buildEndTime,
+ gitCommitInfo,
+ buildUniqId,
+ hasDeployed,
+ state,
+ };
+
+ const buildData = item.data;
+ const runnerType = safeGet(buildData, 'environment.ci.RUNNER_TYPE');
+ // TODO remove
+ const isGitlab = buildData.environment && buildData.environment.gitlab_ci;
+ if (runnerType === 'GITLAB_CI' || isGitlab) {
+ Object.assign(formattedItem, {
+ buildUrl: `${buildData.gitCommitInfo.gitUrl}/builds/${buildNumber}`,
+ configureUrl: buildData.gitCommitInfo.gitUrl,
+ buildLogUrl: `${buildData.gitCommitInfo.gitUrl}/builds/${buildNumber}`,
+ });
+ }
+ result.push(formattedItem);
+ });
+ return result;
+ }
+
+ updatePagination = (pagination, tab) => {
+ const pager = { ...this.state.pagination };
+ Object.assign(pager, pagination);
+ this.setState({
+ pagination: pager,
+ });
+ this.fetch(pagination, tab);
+ }
+
+ fetch = (pager, tab) => {
+ const param = {
+ num: this.state.pagination.pageSize,
+ page: pager && pager.current || 1,
+ jobName: tab,
+ };
+ logger('request getBuildsTable', param);
+
+ this.setState({ loading: true });
+
+ request('getBuildsTable', 'GET', param).then((res) => {
+ if (res.success && res.data && res.data.result) {
+ logger('getBuildsTable res', res);
+ const data = this.getBuildsInfo(res.data.result);
+
+ // Read total count from server
+ const pagination = { ...this.state.pagination };
+ pagination.total = res.data.total;
+
+ this.setState({
+ loading: false,
+ data,
+ pagination,
+ allJobName: res.data && res.data.allJobName || [],
+ });
+ } else {
+ console.error(res);
+ }
+ });
+ }
+
+ handleTabClick = tab => {
+ const pagination = {
+ pageSize: PAGE_SIZE,
+ current: 1,
+ };
+ const path = tab ? `./?jobName=${tab}` : './';
+
+ this.setState({
+ jobName: tab,
+ pagination,
+ });
+ this.props.history.push(path);
+ this.updatePagination(pagination, tab);
+ }
+
+ componentDidMount () {
+ const tab = queryParse(location.search).jobName;
+ this.setState({
+ jobName: tab,
+ });
+ this.fetch({ ...this.state.pagination }, tab);
+ }
+
+ render () {
+ const allJobName = this.state.allJobName;
+ const listItems = allJobName && allJobName.map(item =>
+
+ );
+ return (
+
+ { listItems }
+
+ } key="">
+
+ { listItems }
+
+
+
+ );
+ }
+}
+
+export default withRouter(BuildsTabs);
diff --git a/view/src/components/ChartCard.js b/view/src/components/ChartCard.js
new file mode 100644
index 0000000..d55e562
--- /dev/null
+++ b/view/src/components/ChartCard.js
@@ -0,0 +1,24 @@
+import React from 'react';
+import { Card, Spin } from 'antd';
+
+import './chartCard.less';
+
+class ChartCard extends React.Component {
+ render () {
+ return (
+
+ {
+
+ {this.props.title}
+ {this.props.content}
+
+ }
+
+ );
+ }
+}
+
+export default ChartCard;
diff --git a/view/src/components/DingdingSetting.js b/view/src/components/DingdingSetting.js
new file mode 100644
index 0000000..a098b4e
--- /dev/null
+++ b/view/src/components/DingdingSetting.js
@@ -0,0 +1,180 @@
+'use strict';
+
+import React from 'react';
+import safeGet from 'lodash.get';
+import uniqBy from 'lodash.uniqby';
+import { FormattedMessage } from 'react-intl';
+import {
+ Form,
+ Icon,
+ Spin,
+ Input,
+ Button,
+ Select,
+ message,
+} from 'antd';
+
+import request from '../util/request';
+
+const FormItem = Form.Item;
+const Option = Select.Option;
+
+const webhookFormItemLayout = {
+ wrapperCol: {
+ xs: { span: 24 },
+ sm: { offset: 4, span: 16 },
+ },
+};
+const buttonFormItemLayout = {
+ wrapperCol: {
+ xs: { span: 24 },
+ sm: { offset: 10, span: 4 },
+ },
+};
+
+class DingdingSetting extends React.Component {
+ state = {
+ webhooks: [],
+ loading: false,
+ }
+
+ componentDidMount () {
+ this.fetchWebhooks();
+ }
+
+ fetchWebhooks = async () => {
+ this.setState({ loading: true });
+ const res = await request('getWebhooks', 'GET');
+ this.setState({ loading: false });
+ if (!res.success) return;
+
+ const webhooks = safeGet(res, 'data.webhooks');
+ if (!Array.isArray(webhooks) || !webhooks.length) {
+ this.setState({
+ webhooks: [{
+ url: '',
+ }],
+ });
+ return;
+ }
+
+ this.setState({
+ webhooks,
+ });
+ }
+
+ postWebhooks = async (webhooks) => {
+ const res = await request('postWebhooks', 'POST', {
+ type: 'webhooks',
+ webhooks,
+ });
+ if (res.success) {
+ await this.fetchWebhooks();
+ message.success('Update webhooks successfully!');
+ } else {
+ message.error('Update webhooks failed.');
+ console.error('postWebhooks', res);
+ }
+ }
+
+ removeOneNotification = index => {
+ const webhooks = [
+ ...this.state.webhooks,
+ ];
+ webhooks.splice(index, 1);
+ this.setState({ webhooks });
+ }
+
+ addMoreNotification = () => {
+ this.setState({
+ webhooks: [
+ ...this.state.webhooks,
+ {
+ url: '',
+ },
+ ],
+ });
+ }
+
+ updateWebhooks = e => {
+ e.preventDefault();
+ this.props.form.validateFields((err, values) => {
+ if (err) return;
+ const uniqWebhooks = uniqBy(values.webhooks, value => `${value.tag}${value.url}`);
+ this.postWebhooks(uniqWebhooks);
+ });
+ }
+
+ renderWebhookList = () => {
+ const { getFieldDecorator } = this.props.form;
+ return this.state.webhooks.map((webhook, index) => {
+ const notifyTypeSelector = getFieldDecorator(`webhooks[${index}].tag`, {
+ initialValue: webhook.tag || 'build',
+ })(
+
+
+
+ );
+ return (
+ {getFieldDecorator(`webhooks[${index}].url`, {
+ validateTrigger: ['onBlur'],
+ initialValue: webhook.url,
+ rules: [{
+ required: true,
+ type: 'url',
+ whitespace: true,
+ message: 'Please input DingTalk webhook url.',
+ }],
+ })(
+ this.removeOneNotification(index)}
+ />
+ ) : null
+ }
+ />
+ )}
+ );
+ });
+ }
+
+ render () {
+ return (
+
+ );
+ }
+}
+
+export default Form.create()(DingdingSetting);
+
diff --git a/view/src/components/ExtraTable.js b/view/src/components/ExtraTable.js
new file mode 100644
index 0000000..5999c54
--- /dev/null
+++ b/view/src/components/ExtraTable.js
@@ -0,0 +1,37 @@
+'use strict';
+
+import React from 'react';
+import { Table } from 'antd';
+import { FormattedMessage } from 'react-intl';
+
+import { getUuid } from '../util/index';
+
+const columns = [{
+ title: ,
+ dataIndex: 'extraName',
+ width: 200,
+ render: value => {value} ,
+}, {
+ title: ,
+ dataIndex: 'extraContent',
+ render: value => {JSON.stringify(value, null, 2)} ,
+}];
+
+export default class ExtraTable extends React.Component {
+ state = {
+ loading: false,
+ };
+
+ render () {
+ return (
+ getUuid()}
+ dataSource={this.props.data}
+ loading={this.state.loading}
+ onChange={this.handleTableChange}
+ size="small"
+ bordered
+ />
+ );
+ }
+}
diff --git a/view/src/components/FileTable.js b/view/src/components/FileTable.js
new file mode 100644
index 0000000..4b9eece
--- /dev/null
+++ b/view/src/components/FileTable.js
@@ -0,0 +1,25 @@
+'use strict';
+
+import React from 'react';
+import { List } from 'antd';
+
+export default class DepTable extends React.Component {
+ state = {
+ loading: false,
+ };
+
+ render () {
+ return (
+ (
+
+ {item.fileName}
+
+ )}
+ />
+ );
+ }
+}
diff --git a/view/src/components/Header.js b/view/src/components/Header.js
new file mode 100644
index 0000000..3a7b206
--- /dev/null
+++ b/view/src/components/Header.js
@@ -0,0 +1,130 @@
+'use strict';
+
+import React from 'react';
+import {
+ Icon,
+ Menu,
+ Avatar,
+ Layout,
+ Tooltip,
+ Dropdown,
+} from 'antd';
+import safeGet from 'lodash.get';
+import { FormattedMessage } from 'react-intl';
+import GitHubButton from 'react-github-button';
+
+import 'react-github-button/assets/style.css';
+
+import './header.less';
+
+import { LANG_LIST as langList } from '../constants/index';
+
+const nickName = safeGet(window, 'context.user.nick');
+const Header = Layout.Header;
+
+const pkg = require('../../package.json');
+
+function ContentHeader (props) {
+ const toggle = () => {
+ props.toggleCollapsed(!props.collapsed);
+ };
+
+ const changeLang = key => {
+ localStorage.RELIABLE_LANGUAGE = key;
+ location.href = `/?locale=${key}`;
+ };
+
+ const currentLocale = localStorage.RELIABLE_LANGUAGE || '';
+ const menu = (
+
+ {
+ langList
+ .filter(lang => !currentLocale || lang !== currentLocale)
+ .map(lang => {
+ return (
+ {
+ changeLang(key);
+ }}>
+ {lang}
+
+ );
+ })
+ }
+
+ );
+ return (
+
+
+
+
+
+
+ }>
+
+
+
+
+ }>
+
+
+
+
+
+
+
+
+
+ {
+ nickName &&
+
+
+
+ { nickName }
+
+
+
+ {
+ location.href = '/snsAuthorize/signout';
+ }}>
+
+
+ {'Sign out'}
+
+
+
+ } placement="topCenter">
+
+
+ { nickName.substr(0, 1) }
+
+
+
+ }
+
+
+ );
+}
+
+export default ContentHeader;
diff --git a/view/src/components/OneBuildTabs.js b/view/src/components/OneBuildTabs.js
new file mode 100644
index 0000000..5427238
--- /dev/null
+++ b/view/src/components/OneBuildTabs.js
@@ -0,0 +1,133 @@
+'use strict';
+
+import React from 'react';
+import dayjs from 'dayjs';
+import safeGet from 'lodash.get';
+import { Spin, Tabs } from 'antd';
+import { FormattedMessage } from 'react-intl';
+
+import PkgTable from './PkgTable';
+import TestTable from './TestTable';
+import ExtraTable from './ExtraTable';
+import FileTable from './FileTable';
+
+import { getBuildLink } from '../util';
+
+const TabPane = Tabs.TabPane;
+
+export default class OneBuildTabs extends React.Component {
+ getExtraInfo () {
+ const data = this.props.data;
+ const result = [];
+ if (data && data.extraInfo) {
+ Object.keys(data.extraInfo).forEach(item => {
+ result.push({
+ extraName: item,
+ extraContent: data.extraInfo[item],
+ });
+ });
+ }
+ return result;
+ }
+
+ getPkgInfo () {
+ const data = this.props.data;
+ const result = {
+ packages: [],
+ platform: safeGet(data, 'environment.platform'),
+ };
+ if (data && data.packages && data.packages.length && data.gitCommitInfo) {
+ data.packages.forEach(item => {
+ const commitTime = data.gitCommitInfo.committer.date * 1000;
+ data.gitCommitInfo.commitTime = dayjs(commitTime).format('YYYY-MM-DD HH:mm:ss');
+ data.gitCommitInfo.gitHref = `${data.gitCommitInfo.gitUrl}/commit/${data.gitCommitInfo.hash}`;
+ result.packages.push({
+ ...item,
+ download: getBuildLink(this.props.data, item.path),
+ gitCommitInfo: data.gitCommitInfo,
+ buildUniqId: data.buildUniqId,
+ });
+ });
+ }
+ return result;
+ }
+
+ getTestInfo () {
+ const data = this.props.data;
+ const result = [];
+ if (data && data.testInfo && data.testInfo.tests) {
+ const report = data.testInfo.testHtmlReporterPath;
+ const coverage = data.testInfo.coverageHtmlReporterPath;
+ const commitTime = data.gitCommitInfo.committer.date * 1000;
+ result.push({
+ lineCoverage: data.testInfo.linePercent,
+ passingRate: data.testInfo.passPercent,
+ testInfo: data.testInfo,
+ testReporter: getBuildLink(this.props.data, report),
+ coverageReporter: getBuildLink(this.props.data, coverage),
+ gitBranch: data.gitCommitInfo.gitBranch,
+ gitCommit: data.gitCommitInfo.shortHash,
+ gitHref: `${data.gitCommitInfo.gitUrl}/commit/${data.gitCommitInfo.hash}`,
+ committer: data.gitCommitInfo.committer.name,
+ committerEmail: data.gitCommitInfo.committer.email,
+ commitTime: dayjs(commitTime).format('YYYY-MM-DD HH:mm:ss'),
+ });
+ }
+ return result;
+ }
+
+ getFileInfo () {
+ const data = this.props.data;
+ const result = [];
+ if (data && data.files && data.files.length) {
+ data.files.forEach(item => {
+ result.push({
+ fileName: item,
+ fileAddress: getBuildLink(this.props.data, item),
+ });
+ });
+ }
+ return result;
+ }
+
+ onTabChange (type) {
+ location.hash = `type=${type}`;
+ }
+
+ render () {
+ if (this.props.loading) {
+ return ;
+ }
+ const getPkgInfo = this.getPkgInfo();
+ const activeTabKey = location.hash.replace('#type=', '');
+ const fallBackActiveTabKey = getPkgInfo.packages.length ? 'pkginfo' : 'test';
+ return (
+
+
+ } key="pkginfo">
+
+
+ } key="test">
+
+
+ } key="extrainfo">
+
+
+ } key="fileinfo">
+
+
+
+
+ );
+ }
+}
+
diff --git a/view/src/components/PkgTable.js b/view/src/components/PkgTable.js
new file mode 100644
index 0000000..795f861
--- /dev/null
+++ b/view/src/components/PkgTable.js
@@ -0,0 +1,182 @@
+'use strict';
+
+import React from 'react';
+import QRCode from 'qrcode-react';
+import { FormattedMessage } from 'react-intl';
+
+import {
+ Table,
+ Icon,
+ Modal,
+ Popover,
+} from 'antd';
+
+import safeGet from 'lodash.get';
+
+import {
+ getUuid,
+} from '../util/index';
+import logos from './logos';
+
+import './pkgTable.less';
+
+const getLogo = type => {
+ return logos[type] || logos['web'];
+};
+
+export default class PkgTable extends React.Component {
+ state = {
+ loading: false,
+ record: {},
+ visible: false,
+ currentPackage: {},
+ };
+
+ getColumns = () => {
+ let columns = [{
+ title: ,
+ dataIndex: 'version',
+ width: 100,
+ }, {
+ title: ,
+ dataIndex: 'type',
+ width: 160,
+ }];
+ columns = columns.concat([{
+ title: ,
+ dataIndex: 'download',
+ width: 160,
+ render: (value, record) => (
+
+
+
+
+
+
+ ),
+ }]);
+ columns = columns.concat([{
+ title: ,
+ dataIndex: 'gitBranch',
+ width: 300,
+ render: (value, record) => {
+ return (
+ { record.gitCommitInfo.gitBranch }
+ );
+ },
+ }, {
+ title: ,
+ width: 240,
+ dataIndex: 'gitCommit',
+ render: (value, record) => {
+ return (
+
+
+ committer name: { safeGet(record, 'gitCommitInfo.committer.name') }
+
+
+ committer email: { safeGet(record, 'gitCommitInfo.committer.email') }
+
+
+ author name: { safeGet(record, 'gitCommitInfo.author.name') }
+
+
+ author email: { safeGet(record, 'gitCommitInfo.author.email') }
+
+
+ gitTag: { safeGet(record, 'gitCommitInfo.gitTag') || 'null' }
+
+
+ subject: { safeGet(record, 'gitCommitInfo.subject') }
+
+
+ } trigger="hover" placement="left">
+
+ {record.gitCommitInfo.shortHash}
+ {record.gitCommitInfo.committer.name}
+
+ );
+ },
+ }]
+ );
+ return columns;
+ }
+ showQrCodeModal = (record) => {
+ this.setState({
+ record,
+ visible: true,
+ });
+ }
+
+ handleQrCodeOk = (e) => {
+ this.setState({
+ visible: false,
+ });
+ }
+
+ handleQrCodeCancel = (e) => {
+ this.setState({
+ visible: false,
+ });
+ }
+
+ getDownloadUrl = () => {
+ if (this.state.record &&
+ this.state.record.download &&
+ this.state.record.download.startsWith('http')) {
+ return this.state.record.download;
+ }
+ return `${location.protocol}${this.state.record.download}`;
+ }
+
+ render () {
+ return (
+
+
getUuid()}
+ dataSource={this.props.data}
+ loading={this.state.loading}
+ onChange={this.handleTableChange}
+ />
+
+
+
+ {
+ `${this.state.record.version} |
+ ${this.state.record.type} |
+ ${safeGet(this.state.record, 'gitCommitInfo.commitTime')} |
+ ${safeGet(this.state.record, 'gitCommitInfo.shortHash')} |
+ ${safeGet(this.state.record, 'gitCommitInfo.gitBranch')} |
+ ${safeGet(this.state.record, 'gitCommitInfo.committer.name')}`
+ }
+
+
+
+ );
+ }
+}
diff --git a/view/src/components/ReliableLayout.js b/view/src/components/ReliableLayout.js
new file mode 100644
index 0000000..7592665
--- /dev/null
+++ b/view/src/components/ReliableLayout.js
@@ -0,0 +1,40 @@
+'use strict';
+
+import React from 'react';
+import { Layout } from 'antd';
+
+import Header from './Header';
+import SiderBar from './SiderBar';
+
+const Content = Layout.Content;
+
+export default class ReliableLayout extends React.Component {
+ state = {
+ collapsed: localStorage.RELIABLE_SIDERBAR_COLLAPSED === 'true',
+ };
+
+ toggleCollapsed = (value) => {
+ localStorage.RELIABLE_SIDERBAR_COLLAPSED = value;
+ this.setState({
+ collapsed: value,
+ });
+ };
+
+ render () {
+ return (
+
+ { !this.props.hideMenu && }
+
+
+
+ { this.props.children }
+
+
+
+ );
+ }
+}
+
diff --git a/view/src/components/SiderBar.js b/view/src/components/SiderBar.js
new file mode 100644
index 0000000..08cbe0d
--- /dev/null
+++ b/view/src/components/SiderBar.js
@@ -0,0 +1,68 @@
+'use strict';
+
+import React from 'react';
+import { Link } from 'react-router-dom';
+import { Layout, Menu, Icon } from 'antd';
+import { FormattedMessage } from 'react-intl';
+
+const Sider = Layout.Sider;
+
+export default class SiderBar extends React.Component {
+ handleMenuClick (e) {
+ this.setState({
+ currentPath: e.key,
+ });
+ }
+
+ render () {
+ return (
+
+
+
+
+
Reliable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
diff --git a/view/src/components/TestTable.js b/view/src/components/TestTable.js
new file mode 100644
index 0000000..9d50c7d
--- /dev/null
+++ b/view/src/components/TestTable.js
@@ -0,0 +1,75 @@
+'use strict';
+
+import React from 'react';
+import { Table } from 'antd';
+import { FormattedMessage } from 'react-intl';
+
+import { getUuid } from '../util/index';
+
+const columns = [{
+ title: ,
+ dataIndex: 'lineCoverage',
+ render: value => {value ? `${value}%` : ''} ,
+ width: 100,
+}, {
+ title: ,
+ dataIndex: 'passingRate',
+ render: (text, record) =>
+
+ {
+ record.testInfo.passPercent
+ ?
+
+ {record.testInfo.passPercent}%
+
+ {record.testInfo.passes}/{record.testInfo.tests}
+
+ : null
+ }
+ ,
+ width: 140,
+}, {
+ title: ,
+ dataIndex: 'testReporter',
+ render: value => value ? : '',
+}, {
+ title: ,
+ dataIndex: 'coverageReporter',
+ render: value => value ? : '',
+}, {
+ title: ,
+ dataIndex: 'gitBranch',
+ width: 240,
+}, {
+ title: ,
+ dataIndex: 'gitCommit',
+ render: (value, record) => (
+
+ {value}
+
+ ),
+}, {
+ title: ,
+ dataIndex: 'committer',
+}, {
+ title: ,
+ dataIndex: 'commitTime',
+}];
+
+export default class TesTable extends React.Component {
+ state = {
+ data: [],
+ loading: false,
+ };
+
+ render () {
+ return (
+ record.testId + getUuid()}
+ dataSource={this.props.data}
+ loading={this.state.loading}
+ onChange={this.handleTableChange}
+ />
+ );
+ }
+}
diff --git a/view/src/components/buildsTable.less b/view/src/components/buildsTable.less
new file mode 100644
index 0000000..25ebc67
--- /dev/null
+++ b/view/src/components/buildsTable.less
@@ -0,0 +1,13 @@
+.builds-table {
+ td:last-child {
+ position: relative;
+ }
+ .builds-table-uniqId-tip {
+ padding: 0 4px;
+ opacity: 0;
+ }
+ tr:hover .builds-table-uniqId-tip {
+ cursor: pointer;
+ opacity: 1;
+ }
+}
\ No newline at end of file
diff --git a/view/src/components/chartCard.less b/view/src/components/chartCard.less
new file mode 100644
index 0000000..3452c00
--- /dev/null
+++ b/view/src/components/chartCard.less
@@ -0,0 +1,36 @@
+.chartcard {
+ .title {
+ color: rgba(0, 0, 0, 0.45);
+ font-size: 14px;
+ line-height: 22px;
+ height: 22px;
+ }
+ .content {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ word-break: break-all;
+ white-space: nowrap;
+ color: rgba(0, 0, 0, 0.85);
+ margin-bottom: 0;
+ font-size: 30px;
+ }
+ .topcard {
+ font-size: 14px;
+ .anticon {
+ margin-right: 10px;
+ }
+ .up {
+ .anticon {
+ color: green;
+ }
+ }
+ .down {
+ .anticon {
+ color: red;
+ }
+ }
+ .committer {
+ margin-left: 10px;
+ }
+ }
+}
diff --git a/view/src/components/header.less b/view/src/components/header.less
new file mode 100644
index 0000000..f45bb33
--- /dev/null
+++ b/view/src/components/header.less
@@ -0,0 +1,70 @@
+.logo {
+ height: 64px;
+ width: 100%;
+ position: relative;
+ line-height: 64px;
+ padding-left: 24px;
+ transition: all .3s;
+ background: #002140;
+ overflow: hidden;
+
+ a {
+ color: #1890ff;
+ background-color: transparent;
+ text-decoration: none;
+ outline: none;
+ cursor: pointer;
+ transition: color .3s;
+
+ * {
+ display: inline-block;
+ vertical-align: middle;
+ }
+ }
+
+ img {
+ width: 32px;
+ }
+
+ h1 {
+ color: #fff;
+ font-size: 20px;
+ margin: 0 0 0 12px;
+ font-family: "Myriad Pro", "Helvetica Neue", Arial, Helvetica, sans-serif;
+ font-weight: 600;
+ }
+
+}
+
+.ant-layout-header {
+ z-index: 0;
+ .right {
+ height: 100%;
+ float: right;
+ a {
+ cursor: pointer;
+ padding: 0 12px;
+ display: inline-block;
+ transition: all .3s;
+ height: 100%;
+ .anticon {
+ font-size: 14px !important;
+ transform: none;
+ }
+ }
+ }
+}
+
+.github-btn-container {
+ a {
+ padding: 2px 5px 2px 4px !important;
+ }
+ float: right;
+ height: 20px;
+ padding-left: 12px;
+ margin-top: 21px;
+}
+
+.nickname-menu li .anticon {
+ padding-right: 6px;
+}
diff --git a/view/src/components/logos.js b/view/src/components/logos.js
new file mode 100644
index 0000000..d8dbd2b
--- /dev/null
+++ b/view/src/components/logos.js
@@ -0,0 +1,6 @@
+'use strict';
+
+/* eslint max-len: 0 */
+exports.android = 'data:image/jpeg;base64,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';
+exports.ios = 'data:image/jpeg;base64,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';
+exports.web = 'data:image/jpeg;base64,/9j/4QAYRXhpZgAASUkqAAgAAAAAAAAAAAAAAP/sABFEdWNreQABAAQAAAA8AAD/4QMfaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wLwA8P3hwYWNrZXQgYmVnaW49Iu+7vyIgaWQ9Ilc1TTBNcENlaGlIenJlU3pOVGN6a2M5ZCI/PiA8eDp4bXBtZXRhIHhtbG5zOng9ImFkb2JlOm5zOm1ldGEvIiB4OnhtcHRrPSJBZG9iZSBYTVAgQ29yZSA1LjYtYzExMSA3OS4xNTgzMjUsIDIwMTUvMDkvMTAtMDE6MTA6MjAgICAgICAgICI+IDxyZGY6UkRGIHhtbG5zOnJkZj0iaHR0cDovL3d3dy53My5vcmcvMTk5OS8wMi8yMi1yZGYtc3ludGF4LW5zIyI+IDxyZGY6RGVzY3JpcHRpb24gcmRmOmFib3V0PSIiIHhtbG5zOnhtcE1NPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvbW0vIiB4bWxuczpzdFJlZj0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL3NUeXBlL1Jlc291cmNlUmVmIyIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bXBNTTpEb2N1bWVudElEPSJ4bXAuZGlkOkMwM0I3NkYwNjc0RjExRThBNzQ0REQyRUYzNzBCMjBFIiB4bXBNTTpJbnN0YW5jZUlEPSJ4bXAuaWlkOkMwM0I3NkVGNjc0RjExRThBNzQ0REQyRUYzNzBCMjBFIiB4bXA6Q3JlYXRvclRvb2w9IkFkb2JlIFBob3Rvc2hvcCBDQyAyMDE1IE1hY2ludG9zaCI+IDx4bXBNTTpEZXJpdmVkRnJvbSBzdFJlZjppbnN0YW5jZUlEPSIxRDk1N0JERDQxMUIyREU3Qjk5QUU1Rjc4NTg1MzJCOSIgc3RSZWY6ZG9jdW1lbnRJRD0iMUQ5NTdCREQ0MTFCMkRFN0I5OUFFNUY3ODU4NTMyQjkiLz4gPC9yZGY6RGVzY3JpcHRpb24+IDwvcmRmOlJERj4gPC94OnhtcG1ldGE+IDw/eHBhY2tldCBlbmQ9InIiPz7/7gAOQWRvYmUAZMAAAAAB/9sAhAAGBAQEBQQGBQUGCQYFBgkLCAYGCAsMCgoLCgoMEAwM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';
diff --git a/view/src/components/pkgTable.less b/view/src/components/pkgTable.less
new file mode 100644
index 0000000..46fb4be
--- /dev/null
+++ b/view/src/components/pkgTable.less
@@ -0,0 +1,43 @@
+.pkg {
+ .ant-modal-body {
+ padding: 10px;
+ }
+
+ .tips {
+ margin-top: 10px;
+ word-break: break-all;
+ word-wrap: break-word;
+ white-space: initial;
+ }
+
+ .ant-modal-footer {
+ display: none;
+ }
+
+}
+
+.vertical-center-modal {
+ text-align: center;
+ white-space: nowrap;
+}
+
+.vertical-center-modal:before {
+ content: '';
+ display: inline-block;
+ height: 100%;
+ vertical-align: middle;
+ width: 0;
+}
+
+.vertical-center-modal .ant-modal {
+ display: inline-block;
+ vertical-align: middle;
+ top: 0;
+ text-align: left;
+}
+
+.package-table {
+ td:last-child {
+ position: relative;
+ }
+}
diff --git a/view/src/constants/index.js b/view/src/constants/index.js
new file mode 100644
index 0000000..086f0e9
--- /dev/null
+++ b/view/src/constants/index.js
@@ -0,0 +1,7 @@
+'use strict';
+
+export const SERVER_ADDRESS = window.pageConfig.SERVER_ADDRESS; // 9900
+export const STATIC_ADDRESS = window.pageConfig.STATIC_ADDRESS; // 9920
+export const PAGE_SIZE = 20;
+
+export LANG_LIST from './lang_list';
diff --git a/view/src/constants/lang_list.js b/view/src/constants/lang_list.js
new file mode 100644
index 0000000..0ca31dd
--- /dev/null
+++ b/view/src/constants/lang_list.js
@@ -0,0 +1,4 @@
+export default [
+ 'zh-CN',
+ 'en-US',
+];
diff --git a/view/src/i18n/en_US.js b/view/src/i18n/en_US.js
new file mode 100644
index 0000000..b8dd79a
--- /dev/null
+++ b/view/src/i18n/en_US.js
@@ -0,0 +1,71 @@
+export default {
+ 'common.comfirm': 'Confirm',
+ 'common.cancel': 'Cancel',
+ 'common.comfirmDelete': 'Confirm Delete?',
+ 'common.input.invalid': 'Please correct the input',
+
+ 'header.issues': 'issues',
+ 'header.document': 'document',
+
+ 'sidebar.homepage': 'Home Page',
+ 'sidebar.allbuilds': 'All Builds',
+ 'sidebar.buildinfo': 'Build Info',
+ 'sidebar.insight': 'Insight',
+ 'sidebar.setting': 'Setting',
+
+ 'setting.dingMessage': 'DingTalk Setting',
+ 'setting.addDingMessage': 'Add DingTalk Webhook',
+ 'setting.submit': 'Update',
+ 'setting.versioning': 'Version Info',
+ 'setting.notification.build': 'Build',
+
+ 'builds.buildNumber': 'Build Number',
+ 'builds.buildLog': 'Build Log',
+ 'builds.jobName': 'Job Name',
+ 'builds.platform': 'Platform',
+ 'builds.buildEndTime': 'Build End Time',
+ 'builds.detailInfo': 'Detail',
+ 'builds.rank': 'Rank',
+
+ 'buildinfo.pkgTab': 'Package',
+ 'buildinfo.testTab': 'Test Result',
+ 'buildinfo.extraTab': 'Extra Info',
+ 'buildinfo.filesTab': 'Build Product',
+
+ 'buildinfo.pkg.version': 'Version',
+ 'buildinfo.pkg.type': 'Type',
+ 'buildinfo.pkg.download': 'Download',
+ 'buildinfo.pkg.gitBranch': 'Current Branch',
+ 'buildinfo.pkg.gitCommit': 'Commit Url',
+ 'buildinfo.pkg.gitInfo': 'Commit Info',
+ 'buildinfo.pkg.committer': 'Commiter',
+ 'buildinfo.pkg.commitTime': 'Commit Time',
+ 'buildinfo.state': 'Build State',
+
+ 'buildinfo.test.lineCoverage': 'Coverage Percent',
+ 'buildinfo.test.passPercent': 'Pass Percent',
+ 'buildinfo.test.testReporter': 'Tests Reporter',
+ 'buildinfo.test.coverageReporter': 'Coverage Reporter',
+ 'buildinfo.test.reporter': 'reporter',
+
+ 'buildinfo.extra.extraName': 'Name',
+ 'buildinfo.extra.extraContent': 'Content',
+
+ 'buildinfo.files.fileName': 'File Name',
+ 'buildinfo.files.fileAddress': 'Download',
+
+ 'insight.builds.number': 'Total Job Number',
+ 'insight.builds.trend': 'Total Builds Number',
+ 'insight.builds.top': 'Top',
+ 'insight.test.lineCoverage': 'Coverage Percent avg',
+ 'insight.test.lineCoverage.tip': 'Average test coverage over this period of time',
+ 'insight.test.lineCoverage.history': 'Coverage History',
+ 'insight.test.lineCoverage.latest': 'Coverage',
+ 'insight.test.passPercent': 'Pass Percent',
+ 'insight.test.passPercent.tip': 'CI 100% pass rate',
+ 'insight.test.passPercent.history': 'PassPercent History',
+ 'insight.test.duration': 'Duration avg',
+ 'insight.test.duration.history': 'Duration History',
+ 'insight.committer': 'Last Commit',
+ 'insight.dateRange.tip': 'Please select range or query entire data'
+};
diff --git a/view/src/i18n/zh_CN.js b/view/src/i18n/zh_CN.js
new file mode 100644
index 0000000..174f979
--- /dev/null
+++ b/view/src/i18n/zh_CN.js
@@ -0,0 +1,72 @@
+export default {
+ 'common.comfirm': '确认',
+ 'common.cancel': '取消',
+ 'common.comfirmDelete': '确定删除?',
+ 'common.input.invalid': '请修改输入的内容',
+
+ 'header.issues': '问题反馈',
+ 'header.document': '文档',
+
+ 'sidebar.homepage': '主页',
+ 'sidebar.allbuilds': '所有构建',
+ 'sidebar.buildinfo': '构建信息',
+ 'sidebar.insight': '洞察',
+ 'sidebar.setting': '设置',
+
+ 'setting.dingMessage': '钉钉消息设置',
+ 'setting.addDingMessage': '添加通知',
+ 'setting.submit': '更新设置',
+ 'setting.versioning': '版本信息',
+ 'setting.notification.build': '构建',
+
+ 'builds.buildNumber': '构建号',
+ 'builds.buildLog': '构建日志',
+ 'builds.jobName': '构建名',
+ 'builds.platform': '平台',
+ 'builds.buildEndTime': '完成时间',
+ 'builds.detailInfo': '详情',
+ 'builds.rank': '排行',
+
+ 'buildinfo.pkgTab': '包信息',
+ 'buildinfo.testTab': '测试结果',
+ 'buildinfo.extraTab': '扩展信息',
+ 'buildinfo.filesTab': '构建产物',
+
+ 'buildinfo.pkg.version': '版本号',
+ 'buildinfo.pkg.type': '类型',
+ 'buildinfo.pkg.download': '下载',
+ 'buildinfo.pkg.gitBranch': '代码分支',
+ 'buildinfo.pkg.gitCommit': '提交链接',
+ 'buildinfo.pkg.gitInfo': '提交信息',
+ 'buildinfo.pkg.committer': '提交人',
+ 'buildinfo.pkg.commitTime': '提交时间',
+ 'buildinfo.state': '构建状态',
+
+ 'buildinfo.test.lineCoverage': '行覆盖率',
+ 'buildinfo.test.passPercent': '通过率',
+ 'buildinfo.test.testReporter': '测试报告',
+ 'buildinfo.test.coverageReporter': '覆盖率报告',
+ 'buildinfo.test.reporter': '查看',
+
+ 'buildinfo.extra.extraName': '项',
+ 'buildinfo.extra.extraContent': '内容',
+
+ 'buildinfo.files.fileName': '文件名称',
+ 'buildinfo.files.fileAddress': '下载地址',
+
+ 'insight.builds.number': '应用总数',
+ 'insight.builds.trend': '构建趋势',
+ 'insight.builds.top': '榜单',
+ 'insight.test.lineCoverage': '平均行覆盖率',
+ 'insight.test.lineCoverage.tip': '测试覆盖率的平均值',
+ 'insight.test.lineCoverage.history': '测试覆盖率历史记录',
+ 'insight.test.lineCoverage.latest': '行覆盖率',
+ 'insight.test.passPercent': '通过率',
+ 'insight.test.passPercent.tip': 'CI 100% 成功次数 / CI 执行次数',
+ 'insight.test.passPercent.history': '通过率历史记录',
+ 'insight.test.duration': '平均构建时长',
+ 'insight.test.duration.history': '构建时长历史记录',
+ 'insight.committer': '最后提交',
+ 'insight.dateRange.tip': '请选择时间范围,不选择将会统计全部数据',
+
+};
diff --git a/view/src/index.js b/view/src/index.js
new file mode 100644
index 0000000..e87dfd5
--- /dev/null
+++ b/view/src/index.js
@@ -0,0 +1,64 @@
+'use strict';
+
+import React from 'react';
+import ReactDOM from 'react-dom';
+import { Route, BrowserRouter } from 'react-router-dom';
+import { addLocaleData, IntlProvider } from 'react-intl';
+
+import zhCN from './i18n/zh_CN';
+import enUS from './i18n/en_US';
+import zh from 'react-intl/locale-data/zh';
+import en from 'react-intl/locale-data/en';
+
+import Builds from './page/Builds';
+import Setting from './page/Setting';
+import Insight from './page/Insight';
+import OneBuild from './page/OneBuild';
+import BuildLog from './page/BuildLog';
+import SnsAuthorize from './page/SnsAuthorize';
+
+import './index.less';
+
+addLocaleData([
+ ...en,
+ ...zh,
+]);
+
+const chooseLocale = () => {
+ const language = window.localStorage.RELIABLE_LANGUAGE || window.navigator.language;
+ switch (language) {
+ case 'zh-CN':
+ case 'zh-HK':
+ case 'zh-TW':
+ case 'zh':
+ return {
+ locale: 'zh-CN',
+ messages: zhCN,
+ };
+ default:
+ return {
+ locale: 'en-US',
+ messages: enUS,
+ };
+ }
+};
+const lang = chooseLocale();
+
+ReactDOM.render(
+
+
+
+
+
+
+
+
+
+
+
+ ,
+ document.querySelector('#app')
+);
diff --git a/view/src/index.less b/view/src/index.less
new file mode 100644
index 0000000..c07790c
--- /dev/null
+++ b/view/src/index.less
@@ -0,0 +1,67 @@
+#app {
+ height: 100%;
+ > div {
+ height: 100%;
+ > div {
+ height: 100%;
+ }
+ }
+}
+
+.ant-layout-content {
+ min-height: auto !important;
+}
+
+.ant-menu-dark {
+ margin-top: 20px !important;
+}
+
+.color {
+ &-egt-90 {
+ background: #f6ffed;
+ }
+
+ &-egt-80 {
+ background: #fcffe6;
+ }
+
+ &-egt-70 {
+ background: #fffbe6;
+ }
+
+ &-egt-50 {
+ background: #fff7e6;
+ }
+
+ &-egt-30 {
+ background: #fff2e8;
+ }
+
+ &-egt-0 {
+ background: #fff1f0;
+ }
+}
+
+.ant-table pre {
+ white-space: pre;
+ white-space: pre-wrap;
+}
+
+.ant-table span.itemName {
+ display: inline-block;
+ white-space: pre-wrap;
+ width: 100px;
+}
+
+body, div,
+dl, dt, dd,
+ul, ol, li,
+h1, h2, h3, h4, h5, h6,
+pre, code,
+form, fieldset, legend, input,
+textarea, p, blockquote,
+th, td, hr, button, article, aside, details,
+figcaption, figure, footer, header, hgroup, menu, nav, section {
+ margin: 0;
+ padding: 0;
+}
diff --git a/view/src/page/Blank.js b/view/src/page/Blank.js
new file mode 100644
index 0000000..16bc86b
--- /dev/null
+++ b/view/src/page/Blank.js
@@ -0,0 +1,23 @@
+'use strict';
+
+import React from 'react';
+import { Link } from 'react-router-dom';
+import { FormattedMessage } from 'react-intl';
+import { Alert, Breadcrumb } from 'antd';
+
+import ReliableLayout from '../components/ReliableLayout';
+
+export default class Setting extends React.Component {
+ render () {
+ return (
+
+
+
+
+
+
+
+
+ );
+ }
+}
diff --git a/view/src/page/BuildLog.js b/view/src/page/BuildLog.js
new file mode 100644
index 0000000..6c8bf5d
--- /dev/null
+++ b/view/src/page/BuildLog.js
@@ -0,0 +1,32 @@
+'use strict';
+
+import React from 'react';
+import {
+ Breadcrumb,
+} from 'antd';
+import { Link } from 'react-router-dom';
+import { FormattedMessage } from 'react-intl';
+
+import { queryParse } from '../util/index';
+import ReliableLayout from '../components/ReliableLayout';
+
+export default class BuildLog extends React.Component {
+ render () {
+ const { buildNumber, jobName } = queryParse(location.search);
+
+ return (
+
+
+
+
+
+
+
+
+ {`${jobName} / ${buildNumber}`}
+
+
+ );
+ }
+}
+
diff --git a/view/src/page/Builds.js b/view/src/page/Builds.js
new file mode 100644
index 0000000..633e8ce
--- /dev/null
+++ b/view/src/page/Builds.js
@@ -0,0 +1,32 @@
+'use strict';
+
+import React from 'react';
+import { Link } from 'react-router-dom';
+import { Breadcrumb } from 'antd';
+import { FormattedMessage } from 'react-intl';
+
+import { queryParse } from '../util/index';
+import BuildsTabs from '../components/BuildsTabs';
+import ReliableLayout from '../components/ReliableLayout';
+
+export default class Builds extends React.Component {
+ render () {
+ const jobName = queryParse(location.search).jobName;
+ return (
+
+
+
+
+
+
+
+
+
+ { jobName ? {jobName} : '' }
+
+
+
+
+ );
+ }
+}
diff --git a/view/src/page/Insight.js b/view/src/page/Insight.js
new file mode 100644
index 0000000..14d0d32
--- /dev/null
+++ b/view/src/page/Insight.js
@@ -0,0 +1,388 @@
+'use strict';
+
+import {
+ Row,
+ Col,
+ Spin,
+ Icon,
+ Table,
+ Popover,
+ Breadcrumb,
+ DatePicker,
+} from 'antd';
+import React from 'react';
+import safeGet from 'lodash.get';
+
+import { Link } from 'react-router-dom';
+import { FormattedMessage } from 'react-intl';
+
+import request from '../util/request';
+import ChartCard from '../components/ChartCard';
+import {
+ logger,
+ mapNumberToColor,
+} from '../util/index';
+import ReliableLayout from '../components/ReliableLayout';
+
+const RangePicker = DatePicker.RangePicker;
+
+const topColResponsiveProps = {
+ xs: 24,
+ sm: 10,
+ md: 10,
+ lg: 10,
+ xl: 8,
+ style: { marginBottom: 24 },
+};
+
+export default class Builds extends React.Component {
+ state = {
+ loading: false,
+ total: 0,
+ data: [],
+ loading1: true,
+ loading2: true,
+ loading3: true,
+ loading4: true,
+ };
+
+ componentDidMount () {
+ this.fetch();
+ }
+
+ fetch = async ([ startDate, endDate ] = []) => {
+ this.setState({
+ loading: true,
+ });
+ const insightRes = await request('insight/ci', 'GET', {
+ startDate,
+ endDate,
+ allBranches: 'Y',
+ });
+ if (insightRes.success) {
+ try {
+ const result = insightRes.data;
+ logger('buildData res', result);
+ this.setState({
+ loading1: false,
+ loading3: false,
+ loading4: false,
+ data: result,
+ });
+ } catch (e) {
+ console.error(insightRes, e);
+ }
+ } else {
+ console.error(insightRes);
+ }
+
+ const buildsRes = await request('getBuildsTable', 'GET', {});
+ if (buildsRes.success) {
+ try {
+ const {
+ total,
+ } = buildsRes.data;
+ this.setState({
+ loading2: false,
+ total,
+ });
+ } catch (e) {
+ console.error('getBuildsTable error', buildsRes, e);
+ }
+ } else {
+ console.error('getBuildsTable error', buildsRes);
+ }
+ }
+
+ changeDateRange = async (date, dateString) => {
+ console.log(date, dateString);
+ await this.fetch(dateString);
+ }
+
+
+ getColumns = () => {
+ return [{
+ title: ,
+ key: 'rank',
+ render: (text, record, index) => {++index} ,
+ align: 'center',
+ width: 80,
+ }, {
+ title: ,
+ dataIndex: 'jobName',
+ render: (text, record) =>
+
+ {text}
+ ,
+ }, {
+ title: ,
+ dataIndex: 'linePercentList[0].linePercent',
+ render: (text, record) => {
+ const coverageUrl = safeGet(record, 'linePercentList[0].coverageUrl');
+ if (!text) {
+ return '-';
+ }
+ return
+ {text}%
+ ;
+ },
+ }, {
+ title:
+
+ }>
+
+
+ ,
+ dataIndex: 'linePercent',
+ render: (text, record) => {
+ return record.linePercent
+ ? }
+ content={
+ {
+ return {record.shortHash} ;
+ },
+ },
+ {
+ title: 'Coverage',
+ dataIndex: 'coverageUrl',
+ key: 'commitUrl',
+ align: 'center',
+ render: (text, record) => {
+ return record.linePercent
+ ? {record.linePercent}%
+ : '-';
+ },
+ },
+ {
+ title: 'CreatedAt',
+ dataIndex: 'createdAt',
+ key: 'createdAt',
+ align: 'right',
+ },
+ ]}
+ />
+ }
+ >
+ {record.linePercent}%
+
+ : '-';
+ },
+ }, {
+ title:
+
+ }>
+
+
+ ,
+ dataIndex: 'passPercent',
+ render: (text, record) => {
+ return record.passPercent
+ ? }
+ content={
+ {
+ return {record.shortHash} ;
+ },
+ },
+ {
+ title: 'Pass Percentage',
+ dataIndex: 'reporterUrl',
+ key: 'reporterUrl',
+ align: 'right',
+ render: (text, record) => {
+ return record.passPercent
+ ? {record.passPercent}%
+ : '-';
+ },
+ },
+ {
+ title: 'CreatedAt',
+ dataIndex: 'createdAt',
+ key: 'createdAt',
+ align: 'right',
+ },
+ ]}
+ />
+ }
+ >
+ {record.passPercent}%
+
+ : '-';
+ },
+ }, {
+ title: ,
+ dataIndex: 'humanizeDuration',
+ render: (text, record) => {
+ return record.humanizeDuration
+ ? }
+ content={
+ {
+ return {record.shortHash} ;
+ },
+ },
+ {
+ title: 'Duration',
+ dataIndex: 'duration',
+ key: 'duration',
+ align: 'right',
+ },
+ {
+ title: 'CreatedAt',
+ dataIndex: 'createdAt',
+ key: 'createdAt',
+ align: 'right',
+ },
+ ]}
+ />
+ }
+ >
+ {record.humanizeDuration}
+
+ : '-';
+ },
+ }, {
+ title: ,
+ dataIndex: 'committer',
+ render: (text, record) =>
+ { record.lastCommit.committer } ,
+ }];
+ }
+
+ getData = () => {
+ const data = this.state.data.sort((a, b) => {
+ return parseFloat(b.linePercent || 0) - parseFloat(a.linePercent || 0);
+ });
+ console.log('data =>', data);
+ return data;
+ }
+
+ getTopCard () {
+ const list = this.getData();
+ if (!list.length) {
+ return (
+ No Rank
+ );
+ }
+ const first = list[0];
+ const last = list.length === 1 ? { jobName: '', committer: '' } : list[list.length - 1];
+
+ return (
+
+
+
+
+ {first.jobName}
+
+
+ {first.committer}
+
+
+
+
+
+ {last.jobName}
+
+
+ {last.committer}
+
+
+
+ );
+ }
+
+ render () {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ }
+ loading={this.state.loading1}
+ content={this.state.data.length}
+ />
+
+
+ }
+ loading={this.state.loading2}
+ content={this.state.total}
+ />
+
+
+ }
+ loading={this.state.loading3}
+ content={this.getTopCard()}
+ />
+
+
+
+
+
+
+
+
+
+
+ }
+ onChange={this.changeDateRange}
+ />
+
+
+ record.jobName}
+ rowClassName={record => mapNumberToColor(record.linePercent)}
+ dataSource={this.getData()}
+ columns={this.getColumns()}
+ pagination={false}
+ />
+
+
+ );
+ }
+}
diff --git a/view/src/page/OneBuild.js b/view/src/page/OneBuild.js
new file mode 100644
index 0000000..7dba9a8
--- /dev/null
+++ b/view/src/page/OneBuild.js
@@ -0,0 +1,75 @@
+'use strict';
+
+import React from 'react';
+import { Breadcrumb } from 'antd';
+import { Link } from 'react-router-dom';
+import { FormattedMessage } from 'react-intl';
+
+import request from '../util/request';
+import { logger, queryParse } from '../util/index';
+import OneBuildTabs from '../components/OneBuildTabs';
+import ReliableLayout from '../components/ReliableLayout';
+
+export default class Builds extends React.Component {
+ state = {
+ data: {},
+ loading: true,
+ };
+
+ fetchOneBuild = () => {
+ this.setState({
+ loading: true,
+ });
+ request('getOneBuildTable', 'GET', queryParse(location.href)).then(res => {
+ logger('getOneBuildTable res', res);
+ if (res.success) {
+ try {
+ const buildData = res.data.data;
+ buildData.buildUniqId = res.data.uniqId;
+ logger('buildData res', buildData);
+ this.setState({
+ data: buildData,
+ });
+ } catch (e) {
+ console.error('getOneBuildTable error', res, e);
+ }
+ } else {
+ console.error('getOneBuildTable failed', res);
+ }
+ this.setState({ loading: false });
+ }).catch(e => {
+ console.error('getOneBuildTable error', e);
+ this.setState({ loading: false });
+ });
+ }
+
+ componentDidMount () {
+ this.fetchOneBuild();
+ }
+
+ render () {
+ const { buildNumber, jobName } = queryParse(location.search);
+ return (
+
+
+
+
+
+
+
+
+
+ {jobName}
+
+ {buildNumber}
+
+
+
+
+ );
+ }
+}
diff --git a/view/src/page/Setting.js b/view/src/page/Setting.js
new file mode 100644
index 0000000..96457f2
--- /dev/null
+++ b/view/src/page/Setting.js
@@ -0,0 +1,53 @@
+'use strict';
+
+import React from 'react';
+import { Link } from 'react-router-dom';
+import { FormattedMessage } from 'react-intl';
+import {
+ Row,
+ Col,
+ Card,
+ Breadcrumb,
+} from 'antd';
+
+import ReliableLayout from '../components/ReliableLayout';
+import DingdingSetting from '../components/DingdingSetting';
+
+import pkg from '../../package.json';
+
+import './Setting.less';
+
+export default class Setting extends React.Component {
+
+ render () {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ }>
+
+
+
+
+
+
+ }>
+
+
+ reliable-web: { window.pageConfig.version }
+
+
+
+
+ );
+ }
+}
+
diff --git a/view/src/page/Setting.less b/view/src/page/Setting.less
new file mode 100644
index 0000000..be91d60
--- /dev/null
+++ b/view/src/page/Setting.less
@@ -0,0 +1,5 @@
+.confirm-delete-credential {
+ .ant-confirm-content {
+ margin-left: 0;
+ }
+}
\ No newline at end of file
diff --git a/view/src/page/SnsAuthorize.js b/view/src/page/SnsAuthorize.js
new file mode 100644
index 0000000..09030ba
--- /dev/null
+++ b/view/src/page/SnsAuthorize.js
@@ -0,0 +1,86 @@
+import React from 'react';
+import Script from 'react-load-script';
+import queryString from 'query-string';
+import safeGet from 'lodash.get';
+import {
+ Icon,
+} from 'antd';
+
+import ReliableLayout from '../components/ReliableLayout';
+
+const appid = safeGet(window, 'context.dingtalkAuth.appid');
+const callbackUrl = `${location.protocol}//${location.hostname}${safeGet(window, 'context.dingtalkAuth.callbackUrl')}`;
+
+export default class Builds extends React.Component {
+ handleScriptError = () => {
+ console.log('handleScriptError');
+ }
+ handleScriptLoad = () => {
+ this.addHandler();
+ this.initAuth();
+ }
+ addHandler = () => {
+ const hanndleMessage = event => {
+ const origin = event.origin;
+ if (origin === 'https://login.dingtalk.com') {
+ const loginTmpCode = event.data;
+ this.redirectToCallbackUrl(loginTmpCode);
+ }
+ };
+
+ if (typeof window.addEventListener !== 'undefined') {
+ window.addEventListener('message', hanndleMessage, false);
+ } else if (typeof window.attachEvent !== 'undefined') {
+ window.attachEvent('onmessage', hanndleMessage);
+ }
+ }
+ redirectToCallbackUrl = loginTmpCode => {
+ const query = queryString.stringify({
+ appid,
+ response_type: 'code',
+ scope: 'snsapi_login',
+ state: 'STATE',
+ redirect_uri: callbackUrl,
+ loginTmpCode,
+ });
+ location.href = `https://oapi.dingtalk.com/connect/oauth2/sns_authorize?${query}`;
+ }
+ initAuth = () => {
+ const query = queryString.stringify({
+ appid,
+ response_type: 'code',
+ scope: 'snsapi_login',
+ state: 'STATE',
+ redirect_uri: callbackUrl,
+ });
+ const goto = encodeURIComponent(`https://oapi.dingtalk.com/connect/oauth2/sns_authorize?${query}`);
+ window.DDLogin({
+ id: 'login_container',
+ goto,
+ style: 'border:none;background-color:#FFFFFF;',
+ width: '365',
+ height: '400',
+ });
+ }
+ render () {
+ return (
+
+
+
+
Sign in with Dingtalk
+
+
+
+
+
+ );
+ }
+}
diff --git a/view/src/util/getServer.js b/view/src/util/getServer.js
new file mode 100644
index 0000000..e5806e4
--- /dev/null
+++ b/view/src/util/getServer.js
@@ -0,0 +1,39 @@
+'use strict';
+
+import queryString from 'query-string';
+
+import {
+ SERVER_ADDRESS,
+} from '../constants/index';
+
+export const getServer = (url, param) => {
+ if (url === 'getOneBuildTable') {
+ return `${SERVER_ADDRESS}/api/build?jobName=${param.jobName}&buildNumber=${param.buildNumber}`;
+ }
+
+ if (url === 'insight/ci') {
+ return `${SERVER_ADDRESS}/api/insight/ci?${queryString.stringify({
+ startDate: param.startDate,
+ endDate: param.endDate,
+ allBranches: param.allBranches,
+ })}`;
+ }
+
+ if (url === 'getBuildsTable') {
+ const queryParams = {
+ page: param.page,
+ num: param.num,
+ };
+ if (param.jobName) {
+ queryParams.jobName = param.jobName;
+ }
+ const qs = queryString.stringify(queryParams);
+ return `${SERVER_ADDRESS}/api/build?${qs}`;
+ }
+
+ if (url === 'getWebhooks' || url === 'postWebhooks') {
+ return `${SERVER_ADDRESS}/api/config`;
+ }
+
+ return `${SERVER_ADDRESS}/api/delegate/message`;
+};
diff --git a/view/src/util/index.js b/view/src/util/index.js
new file mode 100644
index 0000000..c937b96
--- /dev/null
+++ b/view/src/util/index.js
@@ -0,0 +1,70 @@
+'use strict';
+
+export const queryParse = url => {
+ const qs = {};
+ if (!url) {
+ return qs;
+ }
+ url.replace(/([^?=&]+)(=([^&]*))?/g, ($0, $1, $2, $3) => {
+ if ($3 === undefined) {
+ return;
+ }
+ qs[$1] = decodeURIComponent($3);
+ });
+ return qs;
+};
+
+export const getUuid = () => {
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
+ const r = Math.random() * 16 | 0;
+ const v = c === 'x' ? r : (r & 0x3 | 0x8);
+ return v.toString(16);
+ });
+};
+
+export const logger = (...content) => {
+ const debugMode = location.href.indexOf('__debug') > -1;
+
+ if (!debugMode) {
+ return;
+ }
+ console.log(...content);
+};
+
+export const getBuildLink = (data, target) => {
+ // TODO remove
+
+ if (!target) {
+ return '';
+ };
+
+ if (target.startsWith('http://') || target.startsWith('https://')) {
+ return target;
+ }
+ return;
+};
+
+export const mapBuildDataToColor = data => {
+ const state = data && data.state;
+ if (state === 'INIT') return;
+
+ const number = data && data.testInfo && data.testInfo.linePercent;
+ return mapNumberToColor(number);
+};
+
+export const mapNumberToColor = number => {
+ let color = 'egt-0';
+
+ if (number >= 90) {
+ color = 'egt-90';
+ } else if (number >= 80) {
+ color = 'egt-80';
+ } else if (number >= 70) {
+ color = 'egt-70';
+ } else if (number >= 60) {
+ color = 'egt-50';
+ } else if (number >= 50) {
+ color = 'egt-30';
+ }
+ return `color-${color}`;
+};
diff --git a/view/src/util/request.js b/view/src/util/request.js
new file mode 100644
index 0000000..c1b4825
--- /dev/null
+++ b/view/src/util/request.js
@@ -0,0 +1,70 @@
+'use strict';
+
+import 'whatwg-fetch';
+
+import { getServer } from './getServer';
+
+const verbs = {
+ GET (url) {
+ return fetch(url, {
+ credentials: 'same-origin',
+ headers: {
+ Accept: 'application/json',
+ 'Content-Type': 'application/json',
+ },
+ });
+ },
+
+ POST (url, params) {
+ return fetch(url, {
+ method: 'POST',
+ credentials: 'same-origin',
+ headers: {
+ Accept: 'application/json',
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(params),
+ });
+ },
+
+ DELETE (url) {
+ return fetch(url, {
+ method: 'DELETE',
+ credentials: 'same-origin',
+ headers: {
+ Accept: 'application/json',
+ 'Content-Type': 'application/json',
+ },
+ });
+ },
+
+ PUT (url, params) {
+ return fetch(url, {
+ method: 'PUT',
+ credentials: 'same-origin',
+ headers: {
+ Accept: 'application/json',
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(params),
+ });
+ },
+};
+
+export default async (url, method = 'GET', params = {}) => {
+ const res = await verbs[method](getServer(url, params), params);
+ if (res.ok) {
+ return res.json();
+ } else if (res.status === 401) {
+ const data = await res.json();
+ if (data.url) {
+ location.href = url;
+ return;
+ }
+ } else {
+ return {
+ success: false,
+ message: 'Network Errror',
+ };
+ };
+};
diff --git a/view/test/mocha.opts b/view/test/mocha.opts
new file mode 100644
index 0000000..28824cb
--- /dev/null
+++ b/view/test/mocha.opts
@@ -0,0 +1,4 @@
+--reporter macaca-reporter
+--require @babel/register
+--recursive
+--timeout 30000
diff --git a/view/test/reliable-config.test.js b/view/test/reliable-config.test.js
new file mode 100644
index 0000000..1be4414
--- /dev/null
+++ b/view/test/reliable-config.test.js
@@ -0,0 +1,51 @@
+'use strict';
+
+import {
+ webpackHelper,
+} from 'macaca-wd';
+
+const {
+ driver,
+ BASE_URL,
+} = webpackHelper;
+
+describe('test/reliable-config.test.js', () => {
+ describe('notification config', () => {
+ before(() => {
+ return driver
+ .initWindow({
+ width: 800,
+ height: 600,
+ deviceScaleFactor: 2,
+ });
+ });
+
+ afterEach(function () {
+ return driver
+ .coverage()
+ .saveScreenshots(this);
+ });
+
+ after(() => {
+ return driver
+ .openReporter(false)
+ .quit();
+ });
+
+ it('add config', () => {
+ return driver
+ .getUrl(`${BASE_URL}/setting`)
+ .elementByCss('[data-accessibilityid=add-notification]')
+ .click()
+ .sleep(500)
+ .waitForElementByCss('[data-accessibilityid="dingtalk-webhooks"] .ant-form-item:nth-child(1) input')
+ .sendKeys('http://host.local')
+ .waitForElementByCss('[data-accessibilityid="dingtalk-webhooks"] .ant-form-item:nth-child(2) input')
+ .sendKeys('http://host2.local')
+ .elementByCss('[data-accessibilityid="dingtalk-webhooks"] form > .ant-form-item:last-child button')
+ .click()
+ .waitForElementByCss('.ant-message')
+ .hasText('Update webhooks successfully!');
+ });
+ });
+});
diff --git a/view/webpack.config.js b/view/webpack.config.js
new file mode 100644
index 0000000..f0f9bf4
--- /dev/null
+++ b/view/webpack.config.js
@@ -0,0 +1,91 @@
+'use strict';
+
+const path = require('path');
+const webpack = require('webpack');
+const MiniCssExtractPlugin = require('mini-css-extract-plugin');
+const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
+
+const pkg = require('./package');
+
+module.exports = (env, argv) => {
+ const isProduction = argv.mode === 'production';
+
+ const webpackConfig = {
+ stats: {
+ publicPath: true,
+ chunks: false,
+ modules: false,
+ children: false,
+ entrypoints: false,
+ chunkModules: false,
+ },
+
+ devtool: isProduction ? false : 'source-map',
+
+ entry: {
+ [pkg.name]: path.resolve('src'),
+ },
+
+ output: {
+ path: path.join(__dirname, 'dist'),
+ publicPath: 'dist',
+ filename: '[name].js',
+ },
+
+ resolve: {
+ extensions: [
+ '.js',
+ '.jsx',
+ ],
+ },
+
+ module: {
+ rules: [
+ {
+ test: /\.js[x]?$/,
+ exclude: /node_modules/,
+ use: 'babel-loader',
+ }, {
+ test: /\.less$/,
+ use: [
+ MiniCssExtractPlugin.loader,
+ {
+ loader: 'css-loader',
+ },
+ {
+ loader: 'postcss-loader',
+ },
+ {
+ loader: 'less-loader',
+ },
+ ],
+ }, {
+ test: /\.css$/,
+ use: [
+ MiniCssExtractPlugin.loader,
+ 'css-loader',
+ ],
+ }, {
+ test: /\.svg$/,
+ loader: 'url-loader',
+ },
+ ],
+ },
+ plugins: [
+ new MiniCssExtractPlugin({
+ filename: '[name].css',
+ chunkFilename: '[id].css',
+ }),
+ ],
+ };
+
+ if (!isProduction) {
+ webpackConfig.plugins.push(new webpack.HotModuleReplacementPlugin());
+ }
+
+ if (process.env.npm_config_report) {
+ webpackConfig.plugins.push(new BundleAnalyzerPlugin());
+ }
+
+ return webpackConfig;
+};